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 - **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. 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 - **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 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 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.", "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_connection": "Connection",
"tab_bus": "Bus initialisation", "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", "close": "Close",
"chip_hint": "Open device setup — {name}" "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": { "connection": {
"title": "Controller connection", "title": "Controller connection",
"mode": "Connection", "mode": "Connection",
+30
View File
@@ -34,9 +34,39 @@
"hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。", "hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
"tab_connection": "连接", "tab_connection": "连接",
"tab_bus": "总线初始化", "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": "关闭", "close": "关闭",
"chip_hint": "点击打开设备设置 — {name}" "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": { "connection": {
"title": "连接控制器", "title": "连接控制器",
"mode": "连接方式", "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, 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. 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 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 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 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 **Gate:** the operator has seen the path and a dry run has completed without a
guard violation. 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 ## 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:])) args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
_force_utf8_streams() _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.compat import check_shared_compat
from orisys_app_shared.i18n import set_locale from orisys_app_shared.i18n import set_locale
from orisys_app_shared.logging import init_logging, install_exception_hooks from orisys_app_shared.logging import init_logging, install_exception_hooks
from orisys_app_shared.theme import build_qss from orisys_app_shared.theme import build_qss
from PySide6.QtWidgets import QApplication 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 .motion import ConnectionSpec, DemoTransport
from .preferences import load_preferences from .preferences import load_preferences
from .ui.window import AutoCalMainWindow from .ui.window import AutoCalMainWindow
@@ -16,7 +16,22 @@ from .models import (
Snapshot, Snapshot,
) )
from .poller import MotionPoller 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 .transport import DemoTransport, NativeTransport, ZmcTransport
from .units import ( from .units import (
DEFAULT_ACCEL, DEFAULT_ACCEL,
@@ -33,7 +48,9 @@ from .units import (
) )
__all__ = [ __all__ = [
"AXIS_ROLES",
"DEFAULT_ACCEL", "DEFAULT_ACCEL",
"DEFAULT_AXIS_ROLES",
"DEFAULT_BAS_TASK", "DEFAULT_BAS_TASK",
"DEFAULT_SPEED", "DEFAULT_SPEED",
"DEFAULT_UNITS", "DEFAULT_UNITS",
@@ -55,12 +72,18 @@ __all__ = [
"MotionPoller", "MotionPoller",
"MotionResult", "MotionResult",
"MotionService", "MotionService",
"MovePlan",
"MoveStep",
"NativeTransport", "NativeTransport",
"PoseMover",
"Snapshot", "Snapshot",
"ZmcTransport", "ZmcTransport",
"clamp_bas_task", "clamp_bas_task",
"deg_to_user_units", "deg_to_user_units",
"mm_to_user_units", "mm_to_user_units",
"normalize_axis_roles",
"plan_pose_move",
"plan_return_to_initial",
"safe_float", "safe_float",
"units_per_degree", "units_per_degree",
"user_units_to_deg", "user_units_to_deg",
@@ -438,7 +438,7 @@ class PlatformStack:
def alignment_branches( def alignment_branches(
self, unit: np.ndarray, tool: np.ndarray self, unit: np.ndarray, tool: np.ndarray
) -> list["Branch"]: ) -> list[Branch]:
"""Every exact pose that puts ``unit`` on ``tool``, in closed form. """Every exact pose that puts ``unit`` on ``tool``, in closed form.
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so: 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)) return math.degrees(math.atan2(sine_term, cosine_term))
@staticmethod @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. """Pick from :meth:`alignment_branches`: reachable, then upright, then best.
Every branch aligns to within floating point, so alignment itself rarely 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 stages is to align. The tie only decides between poses that are the same
to any tolerance the machine can hold. 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 # Alignment first: if one branch genuinely aligns better, that is the
# answer, because aligning is what the stages are for. Reachability and # answer, because aligning is what the stages are for. Reachability and
# orientation decide between branches that align the same, which is the # orientation decide between branches that align the same, which is the
@@ -1051,7 +1051,7 @@ class PlatformStack:
def _better_pose( def _better_pose(
candidate: tuple[float, float, float], candidate: tuple[float, float, float],
best: tuple[float, float, float], best: tuple[float, float, float],
stack: "PlatformStack", stack: PlatformStack,
) -> bool: ) -> bool:
"""Rank two ``(residual, yaw, pitch)`` candidates. """Rank two ``(residual, yaw, pitch)`` candidates.
@@ -173,14 +173,14 @@ class MotionResult:
message: str = "" message: str = ""
@classmethod @classmethod
def success(cls, message: str = "") -> "MotionResult": def success(cls, message: str = "") -> MotionResult:
return cls(ok=True, code=0, message=message) return cls(ok=True, code=0, message=message)
@classmethod @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.""" """The command was not sent: a guard rejected it before any motion."""
return cls(ok=False, code=0, message=message) return cls(ok=False, code=0, message=message)
@classmethod @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) return cls(ok=False, code=int(code), message=message)
@@ -21,7 +21,8 @@ Contract:
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Callable, Iterable from collections.abc import Callable, Iterable
from typing import Any
from PySide6.QtCore import ( from PySide6.QtCore import (
QMetaObject, QMetaObject,
@@ -288,7 +289,6 @@ class MotionPoller(QObject):
# closes immediately and leaves the platform coasting. # closes immediately and leaves the platform coasting.
service.stop_all() service.stop_all()
service.disconnect() service.disconnect()
return None
return self.submit(_disconnect) 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. 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 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 — for a stated reason — bus not initialised, axis not idle, non-numeric input, a
and :meth:`MotionService.jog` and friends return that reason as a target past the P0 travel limit, an axis mapping that does not add up — and
``MotionResult`` instead of silently doing nothing. :meth:`MotionService.jog` and friends return that reason as a ``MotionResult``
instead of silently doing nothing.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import math import math
import time
import os import os
from typing import Callable, Iterable import time
from collections.abc import Callable, Iterable, Mapping
from orisys_app_shared.zmotion import ZAuxError from orisys_app_shared.zmotion import ZAuxError
@@ -34,13 +35,14 @@ from .models import (
MotionResult, MotionResult,
Snapshot, Snapshot,
) )
from .transport import DemoTransport, ZmcTransport, NativeTransport from .transport import NativeTransport, ZmcTransport
from .units import ( from .units import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
DEFAULT_UNITS, DEFAULT_UNITS,
PULSES_PER_MM, PULSES_PER_MM,
deg_to_user_units,
mm_to_user_units,
safe_float, safe_float,
units_per_degree,
user_units_to_mm, 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. #: The platform has five axes; the bus may report fewer if a drive is missing.
PLATFORM_AXES = 5 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`` values written by the vendor ``.bas`` programs.
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"} _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 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: def _online_failure(reply: str) -> str:
"""The controller's own refusal text from an online command, or ``""``. """The controller's own refusal text from an online command, or ``""``.
@@ -107,6 +156,8 @@ class MotionService:
pulses_per_mm: float = PULSES_PER_MM, pulses_per_mm: float = PULSES_PER_MM,
language: str = "zh_CN", language: str = "zh_CN",
bas_task: int = DEFAULT_BAS_TASK, bas_task: int = DEFAULT_BAS_TASK,
axis_roles: Mapping[str, int] | None = None,
limits: Mapping[str, float] | None = None,
) -> None: ) -> None:
self._transport: ZmcTransport = transport or NativeTransport() self._transport: ZmcTransport = transport or NativeTransport()
self.axis_count = int(axis_count) self.axis_count = int(axis_count)
@@ -114,6 +165,14 @@ class MotionService:
self._pulses_per_mm = pulses_per_mm self._pulses_per_mm = pulses_per_mm
self.language = language self.language = language
self.bas_task = clamp_bas_task(bas_task) 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._spec: ConnectionSpec | None = None
self._bus = BusStatus() self._bus = BusStatus()
@@ -151,6 +210,152 @@ class MotionService:
def params(self, axis: int) -> AxisParams: def params(self, axis: int) -> AxisParams:
return self._params.get(axis, 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: def set_language(self, language: str) -> None:
self.language = language 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 import prefs as _prefs
from orisys_app_shared.paths import preference_path 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 from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
#: Connection modes, in the order the combo offers them. #: 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 ``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. 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 { return {
"max_travel_mm": 100.0, "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 #: How far the axis may descend looking for contact before the run is
#: aborted (workflow P7, no-contact abort). #: aborted (workflow P7, no-contact abort).
"contact_search_mm": 3.0, "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", "sensor_type": "box",
"config_ref": "", "config_ref": "",
"geometry_id": "", "geometry_id": "",
#: Which axis presses, and the probe tip offset from the platform's #: The probe tip's offset from the platform's reference point. Empty offset
#: reference point. Empty offset means "not set", which blocks P5. #: means "not set", which blocks P5. Which axis presses is **not** here: it
"press_axis": 2, #: 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_offset_mm": [0.0, 0.0, 0.0],
"probe_tip_set": False, "probe_tip_set": False,
#: 4x4 row-major sensor -> platform transform, plus how well it fitted. #: 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) -------------- # --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
#: P0: limits enforced from P2 onward. #: P0: limits enforced from P2 onward.
"safety_limits": _default_safety_limits(), "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. #: P3/P4: sensor under test, probe tip, sensor<->platform transform.
"sensor_setup": _default_sensor_setup(), "sensor_setup": _default_sensor_setup(),
#: P5: grid generation and run parameters. #: 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]: def load_preferences() -> dict[str, Any]:
"""Stored preferences with AutoCal's defaults filling the gaps.""" """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. # A stored document from an older build may miss a newly added axis block.
axis_params = prefs.setdefault("axis_params", {}) axis_params = prefs.setdefault("axis_params", {})
for axis, values in _default_axis_params().items(): for axis, values in _default_axis_params().items():
@@ -252,6 +297,39 @@ def load_preferences() -> dict[str, Any]:
prefs["finger_tool_xy_mm"] = tool prefs["finger_tool_xy_mm"] = tool
# A hand-edited task number must not reach the controller as typed. # A hand-edited task number must not reach the controller as typed.
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task")) 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 return prefs
@@ -1,7 +1,8 @@
"""Sensor assets: export bundles, the flat UV view, and the mount frames.""" """Sensor assets: export bundles, the flat UV view, and the mount frames."""
from .export_bundle import BundleError, SensorBundle, load_bundle 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 ( from .uv_flat import (
DEFAULT_FLAT_SIZE, DEFAULT_FLAT_SIZE,
FlatSurface, FlatSurface,
@@ -12,8 +13,8 @@ from .uv_flat import (
__all__ = [ __all__ = [
"AXIS_MAP", "AXIS_MAP",
"BundleError",
"DEFAULT_FLAT_SIZE", "DEFAULT_FLAT_SIZE",
"BundleError",
"FlatSurface", "FlatSurface",
"SensorBundle", "SensorBundle",
"describe_frames", "describe_frames",
@@ -26,10 +26,11 @@ from __future__ import annotations
import json import json
import math import math
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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. #: The version this build writes, and the highest it will read.
FORMAT_VERSION = "1.0" FORMAT_VERSION = "1.0"
@@ -14,6 +14,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
from __future__ import annotations from __future__ import annotations
import math
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QBrush, QColor from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -29,8 +32,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from ..motion import ( from ..motion import (
DEFAULT_ACCEL, DEFAULT_ACCEL,
DEFAULT_SPEED, DEFAULT_SPEED,
@@ -582,7 +583,7 @@ def _millimetres(units: float, user_units: float) -> str:
if not units: if not units:
return "—" return "—"
millimetres = user_units_to_mm(user_units, units=units) millimetres = user_units_to_mm(user_units, units=units)
if millimetres != millimetres: # NaN if math.isnan(millimetres):
return "—" return "—"
return f"{millimetres:.3f}" return f"{millimetres:.3f}"
@@ -1,17 +1,23 @@
"""The permanent axis readout rail. """The permanent axis readout rail.
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so Read-only apart from four platform-wide actions. Every per-axis *command* lives in
there is exactly one place a move can be issued from — two places to jog from is the Axis-debug tab, so there is exactly one place a single-axis move can be issued
how an operator moves the wrong axis. What this rail adds is the thing the from — two places to jog from is how an operator moves the wrong axis. What this rail
operator needs while working in any other tab: whether each drive is enabled, adds is the thing the operator needs while working in any other tab: whether each
where it is, and whether it is moving. 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 The four global actions are here rather than in a tab because they are the ones you
ones you reach for in a hurry, and they must not require finding a tab first. 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 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.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QGridLayout, QGridLayout,
@@ -23,9 +29,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import load_colors
from ..motion import Snapshot, user_units_to_mm from ..motion import Snapshot, user_units_to_mm
#: Columns of the compact readout: axis, enable state, position. #: Columns of the compact readout: axis, enable state, position.
@@ -41,6 +44,10 @@ class AxisReadoutPanel(QGroupBox):
enableAllRequested = Signal(bool) enableAllRequested = Signal(bool)
stopAllRequested = Signal() stopAllRequested = Signal()
clearAllRequested = 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: def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -72,7 +79,14 @@ class AxisReadoutPanel(QGroupBox):
self.stop_all_button.clicked.connect(self.stopAllRequested) self.stop_all_button.clicked.connect(self.stopAllRequested)
self.clear_all_button = QPushButton(self) self.clear_all_button = QPushButton(self)
self.clear_all_button.clicked.connect(self.clearAllRequested) 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) button.setProperty("rail", True)
outer.addWidget(button) outer.addWidget(button)
@@ -110,7 +124,12 @@ class AxisReadoutPanel(QGroupBox):
def set_connected(self, connected: bool) -> None: def set_connected(self, connected: bool) -> None:
self._connected = bool(connected) 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) button.setEnabled(self._connected)
def update_snapshot(self, snapshot: Snapshot) -> None: 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 millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
units_text = f"{state.dpos:.3f}" 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}") units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
widgets["position"].setText(units_text) widgets["position"].setText(units_text)
widgets["position"].setToolTip( widgets["position"].setToolTip(
@@ -166,5 +185,7 @@ class AxisReadoutPanel(QGroupBox):
self.setTitle(t("readout.title")) self.setTitle(t("readout.title"))
self.stop_all_button.setText(t("axes.stop_all")) self.stop_all_button.setText(t("axes.stop_all"))
self.clear_all_button.setText(t("axes.clear_alarm_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. # The toggle's label depends on current state, so re-derive it.
self.update_snapshot(self._snapshot) self.update_snapshot(self._snapshot)
@@ -7,13 +7,12 @@ controller is reachable.
from __future__ import annotations 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.i18n import t
from orisys_app_shared.paths import logo_topbar_path from orisys_app_shared.paths import logo_topbar_path
from orisys_app_shared.theme import load_colors 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. #: Chip tone -> colour token name.
_TONE_TOKENS = { _TONE_TOKENS = {
@@ -9,12 +9,12 @@ vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
from __future__ import annotations from __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QGridLayout, QGridLayout,
QGroupBox, QGroupBox,
QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
@@ -22,8 +22,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
#: Height of this panel's fields and buttons. Pinned for the same reason as the #: 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 #: 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. #: 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 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.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QGridLayout, QGridLayout,
@@ -23,9 +25,6 @@ from PySide6.QtWidgets import (
QWidget, 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 from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
_STATE_KEYS = { _STATE_KEYS = {
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
@@ -15,8 +16,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from ..motion import ConnectionSpec from ..motion import ConnectionSpec
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS 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 These are the facts about the machine itself, as opposed to the job: reach the
the bus program, watch the bus come up — and then not again for the rest of the controller, download the bus program, watch the bus come up, and say which drive moves
session. In the left rail they were spending ~460 px of its height on that, above which platform direction. All of them are touched once per machine start-up — or once
the axis readout that is read all day, which is what made the rail scroll. 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 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 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 several places — the strip's button, its state chips, the File menu — without any of
them having to block. 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 (``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. 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 __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QFormLayout,
QGroupBox,
QLabel, QLabel,
QTabWidget, QTabWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from ..motion import ConnectionSpec from ..motion import ConnectionSpec
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
from .bus_panel import BusPanel from .bus_panel import BusPanel
from .connection_panel import ConnectionPanel from .connection_panel import ConnectionPanel
#: Tab identity -> locale key, in the order they are shown. The order is the work: #: 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], ...] = ( TABS: tuple[tuple[str, str], ...] = (
("connection", "setup.tab_connection"), ("connection", "setup.tab_connection"),
("bus", "setup.tab_bus"), ("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): class DeviceSetupDialog(QDialog):
"""Connection and bus initialisation, the once-per-start controls.""" """Connection and bus initialisation, the once-per-start controls."""
@@ -49,6 +172,7 @@ class DeviceSetupDialog(QDialog):
self, self,
spec: ConnectionSpec, spec: ConnectionSpec,
bas_path: str = "", bas_path: str = "",
roles: dict[str, int] | None = None,
parent: QWidget | None = None, parent: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
@@ -70,6 +194,7 @@ class DeviceSetupDialog(QDialog):
self.tabs.setObjectName("setupTabs") self.tabs.setObjectName("setupTabs")
self.connection_panel = ConnectionPanel(spec, self.tabs) self.connection_panel = ConnectionPanel(spec, self.tabs)
self.bus_panel = BusPanel(bas_path, self.tabs) self.bus_panel = BusPanel(bas_path, self.tabs)
self.roles_page = AxisRolesPage(roles, self.tabs)
for key, locale_key in TABS: for key, locale_key in TABS:
self.tabs.addTab(self.panel(key), "") self.tabs.addTab(self.panel(key), "")
column.addWidget(self.tabs, 1) column.addWidget(self.tabs, 1)
@@ -90,8 +215,10 @@ class DeviceSetupDialog(QDialog):
# ----------------------------------------------------------------- content # ----------------------------------------------------------------- content
def panel(self, key: str) -> QWidget: def panel(self, key: str) -> QWidget:
"""One of the two panels, by tab key.""" """One of the three pages, by tab key."""
return self.connection_panel if key == "connection" else self.bus_panel 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: def open_for(self, tab: str | None = None) -> None:
"""Show the dialog, raising it if it is already up, and pick a tab. """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.close_button.setText(t("setup.close"))
self.connection_panel.retranslate() self.connection_panel.retranslate()
self.bus_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 ..motion.kinematics import PlatformStack, Pose, rotation_about
from ..sensor.export_bundle import SensorBundle 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 drag sensitivity and zoom step, matching the design repo's preview.
ORBIT_RAD_PER_PX = 0.008 ORBIT_RAD_PER_PX = 0.008
@@ -22,6 +22,7 @@ import math
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from orisys_app_shared.i18n import t
from PySide6.QtCore import QPoint, Qt, Signal from PySide6.QtCore import QPoint, Qt, Signal
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -34,7 +35,6 @@ from PySide6.QtWidgets import (
QLabel, QLabel,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QScrollArea,
QSizePolicy, QSizePolicy,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
@@ -42,8 +42,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from ..motion.kinematics import PlatformStack from ..motion.kinematics import PlatformStack
from ..sensor.export_bundle import SensorBundle from ..sensor.export_bundle import SensorBundle
from ..sensor.frames import describe as describe_frames from ..sensor.frames import describe as describe_frames
@@ -226,6 +224,12 @@ class FingerPanel(QGroupBox):
loadRequested = Signal() loadRequested = Signal()
pickRequested = Signal(int, int) pickRequested = Signal(int, int)
stackChanged = Signal() 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 #: 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 — #: 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. # colour call hid the base plane and the whole trajectory without a word.
self.preview.problemChanged.connect(lambda _message: self._refresh_reach()) self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
controls.addWidget(self.sim_button) 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) controls.addStretch(1)
column.addLayout(controls) 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 # 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 # composites a long overflowing string as an overlap of itself, and the
# legend was unreadable on the bench because of it. A label wraps. # 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") 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: def _sync_sim_button(self) -> None:
self.sim_button.setText( self.sim_button.setText(
t("finger.sim_stop") if self.preview.simulating else t("finger.sim_play") 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.trajectory_check.setText(t("finger.show_trajectory"))
self.sim_button.setToolTip(t("finger.sim_hint")) self.sim_button.setToolTip(t("finger.sim_hint"))
self._sync_sim_button() 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( self.table.setHorizontalHeaderLabels(
[""] + [t(f"finger.col_{key}") for key in self._headers] [""] + [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 from __future__ import annotations
import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
@@ -44,8 +46,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from ..preferences import POINT_ORDERS, SURFACE_MODES from ..preferences import POINT_ORDERS, SURFACE_MODES
from ..sequence_io import ( from ..sequence_io import (
SequencePoint, SequencePoint,
@@ -642,7 +642,7 @@ def _as_number(text: str, fallback: float) -> float:
value = float(str(text).strip()) value = float(str(text).strip())
except (TypeError, ValueError): except (TypeError, ValueError):
return fallback 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: def _format_number(value: Any) -> str:
@@ -20,19 +20,18 @@ from __future__ import annotations
import time 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.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QPushButton,
QProgressBar, QProgressBar,
QPushButton,
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import load_colors
from ..motion import Snapshot from ..motion import Snapshot
#: Tone -> colour token. Matches the brand bar's chip vocabulary. #: Tone -> colour token. Matches the brand bar's chip vocabulary.
+183 -5
View File
@@ -15,12 +15,11 @@ import sys
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from orisys_app_shared import API_VERSION
from orisys_app_shared import API_VERSION, __version__ as shared_version from orisys_app_shared import __version__ as shared_version
from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.paths import outputs_dir from orisys_app_shared.paths import outputs_dir
from orisys_app_shared.theme import build_qss, load_colors from orisys_app_shared.theme import build_qss, load_colors
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QCloseEvent from PySide6.QtGui import QAction, QCloseEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -40,9 +39,17 @@ from PySide6.QtWidgets import (
from .. import __version__ as app_version from .. import __version__ as app_version
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
from ..motion.kinematics import PlatformStack 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.service import clamp_bas_task
from ..motion.units import DEFAULT_UNITS
from ..preferences import load_preferences, save_preferences 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 ..sensor.uv_flat import DEFAULT_FLAT_SIZE
from ..sequence_io import ( from ..sequence_io import (
FILE_SUFFIX, FILE_SUFFIX,
@@ -145,6 +152,7 @@ class AutoCalMainWindow(QMainWindow):
# Its size and last tab, from the previous session. Applied here rather than # 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. # in ``_build_ui`` because it is stored state, like the window geometry.
self.device_setup.apply_settings(self.prefs.get("device_setup")) self.device_setup.apply_settings(self.prefs.get("device_setup"))
self.finger_panel.set_confirm(self._confirm_moves)
self.retranslate() self.retranslate()
# Load the remembered bundle, so the finger debugger comes up working. # 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, # so the two panels always exist — the window wires them to the controller,
# and the checks and tools reach for them here. # and the checks and tools reach for them here.
self.device_setup = DeviceSetupDialog( 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.connection_panel = self.device_setup.connection_panel
self.bus_panel = self.device_setup.bus_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( self.axis_readout = AxisReadoutPanel(
MotionService().axis_count, self._theme, self.rail_content 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.stopAllRequested.connect(self._on_stop_all)
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms) self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
self.axis_readout.enableAllRequested.connect(self._on_enable_all) 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.runRequested.connect(self._on_run_requested)
self.run_bar.pauseRequested.connect(self._on_run_pause) self.run_bar.pauseRequested.connect(self._on_run_pause)
self.run_bar.abortRequested.connect(self._on_run_abort) 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.""" """The brand bar's chip sends no argument; open on the connection tab."""
self.device_setup.open_for("connection") 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 # ---------------------------------------------------------- finger debug
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE): def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE):
@@ -724,6 +756,12 @@ class AutoCalMainWindow(QMainWindow):
MotionService( MotionService(
language=self._language, language=self._language,
bas_task=clamp_bas_task(self.prefs.get("bas_task")), 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), 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.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)) 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 # Ages the poll-health chip even when no snapshot arrives. Runs on the
# GUI thread and only reads a timestamp — the controller is untouched — # GUI thread and only reads a timestamp — the controller is untouched —
# so a stopped poll becomes visible instead of leaving stale chips # 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.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
self.axis_panel.update_snapshot(snapshot) self.axis_panel.update_snapshot(snapshot)
self.axis_readout.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 # The strip is outside the tabs, so it is fed on every poll regardless
# of which tab is showing. # of which tab is showing.
self.status_strip.set_snapshot(snapshot) self.status_strip.set_snapshot(snapshot)
@@ -796,6 +853,22 @@ class AutoCalMainWindow(QMainWindow):
if self.diagnostics_box.isVisible(): if self.diagnostics_box.isVisible():
self._refresh_diagnostics() 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: def _refresh_status_bar(self) -> None:
snapshot = self._last_snapshot snapshot = self._last_snapshot
if not snapshot.connected: if not snapshot.connected:
@@ -947,8 +1020,113 @@ class AutoCalMainWindow(QMainWindow):
def _stop_all(service: MotionService) -> object: def _stop_all(service: MotionService) -> object:
return service.stop_all() 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) 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: def _on_move_abs(self, axis: int, target: float) -> None:
self.poller.submit(lambda service: service.move_abs(axis, target)) 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 __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QLabel, QLabel,
@@ -20,8 +21,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from .axis_panel import AxisPanel from .axis_panel import AxisPanel
from .sequence_panel import SequencePanel 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 paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg from orisys_app_shared import prefs as prefs_pkg
from orisys_autocal import preferences as prefs_mod from orisys_autocal import preferences as prefs_mod
scratch = _scratch_dir() 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 paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg from orisys_app_shared import prefs as prefs_pkg
from orisys_autocal import preferences as prefs_mod from orisys_autocal import preferences as prefs_mod
from orisys_autocal.motion import DemoTransport from orisys_autocal.motion import DemoTransport
from orisys_autocal.ui.window import AutoCalMainWindow from orisys_autocal.ui.window import AutoCalMainWindow
@@ -21,7 +21,6 @@ import time
from pathlib import Path from pathlib import Path
import pytest import pytest
from orisys_autocal.motion import ( from orisys_autocal.motion import (
DEFAULT_BAS_TASK, DEFAULT_BAS_TASK,
INIT_OK, INIT_OK,
+38 -2
View File
@@ -16,9 +16,7 @@ What these tests protect:
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.i18n import set_locale, t
from orisys_autocal.motion import ConnectionSpec from orisys_autocal.motion import ConnectionSpec
from orisys_autocal.ui.device_setup import DeviceSetupDialog 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() 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): 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.""" """Opening a dialog is not a command: it must not connect or disconnect."""
window = make_window() 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 from __future__ import annotations
import pytest import pytest
from orisys_autocal.motion import errors from orisys_autocal.motion import errors
DOCUMENTED = sorted(errors.MESSAGES) DOCUMENTED = sorted(errors.MESSAGES)
+1 -6
View File
@@ -11,17 +11,15 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from PySide6.QtCore import QPointF
from orisys_autocal.motion.kinematics import PlatformStack from orisys_autocal.motion.kinematics import PlatformStack
from orisys_autocal.sensor import ( from orisys_autocal.sensor import (
DEFAULT_FLAT_SIZE, DEFAULT_FLAT_SIZE,
load_bundle, load_bundle,
rasterise, rasterise,
to_platform,
to_sensor, to_sensor,
) )
from orisys_autocal.ui.sensor_panel import FingerPanel from orisys_autocal.ui.sensor_panel import FingerPanel
from PySide6.QtCore import QPointF
BUNDLE = Path( BUNDLE = Path(
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute" 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 paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared.i18n import set_locale from orisys_app_shared.i18n import set_locale
from orisys_autocal import preferences as prefs_mod from orisys_autocal import preferences as prefs_mod
from orisys_autocal.ui import window as window_mod from orisys_autocal.ui import window as window_mod
from orisys_autocal.ui.window import AutoCalMainWindow 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. right, because getting it wrong draws the tool axis pointing down.
""" """
from orisys_autocal.sensor import to_sensor from orisys_autocal.sensor import to_sensor
from orisys_autocal.ui.pose_preview import PosePreview from orisys_autocal.ui.pose_preview import PosePreview
preview = 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): 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.""" """The 2D label and the 3D arrow must agree, or the label names nothing."""
from orisys_autocal.sensor import to_sensor from orisys_autocal.sensor import to_sensor
from orisys_autocal.ui.pose_preview import PosePreview from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview() preview = PosePreview()
+1 -2
View File
@@ -12,10 +12,9 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from orisys_autocal.motion.kinematics import ( from orisys_autocal.motion.kinematics import (
PlatformStack,
Branch, Branch,
PlatformStack,
rotation_about, rotation_about,
) )
from orisys_autocal.sensor import load_bundle from orisys_autocal.sensor import load_bundle
-2
View File
@@ -17,7 +17,6 @@ import dataclasses
import numpy as np import numpy as np
import pytest import pytest
from orisys_autocal.motion.kinematics import ( from orisys_autocal.motion.kinematics import (
ALIGNED_TOL_DEG, ALIGNED_TOL_DEG,
PlatformStack, PlatformStack,
@@ -26,7 +25,6 @@ from orisys_autocal.motion.kinematics import (
rotation_about, rotation_about,
) )
from orisys_autocal.sensor.frames import ( from orisys_autocal.sensor.frames import (
AXIS_MAP,
describe, describe,
mount_rotation, mount_rotation,
to_platform, to_platform,
+35 -2
View File
@@ -11,7 +11,6 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from orisys_autocal.preferences import load_preferences from orisys_autocal.preferences import load_preferences
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales" LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
@@ -44,7 +43,7 @@ def test_locales_have_identical_key_sets():
other = _flatten(_load(language)) other = _flatten(_load(language))
assert other == reference, { assert other == reference, {
f"{language}_only": sorted(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 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 = ""): def _walk(node, prefix: str = ""):
for key, value in node.items(): for key, value in node.items():
path = f"{prefix}{key}" 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 time
import pytest import pytest
from orisys_autocal.motion import (
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService ConnectionSpec,
DemoTransport,
MotionPoller,
MotionService,
)
class RecordingTransport(DemoTransport): 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 numpy as np
import pytest import pytest
from orisys_autocal.sensor.export_bundle import ( from orisys_autocal.sensor.export_bundle import (
BundleError, BundleError,
load_bundle, load_bundle,
-1
View File
@@ -12,7 +12,6 @@ import shutil
from pathlib import Path from pathlib import Path
import pytest import pytest
from orisys_autocal.sequence_io import ( from orisys_autocal.sequence_io import (
DOCUMENT_KIND, DOCUMENT_KIND,
FORMAT_VERSION, FORMAT_VERSION,
-1
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import math import math
import pytest import pytest
from orisys_autocal.motion.units import ( from orisys_autocal.motion.units import (
PULSES_PER_MM, PULSES_PER_MM,
ROTARY_UNITS_PER_90_DEG, ROTARY_UNITS_PER_90_DEG,
-1
View File
@@ -12,7 +12,6 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from orisys_autocal.sensor import ( from orisys_autocal.sensor import (
DEFAULT_FLAT_SIZE, DEFAULT_FLAT_SIZE,
load_bundle, load_bundle,
+52 -31
View File
@@ -25,7 +25,6 @@ from __future__ import annotations
import argparse import argparse
import math import math
import math
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -116,16 +115,13 @@ STRINGS = {
"setup_open": "设备设置", "setup_open": "设备设置",
"setup_title": "设备设置", "setup_title": "设备设置",
"setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。", "setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
"setup_tabs": ["连接", "总线初始化"], "setup_tabs": ["连接", "总线初始化", "轴映射"],
"setup_rows": [ "setup_rows": [
["连接方式", "以太网 ▾"], ["X(工作台)", "驱动器 0"],
["IP", "192.168.0.11"], ["Y(工作台)", "驱动器 1"],
["状态", "已连接"], ["Z(压轴)", "驱动器 4"],
["BAS 文件", "…\\bas\\ECAT.bas"], ["yaw(回转)", "驱动器 3"],
["初始化状态", "初始化成功"], ["pitch(俯仰)", "驱动器 2"],
["总线类型", "EtherCAT"],
["节点数量", "5"],
["轴数量", "5"],
], ],
"setup_connect": "连接", "setup_connect": "连接",
"setup_disconnect": "断开连接", "setup_disconnect": "断开连接",
@@ -208,6 +204,11 @@ STRINGS = {
"finger_preview_title": "三维预览", "finger_preview_title": "三维预览",
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"], "finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
"finger_play": "播放动作", "finger_play": "播放动作",
"move_execute": "执行动作",
"move_stop": "停止",
"move_return": "回初始位",
"move_confirm": "移动前确认",
"move_status": "1/4 yaw → +36.8160 °(回初始位:先退压轴 Z)",
"finger_preview_notes": [ "finger_preview_notes": [
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。", "颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。", "黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
@@ -270,16 +271,13 @@ STRINGS = {
"setup_open": "Device setup", "setup_open": "Device setup",
"setup_title": "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_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": [ "setup_rows": [
["Connection", "Ethernet ▾"], ["X (table)", "Drive 0"],
["IP", "192.168.0.11"], ["Y (table)", "Drive 1"],
["State", "Connected"], ["Z (press)", "Drive 4"],
["BAS file", "…\\bas\\ECAT.bas"], ["yaw (rotation)", "Drive 3"],
["Init state", "Initialised"], ["pitch (tilt)", "Drive 2"],
["Bus type", "EtherCAT"],
["Nodes", "5"],
["Axes", "5"],
], ],
"setup_connect": "Connect", "setup_connect": "Connect",
"setup_disconnect": "Disconnect", "setup_disconnect": "Disconnect",
@@ -362,6 +360,11 @@ STRINGS = {
"finger_preview_title": "3D preview", "finger_preview_title": "3D preview",
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"], "finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
"finger_play": "Play motion", "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": [ "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.", "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).", "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 y += 24
_button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette) _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 + 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) group = QRect(rect.x(), rect.y() + 280, rect.width(), 32)
_panel(painter, group.adjusted(0, 0, 0, -8), palette) _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) 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"]): for index, label in enumerate(s["setup_tabs"]):
tab = QRect(tabs.x() + index * tab_w, tabs.y(), tab_w, 26) 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.fillRect(tab, palette.window if active else palette.panel)
painter.setPen(QPen(palette.border, 1)) painter.setPen(QPen(palette.border, 1))
painter.drawRect(tab.adjusted(0, 0, -1, -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()) painter.drawLine(tab.bottomLeft(), tab.bottomRight())
_text(painter, tab, label, palette, size=10, bold=active, align=Qt.AlignmentFlag.AlignCenter) _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 mapping page: one row per platform direction, each showing the drive that
# the bus readouts, which is what the two tabs hold between them. # moves it. The values are the bench's own wiring.
body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette) body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette)
y = body.y() + 6 y = body.y() + 6
for label, value in s["setup_rows"]: 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(), y, 150, 22), label, palette, size=10, muted=True)
_text(painter, QRect(body.x() + 104, y, body.width() - 104, 22), value, palette, size=10) _text(painter, QRect(body.x() + 156, y, body.width() - 156, 22), value, palette, size=10)
y += 24 y += 26
_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)
_button(painter, QRect(dialog.right() - 110, dialog.bottom() - 34, 96, 26), s["setup_close"], palette) _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) 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) _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) _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"]: 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) _text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
notes_y += 14 notes_y += 14
+215 -11
View File
@@ -76,16 +76,14 @@ def _visible_descendants(widget):
def main() -> int: 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.i18n import set_locale, t
from orisys_app_shared.theme import build_qss, load_colors from orisys_app_shared.theme import build_qss, load_colors
from orisys_autocal.motion import ConnectionSpec from orisys_autocal.motion import ConnectionSpec
from orisys_autocal.ui.axis_panel import COLUMNS from orisys_autocal.ui.axis_panel import COLUMNS
from orisys_autocal.ui.bus_panel import default_bas_dir from orisys_autocal.ui.bus_panel import default_bas_dir
from orisys_autocal.ui.window import AutoCalMainWindow from orisys_autocal.ui.window import AutoCalMainWindow
from PySide6 import __version__ as pyside_version
from PySide6.QtWidgets import QApplication
checker = Checker() checker = Checker()
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
@@ -115,7 +113,14 @@ def main() -> int:
set_locale("zh_CN") set_locale("zh_CN")
# ---- window ---------------------------------------------------------- # ---- 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"): for theme in ("dark", "light"):
window = AutoCalMainWindow(prefs={**prefs, "theme": theme}) window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
@@ -267,6 +272,50 @@ def main() -> int:
not window.device_setup.isVisible(), not window.device_setup.isVisible(),
"the device setup is open before anyone asked for it", "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( checker.that(
window.device_setup.connection_panel is window.connection_panel window.device_setup.connection_panel is window.connection_panel
and window.device_setup.bus_panel is window.bus_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. # A full simulated session: connect, initialise from the shipped BAS.
window.connect_controller(ConnectionSpec(mode="demo"), label="connect") 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( checker.that(
window._last_snapshot.connected, "the simulated controller did not connect" window._last_snapshot.connected, "the simulated controller did not connect"
) )
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas")) window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
window._on_download_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") checker.that(ok, "the simulated bus did not report initialised")
if ok: if ok:
checker.that( checker.that(
@@ -453,12 +502,167 @@ def main() -> int:
or "5" in strip.chip_axes.text(), or "5" in strip.chip_axes.text(),
f"strip axis chip says {strip.chip_axes.text()!r}", 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") 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: try:
window.poller.shutdown() window.poller.shutdown()
except Exception: # noqa: BLE001 - a shutdown must not mask the result 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( parser.add_argument(
"--setup-dialog", "--setup-dialog",
default="", default="",
choices=("", "connection", "bus"), choices=("", "connection", "bus", "axes"),
help="photograph the device-setup dialog on this tab instead of the window", help="photograph the device-setup dialog on this tab instead of the window",
) )
args = parser.parse_args() args = parser.parse_args()
width, height = (int(part) for part in args.size.lower().split("x")) 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.i18n import set_locale
from orisys_app_shared.theme import build_qss from orisys_app_shared.theme import build_qss
from orisys_autocal.motion import ConnectionSpec, DemoTransport from orisys_autocal.motion import ConnectionSpec, DemoTransport
from orisys_autocal.preferences import load_preferences from orisys_autocal.preferences import load_preferences
from orisys_autocal.ui.bus_panel import default_bas_dir from orisys_autocal.ui.bus_panel import default_bas_dir
from orisys_autocal.ui.window import AutoCalMainWindow from orisys_autocal.ui.window import AutoCalMainWindow
from PySide6.QtWidgets import QApplication
set_locale(args.language) set_locale(args.language)
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
+1 -4
View File
@@ -96,14 +96,12 @@ def main() -> int:
) )
args = parser.parse_args() args = parser.parse_args()
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss from orisys_app_shared.theme import build_qss
from orisys_autocal.preferences import load_preferences from orisys_autocal.preferences import load_preferences
from orisys_autocal.ui.pose_preview import configure_compatible_gl from orisys_autocal.ui.pose_preview import configure_compatible_gl
from orisys_autocal.ui.window import AutoCalMainWindow from orisys_autocal.ui.window import AutoCalMainWindow
from PySide6.QtWidgets import QApplication
width, height = (int(part) for part in args.size.lower().split("x")) 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 # pixels, because a projection that disagrees with the render looks
# perfectly healthy on its own — that is how the labels drifted off # 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. # 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): for label, point, colour in _landmarks(panel):
screen = panel.preview.project(point) screen = panel.preview.project(point)
+23 -28
View File
@@ -9,21 +9,6 @@ from pathlib import Path
from typing import Any from typing import Any
import numpy as np 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.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
DEPTH, 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.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.filters import FilterSettings from orisys_app_shared.force.filters import FilterSettings
from orisys_app_shared.force.session import SessionStore 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 import RecordConfig
from orisys_app_shared.record.sample import to_record_sample 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.recording import RecordingCoordinator, record_config_from_prefs
from orisys_studio.replay import ReplayEngine from orisys_studio.replay import ReplayEngine
from orisys_studio.replay_controller import ReplayController 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_studio.scene3d.prefs import settings_to_prefs
from orisys_app_shared.i18n import set_locale, t from orisys_studio.session_controller import SessionController
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.ui import record_status as record_states from orisys_studio.ui import record_status as record_states
from orisys_studio.ui.about_dialog import AboutDialog from orisys_studio.ui.about_dialog import AboutDialog
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage 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.sensor_picker import SensorPickerWidget
from orisys_studio.ui.toolbar import ToolbarWidget from orisys_studio.ui.toolbar import ToolbarWidget
from orisys_studio.view_layout import ViewLayoutController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -2476,7 +2471,7 @@ class StudioMainWindow(QMainWindow):
return "" return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}") 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. """The 2D domain boundary to draw, latched for the session.
Exactly the treatment ``_depth_range`` gives the colour scale, and for the same Exactly the treatment ``_depth_range`` gives the colour scale, and for the same
@@ -15,7 +15,7 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any
from orisys_app_shared.force.calibration_io import ( from orisys_app_shared.force.calibration_io import (
CHANNEL_KEYS, CHANNEL_KEYS,
@@ -37,14 +37,14 @@ logger = logging.getLogger(__name__)
class CalibrationService: class CalibrationService:
"""Owns the calibration session's files and its prepared model.""" """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() self.store = store or CalSessionStore()
#: The one prepared model the panel displays and exports. #: The one prepared model the panel displays and exports.
self.prepared: Any = None self.prepared: Any = None
#: Device identity of a reopened record, while it is open. #: 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. #: The record currently open, if any.
self.open_record: Optional[CalibrationSessionRecord] = None self.open_record: CalibrationSessionRecord | None = None
# --------------------------------------------------------------- identity # --------------------------------------------------------------- identity
def export_meta( def export_meta(
@@ -19,17 +19,17 @@ repaint when the 2D view mode changes.
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Optional, Protocol from typing import Any, Protocol
from PySide6.QtCore import QObject from PySide6.QtCore import QObject
from orisys_studio.ui.plot_settings_dialog import ( from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N, DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW, DEFAULT_PLOT_DEADBAND_RAW,
DEFAULT_PLOT_FN_MIN,
DEFAULT_PLOT_FN_MAX, DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_FN_RAW_MIN, DEFAULT_PLOT_FN_MIN,
DEFAULT_PLOT_FN_RAW_MAX, DEFAULT_PLOT_FN_RAW_MAX,
DEFAULT_PLOT_FN_RAW_MIN,
DEFAULT_PLOT_SHEAR_MAX, DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_SHEAR_RAW_MAX, DEFAULT_PLOT_SHEAR_RAW_MAX,
DEFAULT_PLOT_WINDOW_SEC, DEFAULT_PLOT_WINDOW_SEC,
@@ -66,7 +66,7 @@ class DisplayHost(Protocol):
prefs: dict prefs: dict
@property @property
def _latest_fps(self) -> Optional[float]: ... def _latest_fps(self) -> float | None: ...
def _persist_prefs(self) -> None: ... def _persist_prefs(self) -> None: ...
@@ -94,7 +94,7 @@ class DisplayPrefsController(QObject):
arrow_rotate_types: frozenset, arrow_rotate_types: frozenset,
mesh_panel: Any = None, mesh_panel: Any = None,
rotate_arrows_action: Any = None, rotate_arrows_action: Any = None,
parent: Optional[QObject] = None, parent: QObject | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._host = host 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. # wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6") os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
from PySide6.QtWidgets import QApplication # noqa: E402 from orisys_app_shared import API_VERSION
from orisys_app_shared import __version__ as shared_version
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat from orisys_app_shared.compat import check_shared_compat
from orisys_app_shared.i18n import set_locale from orisys_app_shared.i18n import set_locale
from orisys_app_shared.logging import init_logging, install_exception_hooks 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.paths import prefer_local_sensor_bundles
from orisys_app_shared.prefs import load_preferences from orisys_app_shared.prefs import load_preferences
from orisys_app_shared.theme import build_qss 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 from orisys_studio.app_window import StudioMainWindow
logger = logging.getLogger(__name__) 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 from __future__ import annotations
import logging import logging
from collections.abc import Callable
from pathlib import Path 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 import ExperimentRecorder, RecordConfig, RecordReport
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
@@ -34,7 +35,7 @@ class RecordingCoordinator:
*, *,
session_store, session_store,
cal_recorder, cal_recorder,
recorder: Optional[ExperimentRecorder] = None, recorder: ExperimentRecorder | None = None,
) -> None: ) -> None:
self.session_store = session_store self.session_store = session_store
self.cal_recorder = cal_recorder self.cal_recorder = cal_recorder
@@ -47,7 +48,7 @@ class RecordingCoordinator:
self._alarms: list[str] = [] self._alarms: list[str] = []
#: A stop was requested and the writer has not closed its files yet. #: A stop was requested and the writer has not closed its files yet.
self.report_pending = False self.report_pending = False
self.last_report: Optional[RecordReport] = None self.last_report: RecordReport | None = None
self._active_calibration_id = "" self._active_calibration_id = ""
self._record_fps = 30.0 self._record_fps = 30.0
self.recorder.set_alarm_handler(self._on_alarm) self.recorder.set_alarm_handler(self._on_alarm)
@@ -210,7 +211,7 @@ class RecordingCoordinator:
self.report_pending = False self.report_pending = False
return Path(directory) 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.""" """Stop and return the report, or ``None`` when nothing was started."""
if self.recorder.directory is None and self.recorder.report is None: if self.recorder.directory is None and self.recorder.report is None:
self.report_pending = False self.report_pending = False
@@ -221,7 +222,7 @@ class RecordingCoordinator:
self.publish(report) self.publish(report)
return 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``.""" """Record a report; a completed one becomes ``last_report``."""
if report is None: if report is None:
return return
@@ -232,7 +233,7 @@ class RecordingCoordinator:
self.report_pending = False self.report_pending = False
self.last_report = report 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.""" """The terminal report, once the writer has actually closed the files."""
if not self.report_pending: if not self.report_pending:
return None return None
@@ -256,7 +257,7 @@ def record_config_from_prefs(
*, *,
mode: str, mode: str,
fps: float, fps: float,
on_problem: Optional[Callable[[str], None]] = None, on_problem: Callable[[str], None] | None = None,
) -> RecordConfig: ) -> RecordConfig:
"""Build the record config the toolbar/prefs describe.""" """Build the record config the toolbar/prefs describe."""
return RecordConfig( return RecordConfig(
+3 -5
View File
@@ -21,13 +21,11 @@ from __future__ import annotations
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional
import numpy as np import numpy as np
from PySide6.QtCore import QObject, Signal
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader, VideoOnlySource from orisys_app_shared.record import RecordingReader, VideoOnlySource
from PySide6.QtCore import QObject, Signal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,7 +62,7 @@ class ReplayEngine(QObject):
self._position = 0.0 self._position = 0.0
#: The frame cache: one video image per cursor position. #: The frame cache: one video image per cursor position.
self._video_position: float | None = None self._video_position: float | None = None
self._video_bgr: Optional[np.ndarray] = None self._video_bgr: np.ndarray | None = None
self._video_reason = "" self._video_reason = ""
self._warnings = "" self._warnings = ""
@@ -209,7 +207,7 @@ class ReplayEngine(QObject):
return self._unit return self._unit
# ----------------------------------------------------------------- frames # ----------------------------------------------------------------- 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 recorded frame under the cursor, as BGR.
The engine hands over pixels, not a prepared widget image: the single 2D The engine hands over pixels, not a prepared widget image: the single 2D
@@ -25,15 +25,14 @@ import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Protocol from typing import Any, Protocol
import numpy as np 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.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir from orisys_app_shared.paths import recordings_dir
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QFileDialog
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -111,7 +110,7 @@ class ReplayController(QObject):
charts: Any, charts: Any,
image_panel: Any, image_panel: Any,
mesh_panel: Any, mesh_panel: Any,
parent: Optional[QObject] = None, parent: QObject | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._engine = engine self._engine = engine
@@ -120,7 +119,7 @@ class ReplayController(QObject):
self._charts = charts self._charts = charts
self._image_panel = image_panel self._image_panel = image_panel
self._mesh_panel = mesh_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 #: 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. #: the acquire pipeline owns the panels and the transport stops the pass.
self._recompute_running = False self._recompute_running = False
@@ -501,7 +500,7 @@ class ReplayController(QObject):
self._host._notify(t("toolbar.nothing_to_stop")) self._host._notify(t("toolbar.nothing_to_stop"))
# ---------------------------------------------------------------- recompute # ---------------------------------------------------------------- recompute
def video_segment(self) -> Optional[Path]: def video_segment(self) -> Path | None:
"""The recorded video a recompute pass should read.""" """The recorded video a recompute pass should read."""
reader = self._engine.reader() reader = self._engine.reader()
segments = sorted(reader.segments()) if reader is not None else [] 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 __future__ import annotations
from typing import Any, Optional from typing import Any
import numpy as np import numpy as np
from orisys_app_shared.acquisition.models import SdkPacket from orisys_app_shared.acquisition.models import SdkPacket
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
Diagnostic, Diagnostic,
@@ -45,7 +44,6 @@ from orisys_app_shared.scene3d.contracts import (
FrameIdentity, FrameIdentity,
SurfaceFieldFrame, SurfaceFieldFrame,
diagnostic, diagnostic,
error,
freeze, freeze,
) )
from orisys_app_shared.scene3d.sampling import remap_to_domain 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) 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|`` as float32, or ``None`` when the packet carries no flow."""
flow = getattr(packet, "flow", None) flow = getattr(packet, "flow", None)
if flow is None: if flow is None:
@@ -97,11 +95,11 @@ def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
return None 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]) 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. """Whether the packet may claim ``field`` for *this* frame.
Three answers, and the middle one is the point: ``None`` on the packet means the Three answers, and the middle one is the point: ``None`` on the packet means the
@@ -145,7 +143,7 @@ def _device_mask(
packet: SdkPacket, packet: SdkPacket,
shape_hw: tuple[int, int], shape_hw: tuple[int, int],
latch: Any = None, 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. """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 Both silent choices are wrong: stretching a mask from another grid marks samples
@@ -177,7 +175,7 @@ def _device_mask(
return array, None 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.""" """Inside the UV island AND the device's own valid area, when it stated one."""
if device is None: if device is None:
return freeze(mask, dtype=np.bool_) return freeze(mask, dtype=np.bool_)
@@ -218,8 +216,8 @@ class FingerFrameAdapter:
geometry_id: str, geometry_id: str,
field_domain_id: str, field_domain_id: str,
shape_hw: tuple[int, int], shape_hw: tuple[int, int],
map_x: Optional[np.ndarray] = None, map_x: np.ndarray | None = None,
map_y: Optional[np.ndarray] = None, map_y: np.ndarray | None = None,
preferred_field: str = "depth", preferred_field: str = "depth",
) -> None: ) -> None:
self.geometry_id = str(geometry_id) self.geometry_id = str(geometry_id)
@@ -227,7 +225,7 @@ class FingerFrameAdapter:
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1])) self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
self.map_x = map_x self.map_x = map_x
self.map_y = map_y 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 #: 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 #: 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 #: 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) self.preferred_field = _field_name(preferred_field)
#: Set (and cleared) by every snapshot: the reason the picture is not the 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. #: 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 #: The session's island latch (``image_backend.DomainMaskLatch``), when the
#: window has one. See ``_device_mask``: the surface's validity is the *latched* #: 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" #: 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) self.preferred_field = _field_name(name)
@classmethod @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).""" """Build from a ``LegacyFieldDomain`` (which carries the map, when loaded)."""
return cls( return cls(
geometry_id=geometry_id, geometry_id=geometry_id,
@@ -259,7 +257,7 @@ class FingerFrameAdapter:
def snapshot( def snapshot(
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
) -> Optional[SurfaceFieldFrame]: ) -> SurfaceFieldFrame | None:
"""Colour the surface with the field the operator chose, or say why not. """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``); Two quantities can be drawn and the operator picks one (``DisplaySettings.field``);
@@ -306,7 +304,7 @@ class FingerFrameAdapter:
identity: FrameIdentity, identity: FrameIdentity,
name: str, name: str,
refused: list[Diagnostic], refused: list[Diagnostic],
) -> Optional[SurfaceFieldFrame]: ) -> SurfaceFieldFrame | None:
"""One quantity, or ``None`` with the reason appended to ``refused``.""" """One quantity, or ``None`` with the reason appended to ``refused``."""
if name == "depth": if name == "depth":
depth = getattr(packet, "depth", None) depth = getattr(packet, "depth", None)
@@ -401,7 +399,7 @@ class FingerFrameAdapter:
mask: np.ndarray, mask: np.ndarray,
descriptor: FieldDescriptor, descriptor: FieldDescriptor,
refused: list[Diagnostic], refused: list[Diagnostic],
) -> Optional[SurfaceFieldFrame]: ) -> SurfaceFieldFrame | None:
"""Intersect with the device's own mask and build the frame.""" """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 # Inside the UV island is the only place a value means anything: the tracker is
# looking at masked black outside it. # looking at masked black outside it.
@@ -469,13 +467,13 @@ class BoxFrameAdapter:
self.geometry_id = str(geometry_id) self.geometry_id = str(geometry_id)
self.field_domain_id = str(field_domain_id) self.field_domain_id = str(field_domain_id)
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1])) 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. #: See ``FingerFrameAdapter.domain_latch``: one island, both views.
self.domain_latch: Any = None self.domain_latch: Any = None
def snapshot( def snapshot(
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
) -> Optional[SurfaceFieldFrame]: ) -> SurfaceFieldFrame | None:
if tuple(domain.shape_hw) != self.shape_hw: if tuple(domain.shape_hw) != self.shape_hw:
self.last_diagnostic = diagnostic( self.last_diagnostic = diagnostic(
"field_domain_mismatch", "field_domain_mismatch",
@@ -492,7 +490,7 @@ class BoxFrameAdapter:
if mask_problem is not None: if mask_problem is not None:
self.last_diagnostic = mask_problem self.last_diagnostic = mask_problem
return None return None
refused: Optional[Diagnostic] = None refused: Diagnostic | None = None
for name, field in candidates: for name, field in candidates:
if field is None: if field is None:
continue continue
@@ -28,12 +28,11 @@ exactly the kind of thing this feature is not allowed to cause.
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional from typing import TYPE_CHECKING, Any
from PySide6.QtCore import QObject, Signal
from orisys_app_shared.paths import config_dir from orisys_app_shared.paths import config_dir
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
@@ -43,6 +42,8 @@ from orisys_app_shared.scene3d.contracts import (
diagnostic, diagnostic,
error, error,
) )
from PySide6.QtCore import QObject, Signal
from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement
if TYPE_CHECKING: # the description is a value object; the callable returns one 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}" 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.""" """Where a compiled package for this sensor belongs."""
base = Path(root) if root is not None else config_dir() base = Path(root) if root is not None else config_dir()
return base / "sensors" / str(sensor_type) / "scene" return base / "sensors" / str(sensor_type) / "scene"
@@ -86,8 +87,8 @@ def find_package(
sensor_type: str, sensor_type: str,
*, *,
override: str | Path | None = None, override: str | Path | None = None,
root: Optional[Path] = None, root: Path | None = None,
) -> Optional[SceneSource]: ) -> SceneSource | None:
"""The package to load, or ``None``. Never raises: absence is a state.""" """The package to load, or ``None``. Never raises: absence is a state."""
if override: if override:
path = Path(str(override)).expanduser() path = Path(str(override)).expanduser()
@@ -126,8 +127,8 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None)
def load_package( def load_package(
source: SceneSource, source: SceneSource,
expected: Optional[ExpectedGeometry] = None, expected: ExpectedGeometry | None = None,
keys: "Optional[SceneKeys]" = None, keys: SceneKeys | None = None,
) -> SceneAssets: ) -> SceneAssets:
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``. """Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.
@@ -153,10 +154,10 @@ def load_package(
def _resolve_and_load( def _resolve_and_load(
source: SceneSource, source: SceneSource,
expected: Optional[ExpectedGeometry], expected: ExpectedGeometry | None,
provider: "Optional[Callable[[], DeviceGeometry]]", provider: Callable[[], DeviceGeometry] | None,
keys: "Optional[SceneKeys]" = None, keys: SceneKeys | None = None,
) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]: ) -> tuple[SceneAssets, DeviceGeometry | None]:
"""Worker-thread half: build the *device's* description, then read the package. """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 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 loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
failed = Signal(object, int) # Diagnostic, request id 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) super().__init__(parent)
self._executor: Optional[ThreadPoolExecutor] = None self._executor: ThreadPoolExecutor | None = None
self._inflight = False self._inflight = False
self._request_id = 0 self._request_id = 0
#: The newest request that could not start yet (source, expected, provider, id). #: 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( def load(
self, self,
source: SceneSource, source: SceneSource,
expected: Optional[ExpectedGeometry] = None, expected: ExpectedGeometry | None = None,
*, *,
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None, expected_provider: Callable[[], DeviceGeometry] | None = None,
keys: "Optional[SceneKeys]" = None, keys: SceneKeys | None = None,
) -> int: ) -> int:
"""Ask for a scene; returns the request id this call was given.""" """Ask for a scene; returns the request id this call was given."""
self._request_id += 1 self._request_id += 1
@@ -15,14 +15,12 @@ from __future__ import annotations
import math import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional
import numpy as np import numpy as np
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
F32,
CameraMatrices, CameraMatrices,
CameraPreset, CameraPreset,
F32,
freeze, freeze,
) )
@@ -54,7 +52,7 @@ class OrbitCamera:
vertical_fov_rad: float = 0.6 vertical_fov_rad: float = 0.6
aspect: float = 1.0 aspect: float = 1.0
_fitted_radius_m: float = 0.05 _fitted_radius_m: float = 0.05
_preset_name: Optional[str] = None _preset_name: str | None = None
# ------------------------------------------------------------------- setup # ------------------------------------------------------------------- setup
def fit(self, bounds_sensor_m: F32, aspect: float) -> None: def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
@@ -36,9 +36,10 @@ from __future__ import annotations
import threading import threading
import time import time
from collections.abc import Callable
from concurrent.futures import CancelledError, Executor, Future from concurrent.futures import CancelledError, Executor, Future
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Optional from typing import Any
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
Diagnostic, Diagnostic,
@@ -106,27 +107,27 @@ class ThreeDCoordinator:
#: withdrawn: a stop, a hide, a new session, a new scene. A compose result #: 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. #: whose epoch is not this one is thrown away instead of presented.
self._epoch = 0 self._epoch = 0
self._assets: Optional[SceneAssets] = None self._assets: SceneAssets | None = None
self._source_id = "" 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 #: 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. #: (a seek UI, a queue that reorders) must not put an older frame on screen.
self._last_request_id = 0 self._last_request_id = 0
self._visible = False self._visible = False
self._settings = DisplaySettings(max_fps=self._max_fps) self._settings = DisplaySettings(max_fps=self._max_fps)
self._inflight: Optional[_Inflight] = None self._inflight: _Inflight | None = None
self._stale_frames = 0 self._stale_frames = 0
self._invalid_frames = 0 self._invalid_frames = 0
self._last_diagnostic: Optional[Diagnostic] = None self._last_diagnostic: Diagnostic | None = None
self._closed = False self._closed = False
self._settings_dirty = 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. #: 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 #: 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 #: 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. #: 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. #: 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 #: Without this the pane re-uploaded the same rest frame on every 16 ms tick
#: while it waited for the first measurement. #: while it waited for the first measurement.
@@ -138,7 +139,7 @@ class ThreeDCoordinator:
self._last_result_latency_ms = 0.0 self._last_result_latency_ms = 0.0
#: The field frame currently on screen, so a gain change can redraw exactly #: The field frame currently on screen, so a gain change can redraw exactly
#: it rather than a newer instant. #: it rather than a newer instant.
self._last_field_frame: Optional[SurfaceFieldFrame] = None self._last_field_frame: SurfaceFieldFrame | None = None
# ------------------------------------------------------------------- state # ------------------------------------------------------------------- state
@property @property
@@ -155,7 +156,7 @@ class ThreeDCoordinator:
"""The display claim results are accepted under (see :class:`_Inflight`).""" """The display claim results are accepted under (see :class:`_Inflight`)."""
return self._epoch 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.""" """What is on screen, and the monotonic time it was handed to the widget."""
return self._last_presented return self._last_presented
@@ -190,7 +191,7 @@ class ThreeDCoordinator:
return self._invalid_frames return self._invalid_frames
@property @property
def last_diagnostic(self) -> Optional[Diagnostic]: def last_diagnostic(self) -> Diagnostic | None:
return self._last_diagnostic return self._last_diagnostic
@property @property
@@ -355,7 +356,7 @@ class ThreeDCoordinator:
self._last_request_id = max(self._last_request_id, identity.request_id) self._last_request_id = max(self._last_request_id, identity.request_id)
return self._incoming.offer(frame) 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. """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 Called from the acquisition thread by the sink that owns the adapter: when the
@@ -373,7 +374,7 @@ class ThreeDCoordinator:
@staticmethod @staticmethod
def _reject_reason( def _reject_reason(
frame: SurfaceFieldFrame, assets: SceneAssets frame: SurfaceFieldFrame, assets: SceneAssets
) -> Optional[Diagnostic]: ) -> Diagnostic | None:
"""The cheap checks a producer thread may do: identity and shape only.""" """The cheap checks a producer thread may do: identity and shape only."""
if frame.geometry_id != assets.geometry_id: if frame.geometry_id != assets.geometry_id:
return diagnostic( return diagnostic(
@@ -24,14 +24,15 @@ zero: the reference model is not a measurement and must not look like one.
from __future__ import annotations from __future__ import annotations
import logging
import math import math
import time import time
from typing import Any, Optional from typing import Any
import numpy as np import numpy as np
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
CameraMatrices, CameraMatrices,
Diagnostic,
DisplaySettings, DisplaySettings,
GLCapabilities, GLCapabilities,
MeshPart, MeshPart,
@@ -43,7 +44,8 @@ from orisys_app_shared.scene3d.contracts import (
error, error,
freeze, 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 #: 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. #: 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 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. """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 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( def _blend_scale_range(
previous: Optional[tuple[float, float]], previous: tuple[float, float] | None,
current: Optional[tuple[float, float]], current: tuple[float, float] | None,
*, *,
alpha: float = 0.15, alpha: float = 0.15,
) -> Optional[tuple[float, float]]: ) -> tuple[float, float] | None:
"""Slowly update an automatic colour range to avoid frame-to-frame flicker.""" """Slowly update an automatic colour range to avoid frame-to-frame flicker."""
if current is None: if current is None:
return previous return previous
@@ -310,7 +312,7 @@ def _blend_scale_range(
(1.0 - a) * float(previous[1]) + a * float(current[1]), (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. """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 ``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: else:
fallback = (float(scale.lower), float(scale.upper)) fallback = (float(scale.lower), float(scale.upper))
lower, upper = float(fallback[0]), float(fallback[1]) 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 return 0.0, 1.0
if upper <= lower: if upper <= lower:
# A constant field still has to be visible rather than all one clipped colour. # 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) 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. """Interleave one part into the layout the shaders read.
``uvs`` are the render-vertex UVs on the domain's pixel grid ``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( def pack_scene_part(
part: MeshPart, *, uvs: Optional[np.ndarray] = None part: MeshPart, *, uvs: np.ndarray | None = None
) -> tuple[np.ndarray, np.ndarray]: ) -> tuple[np.ndarray, np.ndarray]:
"""``(vertices, indices)`` as the GPU wants them, both read-only.""" """``(vertices, indices)`` as the GPU wants them, both read-only."""
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1) 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.""" """One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
__slots__ = ( __slots__ = (
"part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices", "rows" "ebo",
"part_id",
"role",
"rows",
"triangles",
"vao",
"vbo",
"vertices"
) )
def __init__( def __init__(
@@ -620,7 +629,7 @@ class GLRenderer:
"""Shaders, buffers and draw calls for one scene.""" """Shaders, buffers and draw calls for one scene."""
def __init__(self) -> None: def __init__(self) -> None:
self._capabilities: Optional[GLCapabilities] = None self._capabilities: GLCapabilities | None = None
self._programs: dict[str, int] = {} self._programs: dict[str, int] = {}
self._uniforms: dict[str, dict[str, int]] = {} self._uniforms: dict[str, dict[str, int]] = {}
self._parts: dict[str, _PartBuffers] = {} self._parts: dict[str, _PartBuffers] = {}
@@ -632,13 +641,13 @@ class GLRenderer:
#: allocation and no per-frame vertex traffic. #: allocation and no per-frame vertex traffic.
self._field_texture: int = 0 self._field_texture: int = 0
self._valid_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 self._field_internal_format: int = 0
#: Range of the valid samples of the field currently uploaded, for the #: Range of the valid samples of the field currently uploaded, for the
#: automatic colour scale. ``None`` until a frame carries valid data. #: 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. #: 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._last_scale_mode = ""
self._sensing_part_id: str = "" self._sensing_part_id: str = ""
self._estimated_gpu_bytes: int = 0 self._estimated_gpu_bytes: int = 0
@@ -926,8 +935,12 @@ class GLRenderer:
GL.glDeleteVertexArrays(1, [int(vao)]) GL.glDeleteVertexArrays(1, [int(vao)])
GL.glDeleteBuffers(1, [int(vbo)]) GL.glDeleteBuffers(1, [int(vbo)])
GL.glDeleteBuffers(1, [int(ebo)]) GL.glDeleteBuffers(1, [int(ebo)])
except Exception: # noqa: BLE001 except Exception as exc: # noqa: BLE001 - best-effort delete, reported below
pass # 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( def draw(
self, self,
@@ -1021,7 +1034,7 @@ class GLRenderer:
scale_mode=self._last_scale_mode, 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).""" """The colour bounds the last draw actually used (``None`` before any draw)."""
return self._last_scale return self._last_scale
@@ -1059,7 +1072,7 @@ class GLRenderer:
GL.glActiveTexture(GL.GL_TEXTURE0) GL.glActiveTexture(GL.GL_TEXTURE0)
@property @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.""" """The allocated field texture's ``(H, W)``, or ``None`` before the first frame."""
return self._field_shape return self._field_shape
@@ -1150,7 +1163,7 @@ class GLRenderer:
GL.glBindTexture(GL.GL_TEXTURE_2D, 0) GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
self._lut_id = lut_id self._lut_id = lut_id
def capabilities(self) -> Optional[GLCapabilities]: def capabilities(self) -> GLCapabilities | None:
return self._capabilities return self._capabilities
@property @property
@@ -1246,7 +1259,7 @@ def _decode(value: Any) -> str:
return str(value).strip() 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: if location is None:
return return
# Column-major upload, explicitly: the layout of the NumPy array must not # 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)) 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: if location is not None:
GL.glUniform1f(location, float(value)) 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: if location is not None:
GL.glUniform3f(location, float(values[0]), float(values[1]), float(values[2])) 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: if location is not None:
GL.glUniform4f( GL.glUniform4f(
location, float(values[0]), float(values[1]), float(values[2]), float(values[3]) 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 from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Mapping, Optional from typing import Any
from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic
@@ -36,7 +37,7 @@ PREF_REQUIRE_SIGNATURE = "scene3d_require_signature"
class SceneKeys: class SceneKeys:
"""What the loader needs to authenticate a package — and how strict to be.""" """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 provider: Any = None
require_signature: bool = False require_signature: bool = False
#: Paths/ids only, for logs and for the recording: never material. #: 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. """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 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 {}) prefs = dict(prefs or {})
sources: dict[str, str] = {} sources: dict[str, str] = {}
verify_pem: Optional[bytes] = None verify_pem: bytes | None = None
provider = None provider = None
verify_path = str(prefs.get(PREF_VERIFY_KEY) or "").strip() 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. """Refuse an unsigned package when the application is in release mode.
Development builds accept a plain package (the shipped engineering one is exactly 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 __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
import numpy as np 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.QtCore import Qt, Signal
from PySide6.QtGui import QImage from PySide6.QtGui import QImage
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -38,18 +48,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t from orisys_studio.scene3d.prefs import DEFAULT_FIELD, FPS_CHOICES
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.ui.fit_text import ShrinkableLabel from orisys_studio.ui.fit_text import ShrinkableLabel
if TYPE_CHECKING: # the panel talks to these two by interface, not by import 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. 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) super().__init__(parent)
self.setObjectName("colorScaleBar") self.setObjectName("colorScaleBar")
self.setFixedHeight(12) self.setFixedHeight(12)
@@ -136,23 +135,23 @@ class ThreeDPanel(QFrame):
self, self,
coordinator: ThreeDCoordinator, coordinator: ThreeDCoordinator,
widget: Sensor3DWidget, widget: Sensor3DWidget,
parent: Optional[QWidget] = None, parent: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("contentPanel") self.setObjectName("contentPanel")
self._coordinator = coordinator self._coordinator = coordinator
self._widget = widget self._widget = widget
self._assets: Optional[SceneAssets] = None self._assets: SceneAssets | None = None
self._descriptor: Optional[FieldDescriptor] = None self._descriptor: FieldDescriptor | None = None
self._state: SceneState = "disabled" self._state: SceneState = "disabled"
self._source_kind = "live" self._source_kind = "live"
self._syncing = False self._syncing = False
#: What the data source can provide (see ``contracts.SceneCapabilities``): an #: What the data source can provide (see ``contracts.SceneCapabilities``): an
#: unavailable capability disables its control and says why, rather than #: unavailable capability disables its control and says why, rather than
#: letting the operator operate an input that does not exist. #: 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). #: 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 #: Whether the pane explains the pipeline around the picture (the window's
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay. #: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
self._show_diagnostics = False self._show_diagnostics = False
@@ -355,7 +354,7 @@ class ThreeDPanel(QFrame):
self.set_state("disabled") self.set_state("disabled")
# ------------------------------------------------------------------ inputs # ------------------------------------------------------------------ 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.""" """Fill the preset list from the asset: its default view is the asset's."""
self._assets = assets self._assets = assets
self._syncing = True 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.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count()))
self.reset_button.setEnabled(assets is not None) 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.""" """The measurement currently on screen — or none, which hides the bar."""
if descriptor != self._descriptor: if descriptor != self._descriptor:
self._effective_scale = None self._effective_scale = None
@@ -417,7 +416,7 @@ class ThreeDPanel(QFrame):
""" """
self.scale_bar.set_ramp(ramp) 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. """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 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") else t("scene3d.contact_arrow_tip")
) )
def capabilities(self) -> Optional[SceneCapabilities]: def capabilities(self) -> SceneCapabilities | None:
return self._capabilities return self._capabilities
def _render_legend(self) -> None: 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.setToolTip(t(f"scene3d.source_{kind}_tip"))
self.source_label.setVisible(self._show_diagnostics) 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. """One diagnostic line. The 2D views stay usable behind it.
Warnings and errors are always stated: they are claims about the *measurement* 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 __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 ( from orisys_app_shared.scene3d.contracts import (
UNAVAILABLE_DESCRIPTOR,
ColorScale, ColorScale,
DisplaySettings, DisplaySettings,
FieldDescriptor, FieldDescriptor,
UNAVAILABLE_DESCRIPTOR,
) )
#: Frame rates the pane offers. A saved value outside this set is repaired to the #: 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) number = float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return fallback 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 fallback
return number return number
@@ -102,9 +104,9 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
def settings_from_prefs( def settings_from_prefs(
raw: Optional[Mapping[str, Any]], raw: Mapping[str, Any] | None,
*, *,
descriptor: Optional[FieldDescriptor] = None, descriptor: FieldDescriptor | None = None,
) -> DisplaySettings: ) -> DisplaySettings:
"""Rebuild the display settings, repairing anything unusable. """Rebuild the display settings, repairing anything unusable.
@@ -121,7 +123,7 @@ def settings_from_prefs(
field = str(raw.get("field") or DEFAULT_FIELD) field = str(raw.get("field") or DEFAULT_FIELD)
if field not in FIELD_CHOICES: if field not in FIELD_CHOICES:
field = DEFAULT_FIELD field = DEFAULT_FIELD
scale: Optional[ColorScale] = None scale: ColorScale | None = None
saved = raw.get("color_scale") saved = raw.get("color_scale")
if isinstance(saved, Mapping): if isinstance(saved, Mapping):
mode = str(saved.get("mode") or "fixed") 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 __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.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@@ -25,8 +26,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
from orisys_studio.scene3d.panel import ThreeDPanel from orisys_studio.scene3d.panel import ThreeDPanel
@@ -35,7 +34,7 @@ class Scene3dSettingsDialog(QDialog):
"""A modeless home for the 3D pane's controls.""" """A modeless home for the 3D pane's controls."""
def __init__( def __init__(
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None self, panel: ThreeDPanel, parent: QWidget | None = None
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("scene3dSettings") 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 from __future__ import annotations
import logging import logging
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Optional from typing import Any
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QVBoxLayout, QWidget
from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
@@ -47,6 +45,9 @@ from orisys_app_shared.scene3d.contracts import (
diagnostic, diagnostic,
validate_assets, 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.adapters import BoxFrameAdapter, FingerFrameAdapter
from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator from orisys_studio.scene3d.coordinator import ThreeDCoordinator
@@ -90,24 +91,24 @@ class Scene3DView(QWidget):
descriptorChanged = Signal(object) # Optional[FieldDescriptor] descriptorChanged = Signal(object) # Optional[FieldDescriptor]
enabledChanged = Signal(bool) enabledChanged = Signal(bool)
def __init__(self, parent: Optional[QWidget] = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._available, self._reason = gl_available() self._available, self._reason = gl_available()
self._widget: Optional[Sensor3DWidget] = None self._widget: Sensor3DWidget | None = None
self._panel: Optional[ThreeDPanel] = None self._panel: ThreeDPanel | None = None
self._coordinator: Optional[ThreeDCoordinator] = None self._coordinator: ThreeDCoordinator | None = None
self._camera = OrbitCamera() self._camera = OrbitCamera()
self._executor: Optional[ThreadPoolExecutor] = None self._executor: ThreadPoolExecutor | None = None
self._assets: Optional[SceneAssets] = None self._assets: SceneAssets | None = None
self._composer: Optional[SceneComposer] = None self._composer: SceneComposer | None = None
#: The session's island latch, shared with the 2D canvas when the window hands #: The session's island latch, shared with the 2D canvas when the window hands
#: one over (``image_backend.DomainMaskLatch``). One latch, two views: the #: one over (``image_backend.DomainMaskLatch``). One latch, two views: the
#: surface's validity texture and the canvas's island are the same mask, so #: 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 #: 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. #: each frame's own mask, which is what the tests and the PoC want.
self._domain_latch: Any = None self._domain_latch: Any = None
self._expected: Optional[ExpectedGeometry] = None self._expected: ExpectedGeometry | None = None
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused, #: 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 #: 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. #: offer. Kept rather than closed over so those answers can be asked.
@@ -115,11 +116,11 @@ class Scene3DView(QWidget):
self._source_id = "" self._source_id = ""
self._enabled = False self._enabled = False
self._source_kind = "live" self._source_kind = "live"
self._last_diagnostic: Optional[Diagnostic] = None self._last_diagnostic: Diagnostic | None = None
self._last_descriptor: Optional[FieldDescriptor] = None self._last_descriptor: FieldDescriptor | None = None
self._last_stats: Optional[RenderStats] = None self._last_stats: RenderStats | None = None
#: The coordinator's last diagnostic as *this* view has already reported it. #: 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. #: Whether the loaded asset was compared against the device's own description.
self._device_verified = False self._device_verified = False
#: Frames the producer did not even build (a frame was already waiting). #: Frames the producer did not even build (a frame was already waiting).
@@ -173,7 +174,7 @@ class Scene3DView(QWidget):
self, self,
assets: SceneAssets, assets: SceneAssets,
source_id: str, source_id: str,
expected: Optional[ExpectedGeometry] = None, expected: ExpectedGeometry | None = None,
) -> bool: ) -> bool:
"""Adopt a validated scene. Returns whether the view accepted it. """Adopt a validated scene. Returns whether the view accepted it.
@@ -242,8 +243,8 @@ class Scene3DView(QWidget):
return not violations return not violations
def _make_snapshot( def _make_snapshot(
self, assets: SceneAssets, expected: Optional[ExpectedGeometry] self, assets: SceneAssets, expected: ExpectedGeometry | None
) -> Optional[Callable[[Any, FrameIdentity], Any]]: ) -> Callable[[Any, FrameIdentity], Any] | None:
"""Bind the adapter that fits this scene, or refuse with a diagnostic. """Bind the adapter that fits this scene, or refuse with a diagnostic.
The two adapters have different call signatures because they need different 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 except Exception: # noqa: BLE001 - a display sink must never break capture
logger.exception("the 3D sink failed for one packet") 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).""" """Why the adapter refused the last packet (the adapter owns the answer)."""
item = getattr(self._adapter, "last_diagnostic", None) item = getattr(self._adapter, "last_diagnostic", None)
return item if isinstance(item, Diagnostic) else 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.""" """The identity a field frame built from this packet would carry."""
if self._coordinator is None or self._assets is None: if self._coordinator is None or self._assets is None:
return None return None
@@ -448,7 +449,7 @@ class Scene3DView(QWidget):
self._panel.set_source_kind(kind) self._panel.set_source_kind(kind)
self._publish_capabilities() self._publish_capabilities()
def capabilities(self) -> Optional[SceneCapabilities]: def capabilities(self) -> SceneCapabilities | None:
"""What this data source can provide, for the panel's controls. """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 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 return self._enabled
@property @property
def assets(self) -> Optional[SceneAssets]: def assets(self) -> SceneAssets | None:
return self._assets return self._assets
@property @property
def coordinator(self) -> Optional[ThreeDCoordinator]: def coordinator(self) -> ThreeDCoordinator | None:
return self._coordinator return self._coordinator
@property @property
def gl_widget(self) -> Optional[Sensor3DWidget]: def gl_widget(self) -> Sensor3DWidget | None:
return self._widget return self._widget
@property @property
def panel(self) -> Optional[ThreeDPanel]: def panel(self) -> ThreeDPanel | None:
return self._panel return self._panel
@property @property
def last_diagnostic(self) -> Optional[Diagnostic]: def last_diagnostic(self) -> Diagnostic | None:
return self._last_diagnostic return self._last_diagnostic
@property @property
def last_descriptor(self) -> Optional[FieldDescriptor]: def last_descriptor(self) -> FieldDescriptor | None:
return self._last_descriptor return self._last_descriptor
def state(self) -> SceneState: def state(self) -> SceneState:
@@ -568,7 +569,7 @@ class Scene3DView(QWidget):
return "disabled" return "disabled"
return self._coordinator.state return self._coordinator.state
def render_stats(self) -> Optional[RenderStats]: def render_stats(self) -> RenderStats | None:
return self._last_stats return self._last_stats
def describe(self) -> dict: def describe(self) -> dict:
@@ -751,7 +752,7 @@ class Scene3DView(QWidget):
self._panel.set_state(state) self._panel.set_state(state)
self.stateChanged.emit(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.""" """Raise a coordinator diagnostic once, when it changes."""
if item is None or item == self._reported_diagnostic: if item is None or item == self._reported_diagnostic:
return return
+18 -17
View File
@@ -17,23 +17,24 @@ two-dimensional application carries on.
from __future__ import annotations from __future__ import annotations
import time 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 ( from orisys_app_shared.scene3d.contracts import (
Diagnostic, Diagnostic,
DisplaySettings, DisplaySettings,
FrameIdentity,
GLCapabilities, GLCapabilities,
RenderFrame, RenderFrame,
RenderStats, RenderStats,
SceneAssets, SceneAssets,
diagnostic, 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.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import ( from orisys_studio.scene3d.gl_backend import (
GLDispatch, GLDispatch,
@@ -62,27 +63,27 @@ class Sensor3DWidget(QOpenGLWidget):
self, self,
renderer: GLRenderer, renderer: GLRenderer,
camera: OrbitCamera, camera: OrbitCamera,
parent: Optional[QWidget] = None, parent: QWidget | None = None,
*, *,
dispatch: Optional[GLDispatch] = None, dispatch: GLDispatch | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._renderer = renderer self._renderer = renderer
self._camera = camera self._camera = camera
self._dispatch = dispatch or GLDispatch() self._dispatch = dispatch or GLDispatch()
self._assets: Optional[SceneAssets] = None self._assets: SceneAssets | None = None
self._frame: Optional[RenderFrame] = None self._frame: RenderFrame | None = None
self._settings = DisplaySettings() self._settings = DisplaySettings()
self._capabilities: Optional[GLCapabilities] = None self._capabilities: GLCapabilities | None = None
self._diagnostic: Optional[Diagnostic] = None self._diagnostic: Diagnostic | None = None
self._needs_scene_upload = False self._needs_scene_upload = False
self._needs_surface_upload = False self._needs_surface_upload = False
self._last_stats_at = 0.0 self._last_stats_at = 0.0
#: What reached the screen, counted by identity, and what it cost to compose. #: What reached the screen, counted by identity, and what it cost to compose.
self._unique_frames = 0 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_compose_ms = 0.0
self._last_press: Optional[tuple[float, float]] = None self._last_press: tuple[float, float] | None = None
self._unavailable = False self._unavailable = False
#: Frames the display never showed, set by the coordinator. Never a #: Frames the display never showed, set by the coordinator. Never a
#: recording figure. #: recording figure.
@@ -119,7 +120,7 @@ class Sensor3DWidget(QOpenGLWidget):
def camera(self) -> OrbitCamera: def camera(self) -> OrbitCamera:
return self._camera return self._camera
def capabilities(self) -> Optional[GLCapabilities]: def capabilities(self) -> GLCapabilities | None:
return self._capabilities return self._capabilities
def displayed_frames(self) -> int: 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.""" """What the frame on screen cost to compose, measured where it was composed."""
return float(self._last_compose_ms) return float(self._last_compose_ms)
def diagnostic(self) -> Optional[Diagnostic]: def diagnostic(self) -> Diagnostic | None:
return self._diagnostic return self._diagnostic
@property @property
@@ -24,11 +24,12 @@ from __future__ import annotations
import logging import logging
import time import time
from typing import Any, Callable, Optional from collections.abc import Callable
from typing import Any
from PySide6.QtCore import QObject, QTimer, Signal
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtCore import QObject, QTimer, Signal
from orisys_studio import session_metadata from orisys_studio import session_metadata
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,7 +59,7 @@ class SessionController(QObject):
*, *,
recording: Any, recording: Any,
packet_for_3d: Callable[[Any], None], packet_for_3d: Callable[[Any], None],
packet_sink: Optional[Callable[[Any], None]] = None, packet_sink: Callable[[Any], None] | None = None,
sensor_type: str = "box", sensor_type: str = "box",
) -> None: ) -> None:
super().__init__() super().__init__()
@@ -82,9 +83,9 @@ class SessionController(QObject):
self._connect_slow_notified = False self._connect_slow_notified = False
self._raw_fallback_notified = False self._raw_fallback_notified = False
# --- per-session telemetry (read by the rail, the plots and the record) ----- # --- per-session telemetry (read by the rail, the plots and the record) -----
self.last_sample_valid: Optional[bool] = None self.last_sample_valid: bool | None = None
self.latest_fps: Optional[float] = None self.latest_fps: float | None = None
self.latest_process_ms: Optional[float] = None self.latest_process_ms: float | None = None
self.latest_capture_fps = 0.0 self.latest_capture_fps = 0.0
self.latest_capture_dropped = 0 self.latest_capture_dropped = 0
#: How many readouts failed this session, for the status line's count. #: 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 return consumer() if callable(consumer) else None
# ------------------------------------------------------------- presenter help # ------------------------------------------------------------- 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. """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: 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).""" """The status line for a failed readout (the phase is deliberately untouched)."""
return t("status.sample_invalid", count=self.record_error_count, reason=reason) 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.""" """One message per new batch of samples the memory bound discarded."""
if int(dropped) <= self.dropped_reported or int(dropped) <= 0: if int(dropped) <= self.dropped_reported or int(dropped) <= 0:
return None return None
@@ -25,9 +25,10 @@ from __future__ import annotations
import hashlib import hashlib
import logging import logging
from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path 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.force.filters import FilterSettings, settings_from_mapping
from orisys_app_shared.record import RecordConfig from orisys_app_shared.record import RecordConfig
@@ -198,7 +199,8 @@ class SessionFacts:
def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict: def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict:
"""The ``session.json`` metadata written when a recording starts.""" """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 from orisys_studio import __version__ as app_version
@@ -2,13 +2,12 @@
from __future__ import annotations 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.QtCore import Qt
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout 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 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 __future__ import annotations
from typing import Optional from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
from PySide6.QtCore import QSize, Qt, Signal 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.QtGui import QImage, QPainter, QPixmap, QTransform
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
@@ -23,15 +28,6 @@ from PySide6.QtWidgets import (
QVBoxLayout, 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 from orisys_studio.ui.fit_text import ShrinkableLabel
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH) VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
@@ -340,7 +336,7 @@ class ArrowPanel(QFrame):
self._placeholder = self._empty_message() self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder) 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. """Adopt what the frame on screen can be shown as.
The modes come from the frame's own fields (see ``image_backend.plan_image``), The modes come from the frame's own fields (see ``image_backend.plan_image``),
@@ -14,13 +14,12 @@ from __future__ import annotations
import logging import logging
from pathlib import Path 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.i18n import t
from orisys_app_shared.paths import logo_topbar_path from orisys_app_shared.paths import logo_topbar_path
from orisys_app_shared.theme import color 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__) logger = logging.getLogger(__name__)
+6 -13
View File
@@ -3,6 +3,12 @@
from __future__ import annotations from __future__ import annotations
import numpy as np 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.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase from PySide6.QtGui import QColor, QFont, QFontDatabase
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -28,19 +34,6 @@ from PySide6.QtWidgets import (
QWidget, 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.calibration_service import CalibrationService
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog 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 from __future__ import annotations
import numpy as np 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.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@@ -17,10 +18,6 @@ from PySide6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
) )
from orisys_app_shared.force import NA
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
try: try:
import pyqtgraph as pg import pyqtgraph as pg
except ImportError as exc: # pragma: no cover except ImportError as exc: # pragma: no cover
+12 -13
View File
@@ -5,16 +5,6 @@ from __future__ import annotations
import time import time
import numpy as np 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.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS 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.i18n import t
from orisys_app_shared.record.sample import plottable_axis_mask from orisys_app_shared.record.sample import plottable_axis_mask
from orisys_app_shared.theme import color 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 import icons
from orisys_studio.ui.icons import ICON_SIZE 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: class _Sync:
"""Marks a block of range changes as programmatic (not a user gesture).""" """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._panel = panel
self._previous = panel._syncing self._previous = panel._syncing
def __enter__(self) -> "_Sync": def __enter__(self) -> _Sync:
self._panel._syncing = True self._panel._syncing = True
return self return self
@@ -1054,7 +1053,7 @@ class ForcePlotsPanel(QFrame):
self._window = (x0, x1) self._window = (x0, x1)
self.refresh_from_store() self.refresh_from_store()
def _guard(self) -> "_Sync": def _guard(self) -> _Sync:
"""Context manager marking range changes as programmatic.""" """Context manager marking range changes as programmatic."""
return _Sync(self) return _Sync(self)
+1 -2
View File
@@ -14,11 +14,10 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from orisys_app_shared.theme import color
from PySide6.QtCore import QRectF, Qt from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap 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 of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view") 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 __future__ import annotations
from collections.abc import Callable
from pathlib import Path 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.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
@@ -27,13 +33,6 @@ from PySide6.QtWidgets import (
QWidget, 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 ( from orisys_studio.scene3d.asset_source import (
SceneAssetLoader, SceneAssetLoader,
SceneSource, 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 if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
from orisys_app_shared.acquisition.geometry import DeviceGeometry from orisys_app_shared.acquisition.geometry import DeviceGeometry
from orisys_studio.scene3d.view import Scene3DView from orisys_studio.scene3d.view import Scene3DView
#: Shown when the pane is on but this sensor has no 3D scene at all (box, mini). #: 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) enabledChanged = Signal(bool)
def __init__( def __init__(
self, parent: Optional[QWidget] = None, *, theme: str = "dark" self, parent: QWidget | None = None, *, theme: str = "dark"
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setObjectName("contentPanel") self.setObjectName("contentPanel")
self._theme = str(theme) self._theme = str(theme)
self._view: Optional["Scene3DView"] = None self._view: Scene3DView | None = None
self._loader: Optional[SceneAssetLoader] = None self._loader: SceneAssetLoader | None = None
self._source: Optional[SceneSource] = None self._source: SceneSource | None = None
self._sensor_type = "" self._sensor_type = ""
self._expected: Optional[ExpectedGeometry] = None self._expected: ExpectedGeometry | None = None
self._expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None self._expected_provider: Callable[[], DeviceGeometry] | None = None
self._source_kind = "live" self._source_kind = "live"
self._enabled = False self._enabled = False
self._loaded = False self._loaded = False
@@ -103,8 +103,8 @@ class Mesh3dPanel(QFrame):
self._keys = None self._keys = None
#: The package whose scene is in the view, so switching sensor away and back #: 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). #: does not reload it (and does not pretend to be loading it either).
self._loaded_path: Optional[Path] = None self._loaded_path: Path | None = None
self._reported: Optional[Diagnostic] = None self._reported: Diagnostic | None = None
self._pending_settings = None self._pending_settings = None
self._message_kind = _UNAVAILABLE self._message_kind = _UNAVAILABLE
self._message_args: dict[str, str] = {"reason": ""} self._message_args: dict[str, str] = {"reason": ""}
@@ -225,8 +225,8 @@ class Mesh3dPanel(QFrame):
self, self,
sensor_type: str, sensor_type: str,
*, *,
expected: Optional[ExpectedGeometry] = None, expected: ExpectedGeometry | None = None,
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None, expected_provider: Callable[[], DeviceGeometry] | None = None,
package_override: str | Path | None = None, package_override: str | Path | None = None,
) -> None: ) -> None:
"""Tell the pane which sensor it is looking at. Cheap; no IO. """Tell the pane which sensor it is looking at. Cheap; no IO.
@@ -276,7 +276,7 @@ class Mesh3dPanel(QFrame):
return return
self._show_message(_LOADING, name=self._source.path.name) 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. """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 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: if not view.available:
self._show_message(_UNAVAILABLE, reason=view.reason) 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.""" """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": if self._view is not None or self._sensor_type != "finger":
return self._view return self._view
@@ -448,11 +448,11 @@ class Mesh3dPanel(QFrame):
# ------------------------------------------------------------------ inputs # ------------------------------------------------------------------ inputs
@property @property
def view(self) -> Optional["Scene3DView"]: def view(self) -> Scene3DView | None:
return self._view return self._view
@property @property
def last_diagnostic(self) -> Optional[Diagnostic]: def last_diagnostic(self) -> Diagnostic | None:
return self._reported return self._reported
@property @property
@@ -471,7 +471,7 @@ class Mesh3dPanel(QFrame):
"""Whether a 3D picture is possible here at all (GL and an asset).""" """Whether a 3D picture is possible here at all (GL and an asset)."""
return self._view is not None and bool(self._view.available) 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 return None if self._source is None else self._source.path
def set_source_kind(self, kind: str) -> None: 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 __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 ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QComboBox, QComboBox,
@@ -25,14 +31,6 @@ from PySide6.QtWidgets import (
QVBoxLayout, 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 DEFAULT_PLOT_WINDOW_SEC = 4.0
#: Lower bounds. FN is allowed below zero: a resting normal force has no reason #: 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 #: 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 __future__ import annotations
from PySide6.QtWidgets import QLabel
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color from orisys_app_shared.theme import color
from PySide6.QtWidgets import QLabel
#: The recording channel is idle, never started in this session. #: The recording channel is idle, never started in this session.
IDLE = "idle" IDLE = "idle"
@@ -17,6 +17,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from orisys_app_shared.i18n import t
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QFrame, QFrame,
@@ -26,8 +27,6 @@ from PySide6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
) )
from orisys_app_shared.i18n import t
from orisys_studio.ui.fit_text import ShrinkableLabel 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 __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.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
@@ -18,9 +20,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
CONTROL_HEIGHT = 30 CONTROL_HEIGHT = 30
#: Both combos stay narrow: this row shares one line with the session state. #: Both combos stay narrow: this row shares one line with the session state.
TYPE_WIDTH = 76 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 __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.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
@@ -33,9 +35,6 @@ from PySide6.QtWidgets import (
QWidget, 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.fit_text import ShrinkableLabel
from orisys_studio.ui.record_status import RecordStatusChip from orisys_studio.ui.record_status import RecordStatusChip
+4 -4
View File
@@ -22,7 +22,7 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Optional, Protocol from typing import Any, Protocol
from PySide6.QtCore import QObject from PySide6.QtCore import QObject
@@ -80,7 +80,7 @@ class ViewLayoutController(QObject):
default_splitter_sizes: tuple, default_splitter_sizes: tuple,
min_panel_widths: dict, min_panel_widths: dict,
default_3d_pane_width: int, default_3d_pane_width: int,
parent: Optional[QObject] = None, parent: QObject | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._host = host self._host = host
@@ -103,7 +103,7 @@ class ViewLayoutController(QObject):
self._view_preset = preset if preset in self._presets else "custom" self._view_preset = preset if preset in self._presets else "custom"
self._show_cal = bool(host.prefs.get("show_cal_panel", False)) self._show_cal = bool(host.prefs.get("show_cal_panel", False))
self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True)) self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True))
self._actions: Optional[LayoutActions] = None self._actions: LayoutActions | None = None
# ------------------------------------------------------------------ wiring # ------------------------------------------------------------------ wiring
def bind_actions( def bind_actions(
@@ -244,7 +244,7 @@ class ViewLayoutController(QObject):
self._set_check(self._actions.settings, self._toolbar.settings_expanded()) self._set_check(self._actions.settings, self._toolbar.settings_expanded())
@staticmethod @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.""" """Set a checkable entry without letting it drive its own handler."""
if action is None: if action is None:
return return
+1 -1
View File
@@ -62,8 +62,8 @@ def qt_app():
@pytest.fixture @pytest.fixture
def make_window(qt_app, tmp_path, monkeypatch): def make_window(qt_app, tmp_path, monkeypatch):
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path.""" """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 paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
from orisys_studio.app_window import StudioMainWindow from orisys_studio.app_window import StudioMainWindow
prefs_file = tmp_path / "preference.json" prefs_file = tmp_path / "preference.json"
@@ -9,7 +9,6 @@ the curves and the value cards were actually given.
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from orisys_app_shared.force.session import SessionSample, SessionStore from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force 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 __future__ import annotations
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from tests.conftest import finish_run, running_window, show_window 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 from __future__ import annotations
import pytest import pytest
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio import replay_controller from orisys_studio import replay_controller
from orisys_studio.replay_controller import ReplayController 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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
import numpy as np import numpy as np
import pytest import pytest
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
ExpectedGeometry, ExpectedGeometry,
FrameIdentity, FrameIdentity,
ThreeDError,
validate_surface_frame, validate_surface_frame,
) )
from orisys_studio.scene3d.adapters import ( from orisys_studio.scene3d.adapters import (
@@ -40,16 +37,16 @@ class FakePacket:
behaviour under test. behaviour under test.
""" """
flow: Optional[np.ndarray] = None flow: np.ndarray | None = None
depth: Optional[np.ndarray] = None depth: np.ndarray | None = None
valid_mask: Optional[np.ndarray] = None valid_mask: np.ndarray | None = None
frame_id: int = 1 frame_id: int = 1
invalid_reason: str = "" invalid_reason: str = ""
flow_valid: Optional[bool] = True flow_valid: bool | None = True
flow_frame_id: Optional[int] = None flow_frame_id: int | None = None
depth_valid: Optional[bool] = True depth_valid: bool | None = True
depth_frame_id: Optional[int] = None depth_frame_id: int | None = None
forces_valid: Optional[bool] = True forces_valid: bool | None = True
def _identity(generation: int = 1) -> FrameIdentity: def _identity(generation: int = 1) -> FrameIdentity:
@@ -15,7 +15,6 @@ from dataclasses import replace
import numpy as np import numpy as np
import pytest import pytest
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
+5 -2
View File
@@ -23,7 +23,6 @@ import os
import numpy as np import numpy as np
import pytest import pytest
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
ColorScale, ColorScale,
@@ -105,7 +104,11 @@ def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
def gl_widget(qt_app): def gl_widget(qt_app):
"""A real, shown QOpenGLWidget with the synthetic scene, driven by Qt.""" """A real, shown QOpenGLWidget with the synthetic scene, driven by Qt."""
from orisys_studio.scene3d.camera import OrbitCamera 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 from orisys_studio.scene3d.widget import Sensor3DWidget
configure_surface_format("desktop33") configure_surface_format("desktop33")
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from orisys_app_shared.acquisition.arrows import _policy_from_style from orisys_app_shared.acquisition.arrows import _policy_from_style
from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import ( 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 from __future__ import annotations
import pytest import pytest
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio.ui import icons from orisys_studio.ui import icons
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
@pytest.fixture @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): def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
"""Warnings are claims about the measurement and always show; info is explanation.""" """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_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False))
panel.set_render_stats( panel.set_render_stats(
@@ -13,7 +13,6 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from orisys_app_shared.scene3d import protect, synthetic from orisys_app_shared.scene3d import protect, synthetic
from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError
from orisys_app_shared.scene3d.package import write_package 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, RenderFrame,
freeze, freeze,
) )
from PySide6.QtGui import QImage
from orisys_studio.scene3d import gl_backend from orisys_studio.scene3d import gl_backend
from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import ( from orisys_studio.scene3d.gl_backend import (
@@ -415,6 +413,7 @@ def _render_region(qt_app, assets, island, uvs, preset_name, viewport):
lut_rgba, lut_rgba,
) )
from orisys_studio.scene3d.widget import Sensor3DWidget from orisys_studio.scene3d.widget import Sensor3DWidget
from PySide6.QtGui import QImage
configure_surface_format("desktop33") configure_surface_format("desktop33")
renderer = GLRenderer() 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): 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 orisys_studio.scene3d.gl_backend import configure_surface_format
from PySide6.QtGui import QSurfaceFormat
configure_surface_format("desktop33") configure_surface_format("desktop33")
desktop = QSurfaceFormat.defaultFormat() 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): 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.""" """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.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import GLRenderer from orisys_studio.scene3d.gl_backend import GLRenderer
from orisys_studio.scene3d.widget import Sensor3DWidget from orisys_studio.scene3d.widget import Sensor3DWidget
-1
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import numpy as np import numpy as np
import pytest import pytest
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
CameraPreset, 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 from __future__ import annotations
import math
import time import time
from concurrent.futures import Future from concurrent.futures import Future
from dataclasses import replace from dataclasses import replace
@@ -27,17 +28,14 @@ from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
import ui_check import ui_check
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
UNAVAILABLE_DESCRIPTOR,
ColorScale, ColorScale,
DisplaySettings, DisplaySettings,
FieldDescriptor, FieldDescriptor,
UNAVAILABLE_DESCRIPTOR,
) )
from orisys_app_shared.scene3d.package import write_package from orisys_app_shared.scene3d.package import write_package
from orisys_studio.app_window import MIN_PANEL_WIDTHS from orisys_studio.app_window import MIN_PANEL_WIDTHS
@@ -49,9 +47,15 @@ from orisys_studio.scene3d.asset_source import (
load_package, load_package,
no_asset_diagnostic, no_asset_diagnostic,
) )
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from tests.conftest import ( from tests.conftest import (
finish_run as _finish, finish_run as _finish,
)
from tests.conftest import (
running_window as _running_window, running_window as _running_window,
)
from tests.conftest import (
show_window as _show, show_window as _show,
) )
@@ -60,7 +64,7 @@ from tests.conftest import (
def write_poc(tmp_path): def write_poc(tmp_path):
"""A real package on disk, produced by the shipping writer.""" """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}" path = tmp_path / f"{name}{PACKAGE_SUFFIX}"
write_package( write_package(
synthetic.poc_scene(), 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 assert settings.field in scene3d_prefs.FIELD_CHOICES
if settings.color_scale is not None: if settings.color_scale is not None:
lower, upper = settings.color_scale.lower, settings.color_scale.upper 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 upper > lower
assert abs(lower) <= scene3d_prefs.MAX_SCALE_BOUND assert abs(lower) <= scene3d_prefs.MAX_SCALE_BOUND
assert abs(upper) <= 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, 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. so the picture stays on the first run's last frame forever.
""" """
from orisys_app_shared.scene3d.contracts import FrameIdentity
import ui_check import ui_check
from orisys_app_shared.scene3d.contracts import FrameIdentity
window = _show(make_window(sensor_type="finger"), (1440, 880)) window = _show(make_window(sensor_type="finger"), (1440, 880))
try: try:
-2
View File
@@ -10,8 +10,6 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
from orisys_studio import app_window from orisys_studio import app_window
@@ -9,7 +9,6 @@ recording coordinator and fake workers.
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from orisys_studio import session_controller from orisys_studio import session_controller
from orisys_studio.session_controller import SessionController from orisys_studio.session_controller import SessionController
+1 -2
View File
@@ -14,7 +14,6 @@ from types import SimpleNamespace
import numpy as np import numpy as np
import pytest import pytest
from orisys_app_shared.force.filters import FilterSettings from orisys_app_shared.force.filters import FilterSettings
from orisys_app_shared.record import RecordConfig from orisys_app_shared.record import RecordConfig
from orisys_studio import session_metadata as sm 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( worker = SimpleNamespace(
capture_status=lambda: {"requested_fps": 30.0, "measured_fps": 21.0}, capture_status=lambda: {"requested_fps": 30.0, "measured_fps": 21.0},
filter_state=lambda: {}, filter_state=dict,
filter_settings=lambda: FilterSettings(), filter_settings=lambda: FilterSettings(),
filters_unsupported=lambda: (), filters_unsupported=lambda: (),
) )
@@ -13,7 +13,6 @@ Written against the requirement rather than the implementation:
from __future__ import annotations from __future__ import annotations
import ui_check import ui_check
from tests.conftest import finish_run, running_window, show_window 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 from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
import ui_check import ui_check
from tests.conftest import ( from tests.conftest import (
finish_run as _finish, finish_run as _finish,
)
from tests.conftest import (
running_window as _running_window, running_window as _running_window,
)
from tests.conftest import (
show_window as _show, show_window as _show,
)
from tests.conftest import (
visible_descendants, 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 from pathlib import Path
import orisys.calibration as sdk_calibration import orisys.calibration as sdk_calibration
from orisys_app_shared.force.calibration_io import build_force_calibration_v2 from orisys_app_shared.force.calibration_io import build_force_calibration_v2
window = _show(make_window(), (1400, 880)) 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.""" """The apply path states a sensor/config mismatch instead of silently applying."""
import json import json
from orisys_app_shared.force.calibration_io import build_export_model, write_force_calibration from orisys_app_shared.force.calibration_io import (
from orisys_app_shared.force.calibration_io import CalSample, CalSessionStore CalSample,
CalSessionStore,
build_export_model,
)
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
store = CalSessionStore() store = CalSessionStore()
@@ -816,7 +822,6 @@ def test_a_confirmed_calibration_is_snapshotted_into_the_recording(make_window,
from pathlib import Path from pathlib import Path
import orisys_studio.app_window as app_module import orisys_studio.app_window as app_module
from orisys_app_shared.i18n import t
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
app_module.recordings_dir = lambda: record_root 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 icon-only pause button next to it, so the same position meant two different
things depending on which view was on screen. things depending on which view was on screen.
""" """
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker() 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): def test_record_strip_separates_acquiring_from_recording(make_window):
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_app_shared.record.experiment import RecordStats from orisys_app_shared.record.experiment import RecordStats
from orisys_studio.ui import record_status from orisys_studio.ui import record_status
window, stuck = _running_window(make_window) 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): def test_the_charts_start_in_follow_mode(make_window):
"""Every live entry point resumes following; only browsing is a gesture.""" """Every live entry point resumes following; only browsing is a gesture."""
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
panel = window.force_plots 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): def test_the_sensor_summary_opens_the_dialog(make_window):
"""The rail states the sensor; the pickers live in the dialog it opens.""" """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 orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
assert window.toolbar.btn_sensor.text() == t( 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): 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.""" """The replay view reads the recording: charts, 2D panel and transport."""
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click() 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.""" """One click on the unit chip changes the unit everywhere, not just locally."""
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import unit_display from orisys_studio.ui.force_plots import unit_display
from PySide6.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
window = _show(make_window(), (1440, 880)) 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 csv
import cv2 import cv2
import orisys_studio.app_window as app_module import orisys_studio.app_window as app_module
window, stuck = _running_window(make_window) 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 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". 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 orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click() 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 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". 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 orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.toolbar.set_units("newton") 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, there: plain playback and a recompute pass. The tracker reset owns no device here,
so it is disabled rather than repurposed. so it is disabled rather than repurposed.
""" """
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click() 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): def test_the_live_view_keeps_the_tracker_reset(make_window):
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click() 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 cv2
import numpy as np import numpy as np
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
bare = Path(record_root) / "bare_video.mp4" bare = Path(record_root) / "bare_video.mp4"
writer = cv2.VideoWriter( 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 cv2
import numpy as np import numpy as np
from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module import orisys_studio.app_window as app_module
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
DERIVED_MODES, DERIVED_MODES,
ImageFrame,
SOURCE_LIVE, SOURCE_LIVE,
SOURCE_RECOMPUTE, SOURCE_RECOMPUTE,
ImageFrame,
) )
from PySide6.QtWidgets import QApplication
bare = Path(record_root) / "backend_video.mp4" bare = Path(record_root) / "backend_video.mp4"
writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)) 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 cv2
import numpy as np import numpy as np
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
bare = Path(record_root) / "gated_video.mp4" bare = Path(record_root) / "gated_video.mp4"
writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)) 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.""" """Not a recording *and* not decodable: say what is missing and offer recompute."""
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
broken = Path(record_root) / "not_really_a_video.mp4" broken = Path(record_root) / "not_really_a_video.mp4"
broken.write_text("this is not a video at all\n", encoding="utf-8") 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( def test_a_video_inside_a_recording_opens_the_recording_and_plays(
make_window, replay_recording make_window, replay_recording
): ):
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click() 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 csv
import json import json
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
directory = Path(record_root) / "no_video_recording" directory = Path(record_root) / "no_video_recording"
directory.mkdir(parents=True, exist_ok=True) 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 import time
from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module import orisys_studio.app_window as app_module
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880)) window = _show(make_window(), (1440, 880))
assert not hasattr(window, "device_label"), ( assert not hasattr(window, "device_label"), (

Some files were not shown because too many files have changed in this diff Show More