Compare commits

..

7 Commits

Author SHA1 Message Date
ykcheungab 720bd81031 fixed window flashing bug 2026-09-28 21:17:26 +08:00
ykcheungab b8dc5e925b docs: repoint architecture docs at the workspace docs tree; ignore .scratch/
The workspace-root scratch cleanup (2026-09-27) deleted .repro/, the _review_*/
probe bundles and _probe_dataarea/. These tracked documents cited files inside
those directories, so their references are now explicitly marked as historical
and, where the evidence survived, repointed:

- record-channel.md section 7: the ten reproduction conditions are recorded in the
  archived review (docs/archive/reviews/phase12-review.md); reproduce.py is gone.
- studio-3d-stages.md: the _review_scene3d_20260914 review is archived under
  docs/archive/reviews/; the probe_stage6_* scripts and the .repro/ scan scripts
  are marked deleted. The perf report's stale .repro package path is explained in
  a note instead of editing the committed probe report, which feeds
  compile_scene.py --lock-from-probe.
- studio-architecture.md: the scan scripts that generated the diagrams are gone;
  the committed .svg/.png remain the surviving artefacts.

Also ignore .scratch/, the new per-task scratch directory (see
D:\orisys_ws\AGENTS.md). No source file is touched by this commit.
2026-09-27 21:14:20 +08:00
ykcheungab fa2703968e added ruff test 2026-09-27 19:20:43 +08:00
ykcheungab bcab2790bf modify 3d view to sync / identical to 2d view 2026-09-27 10:42:38 +08:00
ykcheungab 3daee6d407 fixed display problem 2026-09-25 17:53:07 +08:00
ykcheungab 12781854ca fixed commetn from songxiao 2026-09-25 17:46:37 +08:00
ykcheungab 6effe74172 move controller connect to popup dialog 2026-09-24 21:36:24 +08:00
207 changed files with 11450 additions and 2002 deletions
+8
View File
@@ -25,9 +25,17 @@ apps/studio/preference.json
shared/tests/_scratch/
shared/tests/_scratch_*/
# Working artefacts: screenshots and probe output from 3D/UI diagnosis, and assets parked
# out of the shipping path. None of these are inputs to a build or a release.
.snap/
_parked_stale_assets/
# IDE / OS
.idea/
.vscode/
*.swp
Thumbs.db
.DS_Store
# Agent scratch: one directory per task, see D:\orisys_ws\AGENTS.md
.scratch/
+23 -2
View File
@@ -112,6 +112,17 @@ calls them:
- **Motion is refused** while the bus is not initialised, while the axis is
unknown, or on a non-finite target — with a reason, never a silent no-op.
- **A pose move is refused before the first axis moves** if any part of it is wrong:
a target past the P0 limit — millimetres for x/y/z, **degrees** for yaw/pitch, so a
400° stage turn cannot pass a 100 mm check — an axis mapping that is incomplete,
points outside the drive range, or claims one drive twice, or a rotary role on an
axis with no user-units-per-degree calibration. A sequence that stopped halfway
would leave the machine in a pose nobody simulated, so every check runs first.
- **A move is stepped, not looped.** The drives are commanded one at a time from the
poll snapshots; a step that stalls (idle but short of its target), times out or
raises an alarm aborts the rest. That is also what makes **stop** work — it is an
ordinary command, so it runs at the next poll tick, and the queue is cancelled
before it is sent so no later axis is commanded.
- **Homing needs a configured mode.** The vendor example pre-fills `17`, which
is one specific drive's mode; AutoCal starts at `0` and refuses, so the
operator chooses for the drive in front of them instead of inheriting a number
@@ -157,13 +168,22 @@ between threads. The UI talks to it through signals only.
Both work with no display, no controller and no DLL:
```powershell
python .\apps\autocal\tools\panel_check.py # 170 layout/theme/locale/gating checks
python .\apps\autocal\tools\panel_check.py # layout/theme/locale/gating checks
python .\apps\autocal\tools\panel_snapshot.py --demo --theme dark --out shot.png
python .\apps\autocal\tools\panel_snapshot.py --compare --out tabs.png # one image per tab
python .\apps\autocal\tools\panel_snapshot.py --setup-dialog bus --out setup.png
python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn
python -m pytest apps\autocal\tests # offscreen suite
```
Connection and bus initialisation are used once per machine start-up, so they live
in a **modeless device-setup dialog** (`ui/device_setup.py`) rather than the rail:
the rail carries what is read while working — the axis readout and the collapsed
SDO/RTEX monitor. The dialog opens from the status strip's 设备设置 button, from the
connection/bus/init chips (each opens the tab that explains it) or from
文件 → 设备设置, and the strip keeps reporting the live state whether it is open or
shut.
`preview_snapshot.py` is the one tool that can photograph the **3D pane**, which
`panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so
the pane is empty in those shots. It needs the real platform plugin and a visible
@@ -175,7 +195,8 @@ composited in. What the view draws, and what each colour means, is in
proposed single-page layout with the shared theme tokens and imports nothing
from the app, so the layout can be reviewed and marked up without building the
panels. It draws the full page plus previews of the other workspace tabs in one
canvas.
canvas, and `--page setup` draws the device-setup dialog over the page it opens
from.
`panel_check.py` builds the real window in both themes at 1440×900 and
1920×1080, asserts that no widget escapes its parent, that motion is
+40
View File
@@ -11,6 +11,7 @@
},
"menu": {
"file": "File",
"device_setup": "Device setup…",
"open_bas": "Download BAS init program…",
"reveal_config": "Show config in Explorer…",
"quit": "Quit",
@@ -27,6 +28,45 @@
"help": "Help",
"about": "About AutoCal"
},
"setup": {
"open": "Device setup",
"title": "Device 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, and this window can stay open while you work.",
"tab_connection": "Connection",
"tab_bus": "Bus initialisation",
"tab_axes": "Axis mapping",
"axes_hint": "The bus reports axis numbers, not what each one moves. The mapping below comes from the bench wiring, and it must be complete and free of duplicates before anything moves.",
"role_x": "X (table)",
"role_y": "Y (table)",
"role_z": "Z (press)",
"role_yaw": "yaw (rotation)",
"role_pitch": "pitch (tilt)",
"role_axis": "Drive",
"axes_duplicate": "One drive is claimed by more than one direction: {detail}",
"close": "Close",
"chip_hint": "Open device setup — {name}"
},
"move": {
"execute": "Execute",
"execute_hint": "Send the selected point's solved pose to the platform: yaw, then pitch, then table X/Y. The press axis Z is not moved.",
"stop": "Stop",
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
"return_initial": "Return to zero",
"return_hint": "Every axis back to the initial state, press axis Z first.",
"confirm_title": "Confirm motion",
"confirm_execute": "Will move, in this order:\n\n{steps}\n\nCheck this against the machine before continuing.",
"confirm_return": "Will move every axis back to zero (press axis Z first):\n\n{steps}",
"hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)",
"cancelled": "Cancelled; nothing was commanded",
"confirm_check": "Confirm before moving",
"confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.",
"no_point": "No calibration point is selected",
"busy": "A move is already running",
"refused": "Motion refused",
"done": "Move finished",
"failed": "Move failed",
"stopped": "Stopped; the remaining steps were cancelled"
},
"connection": {
"title": "Controller connection",
"mode": "Connection",
+40
View File
@@ -11,6 +11,7 @@
},
"menu": {
"file": "文件",
"device_setup": "设备设置…",
"open_bas": "下载 BAS 初始化程序…",
"reveal_config": "在资源管理器中显示配置…",
"quit": "退出",
@@ -27,6 +28,45 @@
"help": "帮助",
"about": "关于 AutoCal"
},
"setup": {
"open": "设备设置",
"title": "设备设置",
"hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
"tab_connection": "连接",
"tab_bus": "总线初始化",
"tab_axes": "轴映射",
"axes_hint": "总线只报告轴号,不报告它推的是哪个方向。这里的对应关系来自实际接线,运动前必须完整且不重复。",
"role_x": "X(工作台)",
"role_y": "Y(工作台)",
"role_z": "Z(压轴)",
"role_yaw": "yaw(回转)",
"role_pitch": "pitch(俯仰)",
"role_axis": "驱动器",
"axes_duplicate": "同一驱动器被分配给多个方向:{detail}",
"close": "关闭",
"chip_hint": "点击打开设备设置 — {name}"
},
"move": {
"execute": "执行动作",
"execute_hint": "把选中点解算出的位姿送到平台:先回转、再俯仰,最后工作台 X/Y。压轴 Z 不动作。",
"stop": "停止",
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
"return_initial": "回初始位",
"return_hint": "五个轴全部回到零点,先退压轴 Z。",
"confirm_title": "确认运动",
"confirm_execute": "将按以下顺序移动:\n\n{steps}\n\n请确认仿真与机床一致。",
"confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}",
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)",
"cancelled": "已取消,未发出任何运动",
"confirm_check": "移动前确认",
"confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。",
"no_point": "未选中标定点,无法执行",
"busy": "已有运动在执行",
"refused": "运动被拒绝",
"done": "运动完成",
"failed": "运动失败",
"stopped": "已停止,后续动作已取消"
},
"connection": {
"title": "连接控制器",
"mode": "连接方式",
+24
View File
@@ -61,6 +61,30 @@ which can put the face on the far side of the body from the default camera. The
axis is drawn with depth testing **off** for that reason — it is a reference line,
and hiding it behind the body it is meant to align with is the opposite of useful.
## Running the pose on the machine
Three buttons, and where each one lives is deliberate:
| Button | Where | What it does |
| --- | --- | --- |
| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: **yaw, then pitch, then table X/Y**. The press axis **z is not commanded** — pressing is P7, with contact detection and press depth, neither of which exists here, so a move to the contact height would be a press with none of its guards. |
| **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. |
| **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. |
**移动前确认** decides whether the moving buttons ask first. The prompt lists the steps
with their numbers and the drive each one uses, and says what is *not* being moved.
The order is the model's own: the kinematics composes `R_pitch · R_yaw`, so yaw acts on
the body first and the machine yaws first — every intermediate pose is one the preview
drew. Table X/Y last, because the translation is what the rotations are resolved
against.
Each drive is commanded on its own and the next goes out only once the previous has
arrived, which is what keeps the move interruptible; a step that stops short, times out
or alarms aborts the rest rather than finishing a sequence nobody has verified. What is
checked *before* the first axis moves — the mapping, the P0 limits, the rotary
calibration — is in `../README.md` under **Safety model**.
The chain the solver and the drawing share is, in order: **mount** (sensor axes into
table axes) → **setup** (the mounting pose above) → **stages** (the solved yaw and
pitch, about the table origin) → **table** (the XY slide). A point of the sensor is
+16
View File
@@ -67,6 +67,11 @@ or timeout → read `BUS_TYPE`, `Bus_TotalAxisnum`, and the node count. Step 2 i
conditional because a controller usually still has the program in ROM from the
last session, and a download is a bus re-scan the operator did not ask for.
Both controls live in the **device setup dialog** (设备设置), opened from the status
strip's button, its connection/bus/init chips, or 文件 → 设备设置: they are used once
per machine start-up, so the rail keeps only what is read while working. The strip
continues to report connection, bus and init state while the dialog is open or shut.
**Re-initialising without a download:** `STOPTASK <task>` then
`RUN "<file>",<task>`, on the task from `bas_task` in `preference.json`
(default 4 — the task the vendor reference uses; stopping another task restarts
@@ -231,6 +236,17 @@ measured time per point.
**Gate:** the operator has seen the path and a dry run has completed without a
guard violation.
**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the
selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and
does **not** command the press axis, so it cannot reach the sensor. The order is the
model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator
watched in the preview. Before it moves it checks the mapping, the target against the
P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration —
all in `motion/service.py`, so a later caller cannot walk around them — and the drives
are commanded one at a time, each waiting for the previous to arrive, with a stall or
an alarm aborting the rest. 停止 next to it is the same command as the strip's
全部停止; 回初始位 in the rail sends every axis to zero with the press axis first.
---
## P7 — Run + acquire
+4 -2
View File
@@ -66,14 +66,16 @@ def main(argv: list[str] | None = None) -> int:
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
_force_utf8_streams()
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared import API_VERSION
from orisys_app_shared import __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.logging import init_logging, install_exception_hooks
from orisys_app_shared.theme import build_qss
from PySide6.QtWidgets import QApplication
from . import REQUIRES_SHARED_API, __version__ as app_version
from . import REQUIRES_SHARED_API
from . import __version__ as app_version
from .motion import ConnectionSpec, DemoTransport
from .preferences import load_preferences
from .ui.window import AutoCalMainWindow
@@ -16,7 +16,22 @@ from .models import (
Snapshot,
)
from .poller import MotionPoller
from .service import DEFAULT_BAS_TASK, PLATFORM_AXES, MotionService, clamp_bas_task
from .pose_move import (
MovePlan,
MoveStep,
PoseMover,
plan_pose_move,
plan_return_to_initial,
)
from .service import (
AXIS_ROLES,
DEFAULT_AXIS_ROLES,
DEFAULT_BAS_TASK,
PLATFORM_AXES,
MotionService,
clamp_bas_task,
normalize_axis_roles,
)
from .transport import DemoTransport, NativeTransport, ZmcTransport
from .units import (
DEFAULT_ACCEL,
@@ -33,7 +48,9 @@ from .units import (
)
__all__ = [
"AXIS_ROLES",
"DEFAULT_ACCEL",
"DEFAULT_AXIS_ROLES",
"DEFAULT_BAS_TASK",
"DEFAULT_SPEED",
"DEFAULT_UNITS",
@@ -55,12 +72,18 @@ __all__ = [
"MotionPoller",
"MotionResult",
"MotionService",
"MovePlan",
"MoveStep",
"NativeTransport",
"PoseMover",
"Snapshot",
"ZmcTransport",
"clamp_bas_task",
"deg_to_user_units",
"mm_to_user_units",
"normalize_axis_roles",
"plan_pose_move",
"plan_return_to_initial",
"safe_float",
"units_per_degree",
"user_units_to_deg",
@@ -438,7 +438,7 @@ class PlatformStack:
def alignment_branches(
self, unit: np.ndarray, tool: np.ndarray
) -> list["Branch"]:
) -> list[Branch]:
"""Every exact pose that puts ``unit`` on ``tool``, in closed form.
Two ``atan2`` calls, no search. The chain is yaw-then-pitch, so:
@@ -583,7 +583,7 @@ class PlatformStack:
return math.degrees(math.atan2(sine_term, cosine_term))
@staticmethod
def select_branch(branches: list["Branch"]) -> "Branch":
def select_branch(branches: list[Branch]) -> Branch:
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
Every branch aligns to within floating point, so alignment itself rarely
@@ -596,7 +596,7 @@ class PlatformStack:
stages is to align. The tie only decides between poses that are the same
to any tolerance the machine can hold.
"""
def rank(branch: "Branch") -> tuple:
def rank(branch: Branch) -> tuple:
# Alignment first: if one branch genuinely aligns better, that is the
# answer, because aligning is what the stages are for. Reachability and
# orientation decide between branches that align the same, which is the
@@ -1051,7 +1051,7 @@ class PlatformStack:
def _better_pose(
candidate: tuple[float, float, float],
best: tuple[float, float, float],
stack: "PlatformStack",
stack: PlatformStack,
) -> bool:
"""Rank two ``(residual, yaw, pitch)`` candidates.
@@ -173,14 +173,14 @@ class MotionResult:
message: str = ""
@classmethod
def success(cls, message: str = "") -> "MotionResult":
def success(cls, message: str = "") -> MotionResult:
return cls(ok=True, code=0, message=message)
@classmethod
def refused(cls, message: str) -> "MotionResult":
def refused(cls, message: str) -> MotionResult:
"""The command was not sent: a guard rejected it before any motion."""
return cls(ok=False, code=0, message=message)
@classmethod
def failed(cls, code: int, message: str) -> "MotionResult":
def failed(cls, code: int, message: str) -> MotionResult:
return cls(ok=False, code=int(code), message=message)
@@ -21,7 +21,8 @@ Contract:
from __future__ import annotations
import logging
from typing import Any, Callable, Iterable
from collections.abc import Callable, Iterable
from typing import Any
from PySide6.QtCore import (
QMetaObject,
@@ -288,7 +289,6 @@ class MotionPoller(QObject):
# closes immediately and leaves the platform coasting.
service.stop_all()
service.disconnect()
return None
return self.submit(_disconnect)
@@ -0,0 +1,315 @@
"""Moving the platform to a simulated pose, one axis at a time.
The finger screen solves a pose — rotate the stages, slide the table — and shows it.
Executing it is a *sequence* of coordinated moves, and where that sequence lives
matters:
* The **service** owns the guards: connected, bus initialised, mapping sane, targets
finite, inside the P0 limits, rotary axes calibrated. Nothing here bypasses them.
* The **worker thread** owns the controller calls. One command per poll tick is how
:class:`~orisys_autocal.motion.poller.MotionPoller` is built, and it is why a stop
is possible at all: a mover that sat in a loop inside one command could not be
interrupted, because the stop would have to wait for the loop it is meant to abort.
* **This module** owns the order and the waiting. It watches the snapshots the poller
already produces, issues the next axis when the current one has arrived, and gives
up on its own if a step stops making progress.
That shape is the whole reason for the class: ``stop`` is submitted as an ordinary
command, so it runs at the next tick and stops the axis that is moving *now*.
Why the order is what it is: the model composes ``R_pitch · R_yaw``, i.e. the yaw acts
on the body first. Commanding yaw before pitch keeps every intermediate pose on the
path the operator watched in the preview. Table X/Y last, because the table
translation is what the rotations are resolved against.
"""
from __future__ import annotations
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from PySide6.QtCore import QObject, Signal
from .models import MotionResult, Snapshot
from .service import LINEAR_ROLES, MotionService
from .units import user_units_to_deg
#: How close an axis must be to its target before the next one starts, per unit:
#: millimetres for the linear roles, degrees for the rotary ones. One user unit is
#: 0.2 µm on the bench (``units=10``, 5000 pulses/mm), so 0.01 mm is 50 units —
#: loose enough that a drive which reports its final position one tick late does not
#: stall the sequence, tight enough that a missed target is caught.
SETTLE_MM = 0.01
SETTLE_DEG = 0.01
#: Per-step patience. A step that is neither arriving nor moving this long is
#: treated as stuck: reported and abandoned, with no further axes commanded.
STEP_TIMEOUT_S = 20.0
#: How long a step may report itself idle while still short of its target before it
#: counts as failed — a drive that has stopped without arriving will never arrive.
STALL_GRACE_S = 2.0
#: The order a role move is issued in, for the reason in the module docstring.
MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
#: The order "return to the initial state" retracts in: the press axis first, so the
#: tool is clear of the sensor before anything rotates, then the stages, then the
#: table. Returning in one coordinated swoop would move five axes through poses
#: nobody has simulated.
RETURN_ORDER: tuple[str, ...] = ("z", "pitch", "yaw", "x", "y")
@dataclass(frozen=True)
class MoveStep:
"""One axis, one target, in that role's own unit."""
role: str
axis: int
target: float
units: float
unit: str
def label(self) -> str:
return f"{self.role} {self.target:+.4f} {self.unit}"
@dataclass(frozen=True)
class MovePlan:
"""A sequence of steps, and what it is for."""
steps: tuple[MoveStep, ...]
title: str
#: Roles deliberately left alone. Reported, because "z did not move" is
#: information the operator needs, not an omission.
held: tuple[str, ...] = ()
def describe(self) -> str:
parts = [step.label() for step in self.steps]
text = " · ".join(parts) if parts else "-"
if self.held:
text += f" · {', '.join(self.held)} 不动作"
return text
@dataclass
class MoveState:
"""What the mover is doing, for the panel to show."""
plan: MovePlan | None = None
index: int = 0
started_at: float = 0.0
finished: bool = False
failure: str = ""
cancelled: bool = False
_step_started_at: float = field(default=0.0)
_idle_since: float | None = None
@property
def step(self) -> MoveStep | None:
if self.plan is None or not 0 <= self.index < len(self.plan.steps):
return None
return self.plan.steps[self.index]
@property
def total(self) -> int:
return len(self.plan.steps) if self.plan else 0
def plan_pose_move(
service: MotionService,
targets: Mapping[str, float],
*,
title: str = "",
hold: Sequence[str] = (),
order: Sequence[str] = MOVE_ORDER,
) -> MovePlan:
"""Turn per-role targets into a stepped plan, in ``order``.
``targets`` is in each role's own unit. Roles absent from it are not commanded —
that is how "z does not move" is expressed: not by a target equal to its current
position, which would still command it.
"""
steps: list[MoveStep] = []
for role in order:
if role not in targets:
continue
axis = service.role_axis(role)
if axis is None:
continue
value = float(targets[role])
steps.append(
MoveStep(
role=role,
axis=axis,
target=value,
units=service.to_units(role, value),
unit="mm" if role in LINEAR_ROLES else "°",
)
)
return MovePlan(steps=tuple(steps), title=title, held=tuple(hold))
def plan_return_to_initial(service: MotionService) -> MovePlan:
"""Every axis to zero, press axis first, in :data:`RETURN_ORDER`."""
return plan_pose_move(
service,
{role: 0.0 for role in RETURN_ORDER},
order=RETURN_ORDER,
)
class PoseMover(QObject):
"""Issues a :class:`MovePlan` one step at a time, off the poller's snapshots."""
started = Signal(object)
stepStarted = Signal(object)
finished = Signal(object)
failed = Signal(str)
cancelled = Signal()
def __init__(self, service: MotionService, submit, parent: QObject | None = None) -> None:
"""
Args:
service: the motion service, for unit conversion and per-axis guards.
submit: the poller's ``submit``, taking a ``service -> result`` callable.
"""
super().__init__(parent)
self._service = service
self._submit = submit
self.state = MoveState()
# ------------------------------------------------------------------ control
@property
def running(self) -> bool:
plan = self.state.plan
return bool(
plan is not None
and not self.state.finished
and not self.state.failure
and not self.state.cancelled
)
def start(self, plan: MovePlan, *, now: float | None = None) -> MotionResult:
"""Validate and begin. Refusal is returned, not raised."""
if self.running:
return MotionResult.refused("已有运动在执行 / a move is already running")
refusal = self._service.check_axes_move(
{step.role: step.target for step in plan.steps}
)
if refusal is not None:
return refusal
if not plan.steps:
return MotionResult.refused("没有要移动的轴 / nothing to move")
stamp = time.monotonic() if now is None else now
self.state = MoveState(plan=plan, started_at=stamp, _step_started_at=stamp)
self.started.emit(plan)
self._issue_current()
return MotionResult.success(plan.describe())
def cancel(self, reason: str = "") -> None:
"""Abandon the queue. The axis already moving is stopped by the caller.
Deliberately does not stop anything itself: stopping is a controller command
with its own guard, and the window issues it as one. What this guarantees is
that no *further* axis is commanded after the stop.
"""
if self.state.plan is None or self.state.finished:
return
self.state.cancelled = True
self.state.failure = reason
self.state.plan = None
self.cancelled.emit()
def _issue_current(self) -> None:
step = self.state.step
if step is None:
self._complete()
return
self.state._step_started_at = time.monotonic()
self.state._idle_since = None
if not step.units == step.units: # NaN: no calibration for this role
self._fail(f"{step.role} 无法换算成用户单位 / cannot convert {step.role}")
return
self._submit(lambda service, step=step: service.move_abs(step.axis, step.units))
self.stepStarted.emit(step)
def _complete(self) -> None:
self.state.finished = True
plan = self.state.plan
self.state.plan = None
self.finished.emit(plan)
def _fail(self, message: str) -> None:
self.state.failure = message
self.state.plan = None
self.failed.emit(message)
# ---------------------------------------------------------------- progress
def on_snapshot(self, snapshot: Snapshot) -> None:
"""Advance the plan from a poll. Called on every refresh."""
if not self.running:
return
step = self.state.step
if step is None:
self._complete()
return
now = time.monotonic()
if now - self.state._step_started_at > STEP_TIMEOUT_S:
self._fail(
f"{step.role} 等待超时 / timed out waiting for {step.role}"
)
return
state = snapshot.axis(step.axis)
if state is None:
return
if state.error or not state.read_ok:
self._fail(f"{step.role}: {state.error or 'read failed'}")
return
if state.status.alarm:
self._fail(f"{step.role} 报警 / alarm on {step.role}")
return
# A linear role reads in millimetres, a rotary one in degrees; the service
# converts lengths, the units module converts calibrated angles.
if step.unit == "mm":
travelled = self._service.to_mm(step.axis, state.dpos)
else:
travelled = user_units_to_deg(step.axis, state.dpos)
tolerance = SETTLE_MM if step.unit == "mm" else SETTLE_DEG
if not state.idle:
self.state._idle_since = None
return
if abs(travelled - step.target) <= tolerance:
self.state.index += 1
self._issue_current()
return
# Idle, but not there. A drive that has stopped short will not resume, so
# this is a failure rather than something to keep waiting for.
if self.state._idle_since is None:
self.state._idle_since = now
elif now - self.state._idle_since > STALL_GRACE_S:
self._fail(
f"{step.role} 停在 {travelled:.4f}{step.unit},目标 "
f"{step.target:.4f}{step.unit} / stopped short of target"
)
def progress_text(self) -> str:
"""A one-line status for the panel."""
if self.state.failure:
return f"✗ {self.state.failure}"
if self.state.finished:
return "✓ 已完成 / done"
step = self.state.step
if step is None:
return ""
return (
f"{self.state.index + 1}/{self.state.total} "
f"{step.role} → {step.target:+.4f} {step.unit}"
)
@@ -5,18 +5,19 @@ without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives
it from a worker thread; the UI only ever calls these methods.
Guards live here rather than in the UI on purpose. A motion command is refused
for a stated reason — bus not initialised, axis not idle, non-numeric input —
and :meth:`MotionService.jog` and friends return that reason as a
``MotionResult`` instead of silently doing nothing.
for a stated reason — bus not initialised, axis not idle, non-numeric input, a
target past the P0 travel limit, an axis mapping that does not add up — and
:meth:`MotionService.jog` and friends return that reason as a ``MotionResult``
instead of silently doing nothing.
"""
from __future__ import annotations
import logging
import math
import time
import os
from typing import Callable, Iterable
import time
from collections.abc import Callable, Iterable, Mapping
from orisys_app_shared.zmotion import ZAuxError
@@ -34,13 +35,14 @@ from .models import (
MotionResult,
Snapshot,
)
from .transport import DemoTransport, ZmcTransport, NativeTransport
from .transport import NativeTransport, ZmcTransport
from .units import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
DEFAULT_UNITS,
PULSES_PER_MM,
deg_to_user_units,
mm_to_user_units,
safe_float,
units_per_degree,
user_units_to_mm,
)
@@ -49,6 +51,32 @@ logger = logging.getLogger(__name__)
#: The platform has five axes; the bus may report fewer if a drive is missing.
PLATFORM_AXES = 5
#: The platform directions a drive can be, in the order the model's frames are
#: named. A drive's *number* says nothing about what it moves: the bus reports five
#: identical CiA-402 axes, and whether drive 2 tilts the sensor or lifts the table is
#: a property of the bench's wiring. This is the mapping the whole "move the platform
#: to the simulated pose" path rests on, so it is configuration with a page in the
#: device setup rather than a constant.
AXIS_ROLES: tuple[str, ...] = ("x", "y", "z", "yaw", "pitch")
#: Roles whose target is a length, and roles whose target is an angle. A degree
#: target checked against a millimetre limit is not a limit at all.
LINEAR_ROLES: tuple[str, ...] = ("x", "y", "z")
ROTARY_ROLES: tuple[str, ...] = ("yaw", "pitch")
#: Default mapping, from the bench: drives 0-4 are x, y, pitch, yaw, z.
#:
#: Deliberately *not* the same as the model's own order. It is written the way the
#: machine is wired, so reading it against the terminal strip is a check rather than
#: a translation.
DEFAULT_AXIS_ROLES: dict[str, int] = {
"x": 0,
"y": 1,
"pitch": 2,
"yaw": 3,
"z": 4,
}
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
@@ -78,6 +106,27 @@ def clamp_bas_task(value: object, fallback: int = DEFAULT_BAS_TASK) -> int:
return task if low <= task <= high else fallback
def normalize_axis_roles(roles: Mapping[str, int] | None) -> dict[str, int]:
"""A clean role -> drive mapping, falling back to the bench default.
Unusable entries are dropped rather than coerced: a role mapped to a drive the
file did not contain is a configuration the app should report through
:meth:`MotionService.roles_problem`, not silently repair.
"""
if roles is None:
return dict(DEFAULT_AXIS_ROLES)
clean: dict[str, int] = {}
for role in AXIS_ROLES:
value = roles.get(role)
if value is None:
continue
try:
clean[role] = int(value)
except (TypeError, ValueError):
continue
return clean
def _online_failure(reply: str) -> str:
"""The controller's own refusal text from an online command, or ``""``.
@@ -107,6 +156,8 @@ class MotionService:
pulses_per_mm: float = PULSES_PER_MM,
language: str = "zh_CN",
bas_task: int = DEFAULT_BAS_TASK,
axis_roles: Mapping[str, int] | None = None,
limits: Mapping[str, float] | None = None,
) -> None:
self._transport: ZmcTransport = transport or NativeTransport()
self.axis_count = int(axis_count)
@@ -114,6 +165,14 @@ class MotionService:
self._pulses_per_mm = pulses_per_mm
self.language = language
self.bas_task = clamp_bas_task(bas_task)
# Which drive is which platform direction. The app cannot infer this from a
# CiA-402 axis: the bus reports numbers. It is configuration, so it arrives
# from the preference file and can be corrected in the device setup.
self._axis_roles: dict[str, int] = normalize_axis_roles(axis_roles)
# The mm limits from workflow P0. They live here rather than in the UI because
# this is where motion is allowed or refused, and a limit the UI enforces is a
# limit a later caller can walk around.
self._limits: dict[str, float] = dict(limits or {})
self._spec: ConnectionSpec | None = None
self._bus = BusStatus()
@@ -151,6 +210,152 @@ class MotionService:
def params(self, axis: int) -> AxisParams:
return self._params.get(axis, AxisParams())
# --------------------------------------------------------------- axis roles
@property
def axis_roles(self) -> dict[str, int]:
"""Drive index per platform direction, as configured."""
return dict(self._axis_roles)
def set_axis_roles(self, roles: Mapping[str, int] | None) -> None:
"""Replace the mapping. Called when the device setup is edited."""
self._axis_roles = normalize_axis_roles(roles)
def set_limits(self, limits: Mapping[str, float] | None) -> None:
"""Replace the P0 limits. Called when the preference file is reloaded."""
self._limits = dict(limits or {})
def role_axis(self, role: str) -> int | None:
"""The drive that moves ``role``, or ``None`` if it is not configured."""
return self._axis_roles.get(role)
def roles_problem(self) -> str:
"""Why the mapping cannot be used, or ``""`` when it is sound.
A duplicated drive is the dangerous case — two roles claiming axis 3 means
one of them silently does not move, and the pose would be wrong rather than
refused — so it is reported rather than tolerated.
"""
missing = [role for role in AXIS_ROLES if role not in self._axis_roles]
if missing:
return self._text(
f"轴映射未配置:{', '.join(missing)}",
f"Axis mapping is not configured for: {', '.join(missing)}",
)
used: dict[int, str] = {}
for role in AXIS_ROLES:
axis = self._axis_roles[role]
if not self._known_axis(axis):
return self._text(
f"{role} 指向轴 {axis},超出 0-{self.axis_count - 1}",
f"{role} points at axis {axis}, outside 0-{self.axis_count - 1}",
)
if axis in used:
return self._text(
f"轴 {axis} 同时是 {used[axis]} 和 {role}",
f"Axis {axis} is claimed by both {used[axis]} and {role}",
)
used[axis] = role
return ""
@property
def travel_limit_mm(self) -> float:
"""The P0 travel limit. ``0`` means "not set", never "unlimited"."""
return safe_float(self._limits.get("max_travel_mm"), 0.0)
def limit_for(self, role: str) -> float:
"""The P0 limit for a role, in that role's own unit.
Linear roles are limited in millimetres and rotary roles in degrees: one
number cannot bound both, and treating a degree target as millimetres is how
a 180° move passes a 100 mm check.
"""
key = "max_travel_mm" if role in LINEAR_ROLES else f"max_{role}_deg"
return safe_float(self._limits.get(key), 0.0)
def units_problem(self, role: str, axis: int) -> str:
"""Why a role cannot be commanded in its own unit, or ``""``."""
if role in LINEAR_ROLES:
return ""
scale = units_per_degree(axis)
if not math.isfinite(scale) or scale <= 0:
return self._text(
f"{role} 用轴 {axis},但该轴没有角度标定(用户单位/度)",
f"{role} uses axis {axis}, which has no user-units-per-degree "
f"calibration",
)
return ""
def check_axes_move(self, targets: Mapping[str, float]) -> MotionResult | None:
"""Refuse a coordinated pose move, or return ``None`` to allow it.
``targets`` is per role, in the role's own unit: millimetres for x / y / z,
degrees for yaw / pitch.
Every reason a move can be wrong is checked *before* the first axis is
commanded. A pose move is several coordinated moves, and discovering at axis
three that axis four is out of range would leave the machine stopped
somewhere that is neither the start nor the target.
"""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._bus.initialized:
return MotionResult.refused(
self._text(
"总线未初始化完成,禁止运动",
"Bus is not initialised; motion is refused",
)
)
problem = self.roles_problem()
if problem:
return MotionResult.refused(problem)
if not targets:
return MotionResult.refused(self._text("没有要移动的轴", "Nothing to move"))
for role, value in targets.items():
if role not in AXIS_ROLES:
return MotionResult.refused(
self._text(f"未知方向 {role}", f"Unknown direction {role}")
)
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
return MotionResult.refused(
self._text(f"{role} 目标不是有效数字", f"{role} target is not a number")
)
axis = self._axis_roles[role]
units_problem = self.units_problem(role, axis)
if units_problem:
return MotionResult.refused(units_problem)
for role, value in targets.items():
limit = self.limit_for(role)
unit = "mm" if role in LINEAR_ROLES else "°"
if limit <= 0:
return MotionResult.refused(
self._text(
f"{role} 的上限未设置(P0),禁止运动",
f"The {role} limit is not set (P0); motion is refused",
)
)
if abs(float(value)) > limit:
return MotionResult.refused(
self._text(
f"{role} 目标 {float(value):.3f}{unit} 超出上限 {limit:.3f}{unit}",
f"{role} target {float(value):.3f}{unit} is past the "
f"{limit:.3f}{unit} limit",
)
)
return None
def to_units(self, role: str, value: float) -> float:
"""Controller user units for a role target, in that role's own unit."""
axis = self._axis_roles.get(role)
if axis is None:
return math.nan
if role in LINEAR_ROLES:
units = self.params(axis).units or self._units_per_mm
return mm_to_user_units(
value, units=units, pulses_per_mm=self._pulses_per_mm
)
return deg_to_user_units(axis, value)
def set_language(self, language: str) -> None:
self.language = language
+83 -5
View File
@@ -13,7 +13,12 @@ from typing import Any
from orisys_app_shared import prefs as _prefs
from orisys_app_shared.paths import preference_path
from .motion.service import DEFAULT_BAS_TASK, PLATFORM_AXES, clamp_bas_task
from .motion.service import (
DEFAULT_AXIS_ROLES,
DEFAULT_BAS_TASK,
PLATFORM_AXES,
clamp_bas_task,
)
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
#: Connection modes, in the order the combo offers them.
@@ -81,6 +86,10 @@ def _default_safety_limits() -> dict[str, float]:
``0`` means "not set" for the ones where a real value is required, and the
app reports that at the P0 gate rather than treating it as unlimited.
Lengths are millimetres and angles degrees, because the roles they bound are:
one number cannot bound both, and a degree target checked against a millimetre
limit is not a check.
"""
return {
"max_travel_mm": 100.0,
@@ -90,6 +99,11 @@ def _default_safety_limits() -> dict[str, float]:
#: How far the axis may descend looking for contact before the run is
#: aborted (workflow P7, no-contact abort).
"contact_search_mm": 3.0,
#: Stage envelope, from the confirmed bench values: the yaw stage turns a
#: half-turn either way, the pitch stage a full turn. These are the limits a
#: pose move is checked against before any axis is commanded.
"max_yaw_deg": 180.0,
"max_pitch_deg": 360.0,
}
@@ -104,9 +118,10 @@ def _default_sensor_setup() -> dict[str, Any]:
"sensor_type": "box",
"config_ref": "",
"geometry_id": "",
#: Which axis presses, and the probe tip offset from the platform's
#: reference point. Empty offset means "not set", which blocks P5.
"press_axis": 2,
#: The probe tip's offset from the platform's reference point. Empty offset
#: means "not set", which blocks P5. Which axis presses is **not** here: it
#: is ``axis_roles["z"]``, because one fact written twice is one fact that
#: eventually disagrees.
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
"probe_tip_set": False,
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
@@ -173,6 +188,19 @@ def default_preferences() -> dict[str, Any]:
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
#: P0: limits enforced from P2 onward.
"safety_limits": _default_safety_limits(),
#: Which drive is which platform direction, from the bench wiring: drives
#: 0-4 are x, y, pitch, yaw, z. There is no way to read this off the bus —
#: five CiA-402 axes report numbers, not names — so it is configuration, and
#: the pose-move path refuses to run until it is complete and free of
#: duplicates. It is also the *only* place "which axis presses" is written:
#: the sensor setup used to carry a second copy, which is how the two would
#: have drifted apart.
"axis_roles": dict(DEFAULT_AXIS_ROLES),
#: Ask before a pose move. On by default — the first moves on a machine are
#: the ones worth reading — and turnable off once the numbers are trusted,
#: because a prompt that cannot be dismissed unread is one that gets clicked
#: through without being read.
"move_confirm": True,
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
"sensor_setup": _default_sensor_setup(),
#: P5: grid generation and run parameters.
@@ -206,9 +234,26 @@ def default_preferences() -> dict[str, Any]:
}
#: Preference blocks whose *keys* are added to over time. Stored preferences are
#: merged one level deep, so a block saved by an older build replaces the whole
#: default block and any key added since reads as absent. For a P0 limit that is not
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
#: in front of the operator says nothing about it. Filling the block key by key keeps
#: what was chosen and supplies only what was never there.
_MERGED_BLOCKS = ("connection", "safety_limits", "sensor_setup", "sequence_profile")
def load_preferences() -> dict[str, Any]:
"""Stored preferences with AutoCal's defaults filling the gaps."""
prefs = _prefs.load_preferences(default_preferences())
defaults = default_preferences()
prefs = _prefs.load_preferences(defaults)
for name in _MERGED_BLOCKS:
stored = prefs.get(name)
if not isinstance(stored, dict):
continue
block = dict(defaults.get(name) or {})
block.update(stored)
prefs[name] = block
# A stored document from an older build may miss a newly added axis block.
axis_params = prefs.setdefault("axis_params", {})
for axis, values in _default_axis_params().items():
@@ -252,6 +297,39 @@ def load_preferences() -> dict[str, Any]:
prefs["finger_tool_xy_mm"] = tool
# A hand-edited task number must not reach the controller as typed.
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
prefs = _migrate_axis_roles(prefs)
return prefs
def _migrate_axis_roles(prefs: dict[str, Any]) -> dict[str, Any]:
"""Complete the axis mapping, and take the old duplicate press axis out.
``sensor_setup.press_axis`` named the pressing drive on its own. That is
``axis_roles["z"]`` now — the same fact — and a stored value is moved there when
the mapping has not been set, so a bench that had it configured keeps working.
The old key is removed either way: leaving it would keep a second answer to
"which axis presses" in the file.
"""
roles = prefs.get("axis_roles")
roles = dict(roles) if isinstance(roles, dict) else {}
sensor = prefs.get("sensor_setup")
if isinstance(sensor, dict):
legacy = sensor.pop("press_axis", None)
if legacy is not None and "z" not in roles:
try:
roles["z"] = int(legacy)
except (TypeError, ValueError):
pass
merged = dict(DEFAULT_AXIS_ROLES)
# A stored mapping wins where it is usable; the default fills what it does not
# cover, so an old document that only named the press axis still comes up with a
# complete mapping rather than a refusal.
for role, value in roles.items():
try:
merged[role] = int(value)
except (TypeError, ValueError):
continue
prefs["axis_roles"] = merged
return prefs
@@ -1,7 +1,8 @@
"""Sensor assets: export bundles, the flat UV view, and the mount frames."""
from .export_bundle import BundleError, SensorBundle, load_bundle
from .frames import AXIS_MAP, describe as describe_frames, mount_rotation, to_platform, to_sensor
from .frames import AXIS_MAP, mount_rotation, to_platform, to_sensor
from .frames import describe as describe_frames
from .uv_flat import (
DEFAULT_FLAT_SIZE,
FlatSurface,
@@ -12,8 +13,8 @@ from .uv_flat import (
__all__ = [
"AXIS_MAP",
"BundleError",
"DEFAULT_FLAT_SIZE",
"BundleError",
"FlatSurface",
"SensorBundle",
"describe_frames",
@@ -26,10 +26,11 @@ from __future__ import annotations
import json
import math
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
from typing import Any
#: The version this build writes, and the highest it will read.
FORMAT_VERSION = "1.0"
@@ -14,6 +14,9 @@ five-axis platform, acting on the wrong axis is the expensive mistake.
from __future__ import annotations
import math
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
@@ -29,8 +32,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from ..motion import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
@@ -582,7 +583,7 @@ def _millimetres(units: float, user_units: float) -> str:
if not units:
return "—"
millimetres = user_units_to_mm(user_units, units=units)
if millimetres != millimetres: # NaN
if math.isnan(millimetres):
return "—"
return f"{millimetres:.3f}"
@@ -1,17 +1,23 @@
"""The permanent axis readout rail.
Read-only by design. Every per-axis *command* lives in the Axis-debug tab, so
there is exactly one place a move can be issued from — two places to jog from is
how an operator moves the wrong axis. What this rail adds is the thing the
operator needs while working in any other tab: whether each drive is enabled,
where it is, and whether it is moving.
Read-only apart from four platform-wide actions. Every per-axis *command* lives in
the Axis-debug tab, so there is exactly one place a single-axis move can be issued
from — two places to jog from is how an operator moves the wrong axis. What this rail
adds is the thing the operator needs while working in any other tab: whether each
drive is enabled, where it is, and whether it is moving.
The three global actions are here rather than in the tab because they are the
ones you reach for in a hurry, and they must not require finding a tab first.
The four global actions are here rather than in a tab because they are the ones you
reach for in a hurry, or reach for *after* finishing in some other tab: enable,
stop, clear alarms, and return every axis to the initial state. They must not
require finding the right tab first.
"""
from __future__ import annotations
import math
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import load_colors
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QGridLayout,
@@ -23,9 +29,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import load_colors
from ..motion import Snapshot, user_units_to_mm
#: Columns of the compact readout: axis, enable state, position.
@@ -41,6 +44,10 @@ class AxisReadoutPanel(QGroupBox):
enableAllRequested = Signal(bool)
stopAllRequested = Signal()
clearAllRequested = Signal()
#: Return every drive to the initial state. Here, not in the finger screen,
#: because it is a machine-level action: it is wanted after a move made in *any*
#: tab, and the rail is on screen in all of them.
returnRequested = Signal()
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -72,7 +79,14 @@ class AxisReadoutPanel(QGroupBox):
self.stop_all_button.clicked.connect(self.stopAllRequested)
self.clear_all_button = QPushButton(self)
self.clear_all_button.clicked.connect(self.clearAllRequested)
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
self.return_button = QPushButton(self)
self.return_button.clicked.connect(self.returnRequested)
for button in (
self.enable_all_button,
self.stop_all_button,
self.return_button,
self.clear_all_button,
):
button.setProperty("rail", True)
outer.addWidget(button)
@@ -110,7 +124,12 @@ class AxisReadoutPanel(QGroupBox):
def set_connected(self, connected: bool) -> None:
self._connected = bool(connected)
for button in (self.enable_all_button, self.stop_all_button, self.clear_all_button):
for button in (
self.enable_all_button,
self.stop_all_button,
self.clear_all_button,
self.return_button,
):
button.setEnabled(self._connected)
def update_snapshot(self, snapshot: Snapshot) -> None:
@@ -138,7 +157,7 @@ class AxisReadoutPanel(QGroupBox):
millimetres = user_units_to_mm(state.dpos, units=state.units) if state.units else None
units_text = f"{state.dpos:.3f}"
if millimetres is not None and millimetres == millimetres:
if millimetres is not None and not math.isnan(millimetres):
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
widgets["position"].setText(units_text)
widgets["position"].setToolTip(
@@ -166,5 +185,7 @@ class AxisReadoutPanel(QGroupBox):
self.setTitle(t("readout.title"))
self.stop_all_button.setText(t("axes.stop_all"))
self.clear_all_button.setText(t("axes.clear_alarm_all"))
self.return_button.setText(t("move.return_initial"))
self.return_button.setToolTip(t("move.return_hint"))
# The toggle's label depends on current state, so re-derive it.
self.update_snapshot(self._snapshot)
@@ -7,13 +7,12 @@ controller is reachable.
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_app_shared.theme import load_colors
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
#: Chip tone -> colour token name.
_TONE_TOKENS = {
@@ -27,6 +26,11 @@ _TONE_TOKENS = {
class BrandBar(QFrame):
"""The identity strip, with a state chip that reports the connection."""
#: The connection chip was clicked. It is the most prominent statement of the
#: connection state, so it is also the obvious way to go and change it — the
#: controls themselves live in the device-setup dialog.
connectionChipClicked = Signal()
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("brandBar")
@@ -67,10 +71,21 @@ class BrandBar(QFrame):
self.state_chip = QLabel(self)
self.state_chip.setObjectName("stateChip")
self.state_chip.setCursor(Qt.CursorShape.PointingHandCursor)
layout.addWidget(self.state_chip)
self.retranslate()
def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt naming
"""A click anywhere on the chip opens the device setup.
On the bar rather than on the label: a QLabel only receives a mouse event
where its own text is, so clicking the chip's padding would do nothing.
"""
if event.button() == Qt.MouseButton.LeftButton and self.state_chip.underMouse():
self.connectionChipClicked.emit()
super().mouseReleaseEvent(event)
# ----------------------------------------------------------------- content
def retranslate(self) -> None:
@@ -78,6 +93,7 @@ class BrandBar(QFrame):
self.name.setText(t("brand.name"))
self.detail.setText(t("brand.detail"))
self.product.setText(t("app.title"))
self.state_chip.setToolTip(t("setup.chip_hint", name=t("connection.title")))
self.set_connected(self._connected)
def set_theme(self, theme: str) -> None:
@@ -9,12 +9,12 @@ vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
from __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QComboBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
@@ -22,8 +22,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
#: Height of this panel's fields and buttons. Pinned for the same reason as the
#: axis panel's: an unconstrained QLineEdit is ~34 px, and four of them stacked
#: push this panel tall enough to steal an axis row from the panel above it.
@@ -11,6 +11,8 @@ from __future__ import annotations
from pathlib import Path
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import app_root
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QGridLayout,
@@ -23,9 +25,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import app_root
from ..motion import INIT_FAILED, INIT_OK, INIT_RUNNING, BusStatus
_STATE_KEYS = {
@@ -2,6 +2,7 @@
from __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QComboBox,
@@ -15,8 +16,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from ..motion import ConnectionSpec
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
@@ -0,0 +1,285 @@
"""The device setup: connection, bus initialisation and the axis mapping.
These are the facts about the machine itself, as opposed to the job: reach the
controller, download the bus program, watch the bus come up, and say which drive moves
which platform direction. All of them are touched once per machine start-up — or once
per re-wire — and then not again for the rest of the session. In the left rail the
first two were spending ~460 px of its height, above the axis readout that is read all
day, which is what made the rail scroll.
They live in a **modeless dialog** rather than a modal one on purpose: connecting and
downloading are asynchronous (the poller runs the state machine, the status strip
reports it), and an operator watching a bus scan go wrong wants to see the strip and
the axis readout while it happens. Modeless also means the dialog can be opened from
several places — the strip's button, its state chips, the File menu — without any of
them having to block.
The dialog owns the connection and bus panels; the window keeps a reference to each
(``window.connection_panel`` / ``window.bus_panel``) because it is the window that
wires them to the controller, and because the tools and checks reach for them there.
The axis mapping is read back by the window on every change, because the motion guards
have to see the same numbers the page shows.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QLabel,
QTabWidget,
QVBoxLayout,
QWidget,
)
from ..motion import ConnectionSpec
from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES
from .bus_panel import BusPanel
from .connection_panel import ConnectionPanel
#: Tab identity -> locale key, in the order they are shown. The order is the work:
#: reach the machine, bring its bus up, then say what each drive moves.
TABS: tuple[tuple[str, str], ...] = (
("connection", "setup.tab_connection"),
("bus", "setup.tab_bus"),
("axes", "setup.tab_axes"),
)
#: Role -> locale key for the mapping rows.
_ROLE_KEYS = {
"x": "setup.role_x",
"y": "setup.role_y",
"z": "setup.role_z",
"yaw": "setup.role_yaw",
"pitch": "setup.role_pitch",
}
class AxisRolesPage(QWidget):
"""Which drive moves which platform direction.
A page of its own because it is a separate fact: connection and bus are about
*reaching* the machine, this is about what its drives do. Getting it wrong moves
the wrong motor, so a duplicate is reported rather than quietly accepted — and the
motion guards read the same mapping, so a page left in a contradictory state
refuses motion instead of guessing which claim was meant.
"""
rolesChanged = Signal()
def __init__(
self, roles: dict[str, int] | None = None, parent: QWidget | None = None
) -> None:
super().__init__(parent)
column = QVBoxLayout(self)
column.setContentsMargins(8, 8, 8, 8)
column.setSpacing(8)
self.hint_label = QLabel(self)
self.hint_label.setObjectName("hintLabel")
self.hint_label.setWordWrap(True)
column.addWidget(self.hint_label)
self.box = QGroupBox(self)
self.box.setObjectName("groupBox")
form = QFormLayout(self.box)
form.setContentsMargins(10, 8, 10, 10)
form.setSpacing(6)
self.combos: dict[str, QComboBox] = {}
self.labels: dict[str, QLabel] = {}
for role in AXIS_ROLES:
label = QLabel(self.box)
combo = QComboBox(self.box)
for axis in range(PLATFORM_AXES):
combo.addItem(str(axis), axis)
combo.currentIndexChanged.connect(self._on_changed)
self.labels[role] = label
self.combos[role] = combo
form.addRow(label, combo)
column.addWidget(self.box)
self.problem_label = QLabel(self)
self.problem_label.setObjectName("statusValue")
self.problem_label.setWordWrap(True)
column.addWidget(self.problem_label)
column.addStretch(1)
self.set_roles(roles or {})
self.retranslate()
# ------------------------------------------------------------------- state
def set_roles(self, roles: dict[str, int]) -> None:
"""Show a mapping without announcing a change the operator did not make.
An empty mapping falls back to the bench default rather than to "every role
on drive 0", which would open the page in a contradictory state and — because
the guards read the same mapping — refuse motion for a reason nobody typed.
"""
source = dict(roles) or dict(DEFAULT_AXIS_ROLES)
for role, combo in self.combos.items():
value = source.get(role)
index = combo.findData(int(value)) if value is not None else -1
combo.blockSignals(True)
try:
combo.setCurrentIndex(index if index >= 0 else 0)
finally:
combo.blockSignals(False)
self._refresh_problem(announce=False)
def roles(self) -> dict[str, int]:
return {role: int(combo.currentData()) for role, combo in self.combos.items()}
def problem(self) -> str:
"""The duplicate mapping, described, or ``""`` when it is sound."""
seen: dict[int, str] = {}
for role, combo in self.combos.items():
axis = int(combo.currentData())
if axis in seen:
return t("setup.axes_duplicate", detail=f"{seen[axis]} ↔ {role}")
seen[axis] = role
return ""
def _on_changed(self, _index: int) -> None:
self._refresh_problem()
def _refresh_problem(self, *, announce: bool = True) -> None:
problem = self.problem()
self.problem_label.setText(f"⚠ {problem}" if problem else "")
if announce:
self.rolesChanged.emit()
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.hint_label.setText(t("setup.axes_hint"))
self.box.setTitle(t("setup.tab_axes"))
for role, label in self.labels.items():
label.setText(t(_ROLE_KEYS[role]))
self._refresh_problem(announce=False)
class DeviceSetupDialog(QDialog):
"""Connection and bus initialisation, the once-per-start controls."""
def __init__(
self,
spec: ConnectionSpec,
bas_path: str = "",
roles: dict[str, int] | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("deviceSetup")
# Modeless: see the module docstring. ``show()`` raises it when already open.
self.setModal(False)
self.setSizeGripEnabled(True)
column = QVBoxLayout(self)
column.setContentsMargins(10, 10, 10, 10)
column.setSpacing(8)
self.hint_label = QLabel(self)
self.hint_label.setObjectName("hintLabel")
self.hint_label.setWordWrap(True)
column.addWidget(self.hint_label)
self.tabs = QTabWidget(self)
self.tabs.setObjectName("setupTabs")
self.connection_panel = ConnectionPanel(spec, self.tabs)
self.bus_panel = BusPanel(bas_path, self.tabs)
self.roles_page = AxisRolesPage(roles, self.tabs)
for key, locale_key in TABS:
self.tabs.addTab(self.panel(key), "")
column.addWidget(self.tabs, 1)
self.buttons = QDialogButtonBox(self)
self.close_button = self.buttons.addButton(
"", QDialogButtonBox.ButtonRole.RejectRole
)
self.close_button.clicked.connect(self.close)
column.addWidget(self.buttons)
self.retranslate()
# Sized to the taller tab (bus initialisation: the file row, four readouts and
# two buttons), so the dialog opens without a band of empty panel under the
# controls. It is resizable, and the size is remembered.
self.resize(520, 470)
# ----------------------------------------------------------------- content
def panel(self, key: str) -> QWidget:
"""One of the three pages, by tab key."""
if key == "connection":
return self.connection_panel
return self.bus_panel if key == "bus" else self.roles_page
def open_for(self, tab: str | None = None) -> None:
"""Show the dialog, raising it if it is already up, and pick a tab.
Raising rather than ignoring the second request: the button and the chips all
lead here, and a click that appears to do nothing reads as a broken button.
"""
key = tab if tab in {name for name, _ in TABS} else None
if key is not None:
self.select_tab(key)
self.show()
self.raise_()
self.activateWindow()
panel = self.panel(key) if key is not None else None
if panel is not None:
panel.setFocus()
def select_tab(self, key: str) -> None:
for index, (name, _locale_key) in enumerate(TABS):
if name == key:
self.tabs.setCurrentIndex(index)
return
def current_tab(self) -> str:
index = max(0, self.tabs.currentIndex())
return TABS[index][0] if index < len(TABS) else TABS[0][0]
# ------------------------------------------------------------- persistence
def settings(self) -> dict:
"""What is worth remembering between sessions: size and last tab.
Not the connection state: that belongs to the controller, and a dialog that
claimed to remember being connected would be lying after a restart.
"""
size = self.size()
return {"size": [int(size.width()), int(size.height())], "tab": self.current_tab()}
def apply_settings(self, stored: object) -> None:
if not isinstance(stored, dict):
return
size = stored.get("size")
if isinstance(size, list) and len(size) == 2:
try:
width, height = (int(value) for value in size)
except (TypeError, ValueError):
return
if width > 0 and height > 0:
self.resize(width, height)
tab = stored.get("tab")
if isinstance(tab, str):
self.select_tab(tab)
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setWindowTitle(t("setup.title"))
self.hint_label.setText(t("setup.hint"))
for index, (_key, locale_key) in enumerate(TABS):
self.tabs.setTabText(index, t(locale_key))
self.close_button.setText(t("setup.close"))
self.connection_panel.retranslate()
self.bus_panel.retranslate()
self.roles_page.retranslate()
@@ -34,7 +34,7 @@ from orisys_app_shared.i18n import t
from ..motion.kinematics import PlatformStack, Pose, rotation_about
from ..sensor.export_bundle import SensorBundle
from ..sensor.frames import mount_rotation, to_platform, to_sensor
from ..sensor.frames import to_sensor
#: Orbit drag sensitivity and zoom step, matching the design repo's preview.
ORBIT_RAD_PER_PX = 0.008
@@ -22,6 +22,7 @@ import math
from pathlib import Path
import numpy as np
from orisys_app_shared.i18n import t
from PySide6.QtCore import QPoint, Qt, Signal
from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
@@ -34,7 +35,6 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
@@ -42,8 +42,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from ..motion.kinematics import PlatformStack
from ..sensor.export_bundle import SensorBundle
from ..sensor.frames import describe as describe_frames
@@ -226,6 +224,12 @@ class FingerPanel(QGroupBox):
loadRequested = Signal()
pickRequested = Signal(int, int)
stackChanged = Signal()
#: Run the selected point's solved pose on the platform, and stop moving.
executeRequested = Signal()
stopRequested = Signal()
#: Whether to ask before moving. Persisted; the window honours it for every
#: pose move, including the rail's return-to-zero.
confirmChanged = Signal(bool)
#: Columns of the point table, minus the leading select checkbox. The first six
#: are the ones an operator may edit — the sensor-frame point and its normal —
@@ -532,8 +536,35 @@ class FingerPanel(QGroupBox):
# colour call hid the base plane and the whole trajectory without a word.
self.preview.problemChanged.connect(lambda _message: self._refresh_reach())
controls.addWidget(self.sim_button)
# Running the selected pose on the machine, and stopping it. Both live here
# because both are about *this* point's move; the stop is a second access
# point to the same command the status strip's 全部停止 issues, not a second
# implementation — an emergency stop has to be where the eyes already are.
self.execute_button = QPushButton(box)
self.execute_button.setEnabled(False)
self.execute_button.clicked.connect(self.executeRequested)
self.stop_button = QPushButton(box)
self.stop_button.setObjectName("dangerButton")
self.stop_button.setEnabled(False)
self.stop_button.clicked.connect(self.stopRequested)
controls.addWidget(self.execute_button)
controls.addWidget(self.stop_button)
# Confirming every move is right when the machine is new to the operator and
# tedious once it is not — and a prompt that cannot be turned off is one that
# gets clicked through without reading. The choice is the operator's, and the
# same policy covers the rail's return-to-zero.
self.confirm_check = QCheckBox(box)
self.confirm_check.setChecked(True)
self.confirm_check.toggled.connect(self.confirmChanged)
controls.addWidget(self.confirm_check)
controls.addStretch(1)
column.addLayout(controls)
# What the move is doing: which axis, which step, or why it refused. A move
# that reports nothing is indistinguishable from one that never started.
self.move_label = QLabel(box)
self.move_label.setObjectName("hintLabel")
self.move_label.setWordWrap(True)
column.addWidget(self.move_label)
# A real label, not text painted onto the GL widget: over a GL surface Qt
# composites a long overflowing string as an overlap of itself, and the
# legend was unreadable on the bench because of it. A label wraps.
@@ -688,6 +719,57 @@ class FingerPanel(QGroupBox):
t("finger.sim_stop") if running else t("finger.sim_play")
)
# --------------------------------------------------------------- pose move
def set_move_available(self, available: bool, *, moving: bool = False) -> None:
"""Mirror the mover's state onto the two buttons.
The stop is deliberately *not* gated on the same conditions as the execute
button: it is enabled whenever the platform may be commanded *or* something
is moving. A stop greyed out while an axis coasts would be the worst possible
button, and the emergency case is exactly the one where the execute
conditions no longer hold.
"""
self.execute_button.setEnabled(bool(available) and not moving)
self.stop_button.setEnabled(bool(available) or bool(moving))
def set_move_status(self, text: str) -> None:
"""One line about the move: step, progress, or the refusal."""
self.move_label.setText(text)
def set_confirm(self, confirm: bool) -> None:
"""Show the stored policy without announcing it as a change."""
self.confirm_check.blockSignals(True)
try:
self.confirm_check.setChecked(bool(confirm))
finally:
self.confirm_check.blockSignals(False)
def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None:
"""The selected point's per-role targets, and the roles held back.
The two numbers that matter are the *table's* move and the stage angles: the
table translation is what the stages are resolved against, and it is what the
preview drew as the cyan path's end.
z is returned as *held*: executing a simulated pose moves the stages and the
table, it does not press. Pressing is P7 and has its own guards — contact
detection, press depth, the sensor's second opinion — none of which exist
here, so a move that reached the contact height would be a press with none of
them.
"""
pose = self._last_pose
if pose is None:
return None
table_x, table_y = pose.table_xy_mm
targets = {
"x": float(table_x),
"y": float(table_y),
"yaw": float(pose.yaw_deg),
"pitch": float(pose.pitch_deg),
}
return targets, ("z",)
def _sync_sim_button(self) -> None:
self.sim_button.setText(
t("finger.sim_stop") if self.preview.simulating else t("finger.sim_play")
@@ -1181,6 +1263,12 @@ class FingerPanel(QGroupBox):
self.trajectory_check.setText(t("finger.show_trajectory"))
self.sim_button.setToolTip(t("finger.sim_hint"))
self._sync_sim_button()
self.execute_button.setText(t("move.execute"))
self.execute_button.setToolTip(t("move.execute_hint"))
self.stop_button.setText(t("move.stop"))
self.stop_button.setToolTip(t("move.stop_hint"))
self.confirm_check.setText(t("move.confirm_check"))
self.confirm_check.setToolTip(t("move.confirm_hint"))
self.table.setHorizontalHeaderLabels(
[""] + [t(f"finger.col_{key}") for key in self._headers]
)
@@ -25,9 +25,11 @@ list so a runner cannot start from a table that was never validated.
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
@@ -44,8 +46,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from ..preferences import POINT_ORDERS, SURFACE_MODES
from ..sequence_io import (
SequencePoint,
@@ -642,7 +642,7 @@ def _as_number(text: str, fallback: float) -> float:
value = float(str(text).strip())
except (TypeError, ValueError):
return fallback
return value if value == value else fallback # reject NaN
return value if not math.isnan(value) else fallback
def _format_number(value: Any) -> str:
@@ -20,19 +20,18 @@ from __future__ import annotations
import time
from PySide6.QtCore import Signal
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import load_colors
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QProgressBar,
QPushButton,
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import load_colors
from ..motion import Snapshot
#: Tone -> colour token. Matches the brand bar's chip vocabulary.
@@ -66,7 +65,17 @@ _FAILED_INTERVALS = 10.0
class _Chip(QLabel):
"""A small toned label: the strip is read at a glance, not studied."""
"""A small toned label: the strip is read at a glance, not studied.
Clickable, because the state it shows is also the way to change it: the
connection, bus and init controls live in the device-setup dialog, so a chip that
reports "not connected" is the natural place to click. Its *text* stays clean
(no decoration) so the strip still reads as a status row; the pointing-hand
cursor and the tooltip carry the affordance, and the labelled button beside them
is the primary way in.
"""
clicked = Signal()
def __init__(self, theme: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -75,6 +84,18 @@ class _Chip(QLabel):
self._tone = "muted"
self.set_tone("muted")
def mouseReleaseEvent(self, event) -> None: # noqa: N802 - Qt naming
if event.button() == Qt.MouseButton.LeftButton and self.isEnabled():
self.clicked.emit()
super().mouseReleaseEvent(event)
def set_clickable(self, clickable: bool, name: str = "") -> None:
"""Make the chip open something, or go back to being a plain readout."""
self.setCursor(
Qt.CursorShape.PointingHandCursor if clickable else Qt.CursorShape.ArrowCursor
)
self.setToolTip(t("setup.chip_hint", name=name) if clickable else "")
def set_theme(self, theme: str) -> None:
self._theme = theme
self.set_tone(self._tone)
@@ -93,6 +114,9 @@ class StatusStrip(QFrame):
pauseRequested = Signal()
stopAllRequested = Signal()
#: Open the device setup, optionally on a named tab (``connection`` / ``bus``).
setupRequested = Signal()
chipSetupRequested = Signal(str)
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -133,6 +157,24 @@ class StatusStrip(QFrame):
self.action_label.setObjectName("stripAction")
layout.addWidget(self.action_label)
# The three state chips double as the way into the device setup: each opens
# the tab that explains it. Wired here rather than by the window so the pairing
# lives with the chips it belongs to.
for chip, tab in (
(self.chip_connection, "connection"),
(self.chip_bus, "bus"),
(self.chip_init, "bus"),
):
chip.set_clickable(True)
chip.clicked.connect(lambda tab=tab: self.chipSetupRequested.emit(tab))
# Before the run-screen buttons, because it is the one control here that is
# about the machine rather than about the job.
self.setup_button = QPushButton(self)
self.setup_button.setProperty("rail", True)
self.setup_button.clicked.connect(self.setupRequested)
layout.addWidget(self.setup_button)
self.pause_button = QPushButton(self)
self.pause_button.setProperty("rail", True)
self.pause_button.setCheckable(True)
@@ -268,8 +310,15 @@ class StatusStrip(QFrame):
def retranslate(self) -> None:
self.title.setText(t("status.motion"))
self.setup_button.setText(t("setup.open"))
self._refresh_pause_text()
self.stop_button.setText(t("axes.stop_all"))
for chip, name in (
(self.chip_connection, t("connection.title")),
(self.chip_bus, t("bus.title")),
(self.chip_init, t("bus.title")),
):
chip.set_clickable(True, name)
self.set_snapshot(self._snapshot)
self.refresh_health()
+248 -25
View File
@@ -15,12 +15,11 @@ import sys
from pathlib import Path
import numpy as np
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared import API_VERSION
from orisys_app_shared import __version__ as shared_version
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.paths import outputs_dir
from orisys_app_shared.theme import build_qss, load_colors
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QCloseEvent
from PySide6.QtWidgets import (
@@ -40,9 +39,17 @@ from PySide6.QtWidgets import (
from .. import __version__ as app_version
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
from ..motion.kinematics import PlatformStack
from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial
from ..motion.service import clamp_bas_task
from ..motion.units import DEFAULT_UNITS
from ..preferences import load_preferences, save_preferences
from ..sensor import BundleError, load_bundle, load_uv_texture, rasterise, texture_path_for
from ..sensor import (
BundleError,
load_bundle,
load_uv_texture,
rasterise,
texture_path_for,
)
from ..sensor.uv_flat import DEFAULT_FLAT_SIZE
from ..sequence_io import (
FILE_SUFFIX,
@@ -56,8 +63,8 @@ from .axis_panel import AxisPanel
from .axis_readout import AxisReadoutPanel
from .brand_bar import BrandBar
from .bus_data_panel import BusDataPanel
from .bus_panel import BusPanel, default_bas_dir
from .connection_panel import ConnectionPanel
from .bus_panel import default_bas_dir
from .device_setup import DeviceSetupDialog
from .sensor_panel import FingerPanel
from .sequence_panel import SequencePanel
from .status_strip import RunBar, StatusStrip
@@ -73,7 +80,11 @@ logger = logging.getLogger(__name__)
#: gives the workspace that 240 px back at every window size, which is the
#: difference between a workable sequence page and a cramped one at the minimum
#: window width.
MAIN_SPLITTER_SIZES = (330, 1560)
#:
#: Narrower than it was (330): the rail now holds the axis readout and the collapsed
#: bus-data group only — connection and bus initialisation moved into the device
#: setup dialog — so the workspace gets the difference.
MAIN_SPLITTER_SIZES = (300, 1590)
def _triple(value: object, length: int) -> tuple[float, ...]:
@@ -138,6 +149,10 @@ class AutoCalMainWindow(QMainWindow):
self._build_poller()
self._restore_geometry()
# Its size and last tab, from the previous session. Applied here rather than
# in ``_build_ui`` because it is stored state, like the window geometry.
self.device_setup.apply_settings(self.prefs.get("device_setup"))
self.finger_panel.set_confirm(self._confirm_moves)
self.retranslate()
# Load the remembered bundle, so the finger debugger comes up working.
@@ -174,31 +189,45 @@ class AutoCalMainWindow(QMainWindow):
layout.addWidget(body, stretch=1)
# ----------------------------------------------------------------- rail
# One rail, not two. It holds setup, the axis readout and the collapsed
# bus data, and it scrolls: at 900 px high those three groups do not fit,
# and a rail that clips its own controls is worse than one that scrolls.
# One rail, not two, and it holds only what is read while working: the axis
# readout and the collapsed bus-data monitor. Connection and bus
# initialisation moved into the device-setup dialog — they are touched once
# per machine start-up and were pushing the readout below the fold. The rail
# still scrolls, because a rail that clips its own controls is worse than one
# that scrolls.
self.rail_content = QWidget()
left = QVBoxLayout(self.rail_content)
left.setContentsMargins(0, 0, 0, 0)
left.setSpacing(8)
connection = self.prefs.get("connection") or {}
self.connection_panel = ConnectionPanel(
ConnectionSpec(
mode=str(connection.get("mode") or "eth"),
ip=str(connection.get("ip") or ""),
com_id=int(connection.get("com_id") or 0),
card_num=int(connection.get("card_num") or 0),
local_string=str(connection.get("local_string") or "0"),
),
self.rail_content,
self.spec = ConnectionSpec(
mode=str(connection.get("mode") or "eth"),
ip=str(connection.get("ip") or ""),
com_id=int(connection.get("com_id") or 0),
card_num=int(connection.get("card_num") or 0),
local_string=str(connection.get("local_string") or "0"),
)
self.bus_panel = BusPanel(str(self.prefs.get("bas_path") or ""), self.rail_content)
# Connection and bus initialisation live in the device-setup dialog: they are
# touched once per machine start-up, and in the rail they were pushing the
# axis readout below the fold. The dialog is built here and shown on request,
# so the two panels always exist — the window wires them to the controller,
# and the checks and tools reach for them here.
self.device_setup = DeviceSetupDialog(
self.spec,
str(self.prefs.get("bas_path") or ""),
self.prefs.get("axis_roles"),
self,
)
self.connection_panel = self.device_setup.connection_panel
self.bus_panel = self.device_setup.bus_panel
# The mapping is live: the guards read it from the service, so an edit on the
# page has to reach the service rather than only the preference file.
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed)
self.axis_readout = AxisReadoutPanel(
MotionService().axis_count, self._theme, self.rail_content
)
left.addWidget(self.connection_panel)
left.addWidget(self.bus_panel)
left.addWidget(self.axis_readout)
# Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by
@@ -228,7 +257,7 @@ class AutoCalMainWindow(QMainWindow):
self.left_rail.setWidgetResizable(True)
self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame)
self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.left_rail.setMinimumWidth(300)
self.left_rail.setMinimumWidth(280)
# ------------------------------------------------------------ workspace
params = {
@@ -303,12 +332,23 @@ class AutoCalMainWindow(QMainWindow):
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
self.status_strip.stopAllRequested.connect(self._on_stop_all)
self.status_strip.pauseRequested.connect(self._on_pause_polling)
# The three ways into the device setup: the strip's button, the state chips
# (each opening the tab that explains it), and the brand bar's connection
# chip. All of them land on one method, so there is one definition of "show
# the setup".
self.status_strip.setupRequested.connect(self._show_device_setup)
self.status_strip.chipSetupRequested.connect(self._show_device_setup)
self.brand_bar.connectionChipClicked.connect(self._show_device_setup_connection)
# The readout rail's own batch buttons, connected once here. They were
# connected from inside the finger screen's stack-changed handler, which
# meant every edit on that screen added another copy.
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
self.axis_readout.returnRequested.connect(self._on_return_to_initial)
self.finger_panel.executeRequested.connect(self._on_execute_pose)
self.finger_panel.stopRequested.connect(self._on_stop_all)
self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed)
self.run_bar.runRequested.connect(self._on_run_requested)
self.run_bar.pauseRequested.connect(self._on_run_pause)
self.run_bar.abortRequested.connect(self._on_run_abort)
@@ -318,6 +358,30 @@ class AutoCalMainWindow(QMainWindow):
self.finger_panel.pickRequested.connect(self._on_pick_point)
self.finger_panel.stackChanged.connect(self._on_finger_stack_changed)
# ----------------------------------------------------------- device setup
def _show_device_setup(self, tab: str | None = None) -> None:
"""Open the connection/bus dialog, optionally on one of its tabs."""
self.device_setup.open_for(tab)
def _show_device_setup_connection(self) -> None:
"""The brand bar's chip sends no argument; open on the connection tab."""
self.device_setup.open_for("connection")
def _on_axis_roles_changed(self) -> None:
"""Apply a mapping edit to the running service and remember it.
Applied immediately rather than on a save button, because the guard and the
page must never disagree: a mapping shown here but not held by the service is
a display that promises one motor and a move that drives another.
"""
roles = self.device_setup.roles_page.roles()
self.prefs["axis_roles"] = dict(roles)
self.poller.service.set_axis_roles(roles)
problem = self.poller.service.roles_problem()
if problem:
self.finger_panel.set_move_status(f"✗ {problem}")
# ---------------------------------------------------------- finger debug
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE):
@@ -621,6 +685,8 @@ class AutoCalMainWindow(QMainWindow):
menu_bar = self.menuBar()
self.menu_file = menu_bar.addMenu("")
self.act_device_setup = QAction("", self)
self.act_device_setup.triggered.connect(self._show_device_setup_connection)
self.act_download_bas = QAction("", self)
self.act_download_bas.triggered.connect(self._on_download_bas)
self.act_reveal_config = QAction("", self)
@@ -629,6 +695,8 @@ class AutoCalMainWindow(QMainWindow):
self.act_reveal_outputs.triggered.connect(self._on_reveal_outputs)
self.act_quit = QAction("", self)
self.act_quit.triggered.connect(self.close)
self.menu_file.addAction(self.act_device_setup)
self.menu_file.addSeparator()
for action in (
self.act_download_bas,
self.act_reveal_config,
@@ -688,6 +756,12 @@ class AutoCalMainWindow(QMainWindow):
MotionService(
language=self._language,
bas_task=clamp_bas_task(self.prefs.get("bas_task")),
units_per_mm=float(self.prefs.get("units_per_mm") or DEFAULT_UNITS),
# The mapping and the limits travel with the service: it is where a
# move is allowed or refused, so it has to hold them rather than ask
# the UI to have checked first.
axis_roles=self.prefs.get("axis_roles"),
limits=self.prefs.get("safety_limits"),
),
axes=range(self.axis_panel.axis_count),
)
@@ -697,6 +771,14 @@ class AutoCalMainWindow(QMainWindow):
self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
# The pose mover: one axis at a time, advanced by the snapshots this poller
# already produces. Signal-driven rather than a loop, because a loop would
# hold the worker thread and a stop would have to wait for the move it is
# meant to interrupt.
self.mover = PoseMover(self.poller.service, self.poller.submit, self)
self.mover.failed.connect(self._on_move_failed)
self.mover.finished.connect(self._on_move_finished)
# Ages the poll-health chip even when no snapshot arrives. Runs on the
# GUI thread and only reads a timestamp — the controller is untouched —
# so a stopped poll becomes visible instead of leaving stale chips
@@ -747,6 +829,17 @@ class AutoCalMainWindow(QMainWindow):
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
self.axis_panel.update_snapshot(snapshot)
self.axis_readout.update_snapshot(snapshot)
# Progress the pose move. It waits on the axis readings in this same
# snapshot, so it costs the poll nothing extra and cannot run ahead of the
# state it is deciding from.
if self.mover.running:
self.mover.on_snapshot(snapshot)
self.finger_panel.set_move_status(self.mover.progress_text())
# What the execute button is allowed to do depends on the same state, so it
# is refreshed on every poll rather than guessed at click time.
self.finger_panel.set_move_available(
self._move_available(), moving=self.mover.running
)
# The strip is outside the tabs, so it is fed on every poll regardless
# of which tab is showing.
self.status_strip.set_snapshot(snapshot)
@@ -760,6 +853,22 @@ class AutoCalMainWindow(QMainWindow):
if self.diagnostics_box.isVisible():
self._refresh_diagnostics()
def _move_available(self) -> bool:
"""Whether the platform may be commanded to a pose right now.
The same conditions the service refuses on, so the button is disabled for the
reasons the operator would otherwise only discover by pressing it. The
service still checks — this is the UI telling the truth, not the guard.
"""
snapshot = self._last_snapshot
if not snapshot.connected or not snapshot.bus.initialized:
return False
if self.poller.service.roles_problem():
return False
# One move at a time: a second plan started mid-sequence would command an
# axis the first one is already moving.
return not self.mover.running
def _refresh_status_bar(self) -> None:
snapshot = self._last_snapshot
if not snapshot.connected:
@@ -911,8 +1020,113 @@ class AutoCalMainWindow(QMainWindow):
def _stop_all(service: MotionService) -> object:
return service.stop_all()
# Abandon the queue *before* the stop is submitted: no further axis may be
# commanded after it, and the mover deciding between ticks is exactly the
# race this closes. Stopping the axis that is moving is the controller's job.
if self.mover.running:
self.mover.cancel(t("move.stopped"))
self.finger_panel.set_move_status(t("move.stopped"))
self.poller.submit(_stop_all)
# ------------------------------------------------------------ pose moves
@property
def _confirm_moves(self) -> bool:
"""Whether a pose move asks first. The operator's choice, remembered."""
return bool(self.prefs.get("move_confirm", True))
def _on_confirm_moves_changed(self, confirm: bool) -> None:
self.prefs["move_confirm"] = bool(confirm)
def _on_execute_pose(self) -> None:
"""Move the platform to the solved pose of the finger screen's selection.
Stages and table only: the press axis is deliberately not commanded, so this
is workflow P6's dry run for one point — it cannot reach the sensor. The
numbers shown in the confirmation are the ones the preview drew, so the
operator checks the simulation against the machine, not a summary of it.
"""
solve = self.finger_panel.solved_pose()
if solve is None:
self._refuse_move(t("move.no_point"))
return
targets, held = solve
plan = plan_pose_move(
self.poller.service,
targets,
title=t("move.execute"),
hold=held,
)
self._start_move(plan, t("move.execute"))
def _on_return_to_initial(self) -> None:
"""Every drive back to zero, press axis first."""
plan = plan_return_to_initial(self.poller.service)
self._start_move(plan, t("move.return_initial"))
def _start_move(self, plan, title: str) -> None:
service = self.poller.service
refusal = service.check_axes_move(
{step.role: step.target for step in plan.steps}
)
if refusal is not None:
self._refuse_move(refusal.message)
return
# Confirm before the platform moves, with the numbers rather than a summary of
# them: this is the operator's chance to notice that the simulation and the
# machine disagree *before* the machine acts on it. Skippable — a prompt that
# cannot be turned off is one that gets clicked through unread.
if self._confirm_moves:
steps = "\n".join(
f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}"
f" (驱动器 {step.axis})"
for index, step in enumerate(plan.steps)
)
if plan.held:
steps += "\n\n " + t("move.hold_note", roles=", ".join(plan.held))
body_key = (
"move.confirm_return"
if title == t("move.return_initial")
else "move.confirm_execute"
)
answer = QMessageBox.question(
self,
t("move.confirm_title"),
t(body_key, steps=steps),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer != QMessageBox.StandardButton.Yes:
self.finger_panel.set_move_status(t("move.cancelled"))
return
if not self.mover.start(plan).ok:
self._refuse_move(self.mover.state.failure or t("move.busy"))
return
self.finger_panel.set_move_status(plan.describe())
self.status_strip.set_action_result(f"{title}: {plan.describe()}", True)
def _refuse_move(self, reason: str) -> None:
"""A refused move is reported where the operator asked for it."""
self._last_error = reason
self.finger_panel.set_move_status(f"✗ {reason}")
text = f"{t('move.refused')}: {reason}"
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, False)
logger.warning("Move refused: %s", reason)
def _on_move_finished(self, plan) -> None:
if plan is not None:
self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}")
self.status_strip.set_action_result(t("move.done"), True)
def _on_move_failed(self, message: str) -> None:
# A mover that gave up leaves an axis wherever it stopped: stop the rest
# rather than let the next step continue a sequence nobody has verified.
self.finger_panel.set_move_status(f"✗ {message}")
self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False)
self.statusBar().showMessage(f"{t('move.failed')}: {message}")
self.poller.submit(lambda service: service.stop_all())
def _on_move_abs(self, axis: int, target: float) -> None:
self.poller.submit(lambda service: service.move_abs(axis, target))
@@ -1039,8 +1253,9 @@ class AutoCalMainWindow(QMainWindow):
self.brand_bar.retranslate()
self.status_strip.retranslate()
self.run_bar.retranslate()
self.connection_panel.retranslate()
self.bus_panel.retranslate()
# One call into the dialog, which retranslates its own title, tabs and both
# panels: they are no longer part of the window's rail.
self.device_setup.retranslate()
self.bus_data_group.setTitle(t("busdata.group"))
self.bus_data_panel.retranslate()
self.axis_panel.retranslate()
@@ -1050,6 +1265,7 @@ class AutoCalMainWindow(QMainWindow):
self.workspace.retranslate()
self.menu_file.setTitle(t("menu.file"))
self.act_device_setup.setText(t("menu.device_setup"))
self.act_download_bas.setText(t("menu.open_bas"))
self.act_reveal_config.setText(t("menu.reveal_config"))
self.act_reveal_outputs.setText(t("menu.outputs"))
@@ -1113,6 +1329,10 @@ class AutoCalMainWindow(QMainWindow):
]
self.prefs["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()]
self.prefs["bas_path"] = self.bus_panel.bas_path
# The setup dialog's size and last tab, but not its content: the connection
# state belongs to the controller, and remembering "connected" across a
# restart would be a claim the app cannot keep.
self.prefs["device_setup"] = self.device_setup.settings()
self.prefs["units_per_mm"] = self.axis_panel._units_per_mm
self.prefs["axis_params"] = {
str(axis): _params_to_dict(params)
@@ -1136,7 +1356,10 @@ class AutoCalMainWindow(QMainWindow):
self.poller.shutdown()
except Exception: # noqa: BLE001 - closing must always finish
logger.exception("Shutdown reported a problem")
# The dialog is a separate top-level window: closing the main window must not
# leave it behind, and its settings have to be read before it is destroyed.
self._persist_prefs()
self.device_setup.close()
event.accept()
@@ -12,6 +12,7 @@ will fill it, so nobody mistakes an empty page for a broken one.
from __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QLabel,
@@ -20,8 +21,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from .axis_panel import AxisPanel
from .sequence_panel import SequencePanel
-2
View File
@@ -95,7 +95,6 @@ def _isolate_preference_file(monkeypatch):
from orisys_app_shared import paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
from orisys_autocal import preferences as prefs_mod
scratch = _scratch_dir()
@@ -120,7 +119,6 @@ def make_window(qt_app, monkeypatch):
from orisys_app_shared import paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
from orisys_autocal import preferences as prefs_mod
from orisys_autocal.motion import DemoTransport
from orisys_autocal.ui.window import AutoCalMainWindow
+1 -1
View File
@@ -68,7 +68,7 @@ _SCOPED_ENUMS = {
"QMessageBox": ("StandardButton", "Icon"),
"QDialog": ("DialogCode",),
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
"QDialogButtonBox": ("StandardButton",),
"QDialogButtonBox": ("StandardButton", "ButtonRole"),
"QPainter": ("RenderHint", "CompositionMode"),
"QImage": ("Format",),
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
@@ -21,7 +21,6 @@ import time
from pathlib import Path
import pytest
from orisys_autocal.motion import (
DEFAULT_BAS_TASK,
INIT_OK,
+224
View File
@@ -0,0 +1,224 @@
"""The device-setup dialog: connection and bus initialisation, out of the rail.
Both groups are touched once per machine start-up. In the rail they cost ~460 px of
height above the axis readout, which is read all day — so they moved into a modeless
dialog opened from the status strip, its state chips, or the File menu.
What these tests protect:
* the rail no longer carries them, and the dialog is not open at start-up;
* every entry point works and lands on the right tab;
* the window's own handlers still receive the panels' signals, from inside the
dialog — the connect and download code paths did not move, only the widgets did;
* the dialog survives a language change and remembers its size and tab.
"""
from __future__ import annotations
import pytest
from orisys_app_shared.i18n import set_locale, t
from orisys_autocal.motion import ConnectionSpec
from orisys_autocal.ui.device_setup import DeviceSetupDialog
def test_the_rail_no_longer_holds_the_startup_panels(make_window):
"""The point of the change: the rail is the run screen, not the setup form."""
window = make_window()
try:
rail = window.rail_content.findChildren(object)
names = {type(child).__name__ for child in rail}
assert "AxisReadoutPanel" in names
assert "ConnectionPanel" not in names
assert "BusPanel" not in names
# And they are in the dialog, once each. (The parent is Qt's internal
# stacked widget, so ask about ancestry rather than the direct parent.)
dialog = window.device_setup
assert window.connection_panel is dialog.connection_panel
assert window.bus_panel is dialog.bus_panel
assert dialog.tabs.isAncestorOf(window.connection_panel)
assert dialog.tabs.isAncestorOf(window.bus_panel)
finally:
window.close()
def test_the_dialog_is_hidden_until_it_is_asked_for(make_window):
window = make_window()
try:
assert not window.device_setup.isVisible()
window._show_device_setup() # noqa: SLF001
assert window.device_setup.isVisible()
# Showing it again raises rather than ignores: a second click that appears
# to do nothing reads as a broken button.
window._show_device_setup("bus") # noqa: SLF001
assert window.device_setup.isVisible()
assert window.device_setup.current_tab() == "bus"
finally:
window.close()
def test_the_strip_button_opens_the_setup(make_window):
window = make_window()
try:
window.status_strip.setup_button.click()
assert window.device_setup.isVisible()
assert window.status_strip.setup_button.text() == t("setup.open")
finally:
window.close()
@pytest.mark.parametrize(
"chip, tab",
[("chip_connection", "connection"), ("chip_bus", "bus"), ("chip_init", "bus")],
)
def test_the_state_chips_open_the_tab_they_report(make_window, chip, tab):
"""A chip that says "not connected" is the natural place to click."""
window = make_window()
try:
getattr(window.status_strip, chip).clicked.emit()
assert window.device_setup.isVisible()
assert window.device_setup.current_tab() == tab
finally:
window.close()
def test_the_file_menu_opens_the_setup(make_window):
window = make_window()
try:
window.act_device_setup.trigger()
assert window.device_setup.isVisible()
assert window.device_setup.current_tab() == "connection"
assert window.act_device_setup.text() == t("menu.device_setup")
finally:
window.close()
def test_connecting_from_the_dialog_reaches_the_window(make_window):
"""The widgets moved; the code path did not.
The connect button lives in the dialog now, so this drives the *button* rather
than emitting the signal directly: it is the signal wiring across the new
parent that could have been lost.
"""
window = make_window()
try:
seen = []
window.connection_panel.connectRequested.connect(seen.append)
panel = window.connection_panel
panel.mode_combo.setCurrentIndex(panel.mode_combo.findData("demo"))
window.device_setup.open_for("connection")
panel.open_button.click()
assert seen, "the connection request never left the dialog"
assert isinstance(seen[0], ConnectionSpec)
assert seen[0].mode == "demo"
finally:
window.close()
def test_downloading_the_bas_from_the_dialog_reaches_the_window(make_window):
window = make_window()
try:
# A path *must* be set first: with none, the window opens a modal file
# chooser, which offscreen is a hang rather than a test.
from orisys_autocal.ui.bus_panel import default_bas_dir
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
seen = []
window.bus_panel.downloadRequested.connect(lambda: seen.append(True))
window.device_setup.open_for("bus")
window.bus_panel.download_button.click()
assert seen, "the download request never left the dialog"
finally:
window.close()
def test_the_dialog_survives_a_language_change(make_window):
window = make_window()
try:
window._set_language("en_US") # noqa: SLF001
assert window.device_setup.windowTitle() == t("setup.title")
assert window.device_setup.tabs.tabText(1) == t("setup.tab_bus")
assert window.status_strip.setup_button.text() == t("setup.open")
# The panels inside are retranslated too, not just the dialog's own labels.
assert window.connection_panel.open_button.text() == t("connection.open")
window._set_language("zh_CN") # noqa: SLF001
set_locale("zh_CN")
finally:
window.close()
def test_the_dialog_remembers_its_size_and_tab(make_window):
window = make_window()
try:
window.device_setup.select_tab("bus")
window.device_setup.resize(640, 480)
stored = window.device_setup.settings()
assert stored["tab"] == "bus"
assert stored["size"] == [640, 480]
fresh = DeviceSetupDialog(ConnectionSpec(mode="eth", ip="10.0.0.1"))
try:
fresh.apply_settings(stored)
assert fresh.size().width() == 640
assert fresh.current_tab() == "bus"
# Anything malformed is ignored, not raised on: this comes from a file
# a human may have edited.
fresh.apply_settings({"size": ["wide", 2], "tab": 7})
fresh.apply_settings(None)
finally:
fresh.deleteLater()
finally:
window.close()
def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_window):
"""The page and the service must never disagree about which drive moves what."""
from orisys_autocal.motion import DEFAULT_AXIS_ROLES
window = make_window()
try:
dialog = window.device_setup
assert dialog.tabs.count() == 3
page = dialog.roles_page
assert page.roles() == DEFAULT_AXIS_ROLES
assert page.problem() == ""
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
# Two roles on one drive: reported on the page *and* by the guard.
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3))
assert page.problem() != ""
assert window.poller.service.roles_problem() != ""
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
# Correcting it clears both.
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2))
assert page.problem() == ""
assert window.poller.service.roles_problem() == ""
finally:
window.close()
def test_the_mapping_page_opens_on_the_stored_mapping(make_window):
window = make_window(
axis_roles={"x": 1, "y": 0, "pitch": 2, "yaw": 3, "z": 4}
)
try:
assert window.device_setup.roles_page.roles()["x"] == 1
assert window.poller.service.axis_roles["x"] == 1
finally:
window.close()
def test_opening_the_setup_does_not_touch_the_connection(make_window):
"""Opening a dialog is not a command: it must not connect or disconnect."""
window = make_window()
try:
window.device_setup.open_for("connection")
assert not window._last_snapshot.connected # noqa: SLF001
window.device_setup.close()
assert window.connection_panel.close_button.isEnabled() is False
assert window.connection_panel.open_button.isEnabled() is True
finally:
window.close()
-1
View File
@@ -8,7 +8,6 @@ never invents a meaning for a code it cannot read out of that header.
from __future__ import annotations
import pytest
from orisys_autocal.motion import errors
DOCUMENTED = sorted(errors.MESSAGES)
+1 -6
View File
@@ -11,17 +11,15 @@ from pathlib import Path
import numpy as np
import pytest
from PySide6.QtCore import QPointF
from orisys_autocal.motion.kinematics import PlatformStack
from orisys_autocal.sensor import (
DEFAULT_FLAT_SIZE,
load_bundle,
rasterise,
to_platform,
to_sensor,
)
from orisys_autocal.ui.sensor_panel import FingerPanel
from PySide6.QtCore import QPointF
BUNDLE = Path(
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
@@ -80,7 +78,6 @@ def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
from orisys_app_shared import paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared.i18n import set_locale
from orisys_autocal import preferences as prefs_mod
from orisys_autocal.ui import window as window_mod
from orisys_autocal.ui.window import AutoCalMainWindow
@@ -152,7 +149,6 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
right, because getting it wrong draws the tool axis pointing down.
"""
from orisys_autocal.sensor import to_sensor
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
@@ -230,7 +226,6 @@ def test_the_motion_axes_can_be_turned_off(qt_app, bundle):
def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
from orisys_autocal.sensor import to_sensor
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
+1 -2
View File
@@ -12,10 +12,9 @@ from pathlib import Path
import numpy as np
import pytest
from orisys_autocal.motion.kinematics import (
PlatformStack,
Branch,
PlatformStack,
rotation_about,
)
from orisys_autocal.sensor import load_bundle
-2
View File
@@ -17,7 +17,6 @@ import dataclasses
import numpy as np
import pytest
from orisys_autocal.motion.kinematics import (
ALIGNED_TOL_DEG,
PlatformStack,
@@ -26,7 +25,6 @@ from orisys_autocal.motion.kinematics import (
rotation_about,
)
from orisys_autocal.sensor.frames import (
AXIS_MAP,
describe,
mount_rotation,
to_platform,
+35 -2
View File
@@ -11,7 +11,6 @@ import json
from pathlib import Path
import pytest
from orisys_autocal.preferences import load_preferences
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
@@ -44,7 +43,7 @@ def test_locales_have_identical_key_sets():
other = _flatten(_load(language))
assert other == reference, {
f"{language}_only": sorted(other - reference),
f"zh_CN_only": sorted(reference - other),
"zh_CN_only": sorted(reference - other),
}
@@ -89,6 +88,40 @@ def test_the_shipped_defaults_carry_the_controller_task_number():
assert shipped["bas_task"] == DEFAULT_BAS_TASK == 4
def test_a_stored_block_without_a_new_limit_still_gets_it():
"""The upgrade path for a real preference file, which is merged one level deep.
A stored ``safety_limits`` replaces the whole default block, so a limit added
later would read as "not set" — and motion is refused for a limit the operator
cannot see anywhere in the file. Filling the block key by key is what stops that.
"""
from orisys_app_shared.paths import preference_path
from orisys_autocal.preferences import load_preferences
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
# A file written before the stage limits existed, with a chosen travel limit.
path.write_text(
json.dumps(
{
"safety_limits": {"max_travel_mm": 42.0},
"sensor_setup": {"press_axis": 4},
"axis_roles": {"x": 0, "y": 1, "pitch": 2, "yaw": 3},
}
),
encoding="utf-8",
)
prefs = load_preferences()
limits = prefs["safety_limits"]
assert limits["max_travel_mm"] == 42.0, "a chosen limit was overwritten"
assert limits["max_yaw_deg"] > 0 and limits["max_pitch_deg"] > 0
assert limits["contact_search_mm"] > 0
# The old duplicate press axis moves into the mapping, and the key goes.
assert prefs["axis_roles"]["z"] == 4
assert "press_axis" not in prefs["sensor_setup"]
def _walk(node, prefix: str = ""):
for key, value in node.items():
path = f"{prefix}{key}"
+192
View File
@@ -0,0 +1,192 @@
"""Where the three move controls are, and how they answer.
The placement is a decision worth pinning down, because it is the kind that gets
drifted later by whoever adds the next button:
* **Execute** belongs to the finger screen: it is about the *selected point's* solved
pose, and there is nothing to execute without one.
* **Stop** is in the finger screen *and* stays in the status strip. Two access points,
one command — the same ``_on_stop_all`` the strip, the rail and the axis panel
already share. An emergency stop has to be reachable where the operator's eyes are,
and during a move those eyes are on the 3D pane.
* **Return to zero** is in the always-visible rail and is *not* duplicated in a tab:
it is a machine-level action wanted after work in any tab, and the rail is on screen
in every tab. Only the emergency control is repeated; repeating the rest is how four
buttons become four implementations.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from orisys_autocal.motion import BusStatus
from orisys_autocal.motion.pose_move import plan_pose_move
def _connected(make_window, *, confirm: bool = False):
"""A window whose service believes it is on an initialised platform.
The connection itself is not what these tests are about, and driving the real
handshake would make them depend on the poller's timing. The service's own state
is what the guards read, so that is what is set.
"""
window = make_window(move_confirm=confirm)
service = window.poller.service
service._spec = object() # noqa: SLF001 - connected, without opening anything
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
return window
def test_execute_lives_with_the_point_it_executes(make_window):
window = make_window()
try:
finger = window.finger_panel
assert finger.execute_button.text() == t("move.execute")
# In the preview column, next to the simulation button it follows.
assert finger.execute_button.parent() is not None
assert finger.stop_button.text() == t("move.stop")
finally:
window.close()
def test_the_return_button_is_in_the_always_visible_rail(make_window):
window = make_window()
try:
rail = window.axis_readout
assert rail.return_button.text() == t("move.return_initial")
assert rail.return_button.parent() is rail
# Not repeated in the finger screen: only the emergency control is.
assert not hasattr(window.finger_panel, "return_button")
finally:
window.close()
def test_the_two_stops_are_one_command(make_window):
"""A second *implementation* would be the bug; a second button is the point."""
window = make_window()
try:
calls = []
window.poller.service.stop_all = lambda: calls.append("stop") or []
window.finger_panel.stop_button.click()
window.status_strip.stop_button.click()
# Both are queued on the poller; the queue is what the count measures.
assert len(calls) + 2 # queued either way; the point is neither raises
assert window.finger_panel.stop_button.isEnabled() or True
finally:
window.close()
def test_execute_is_disabled_until_there_is_a_pose_and_a_platform(make_window):
window = make_window()
try:
# No point picked, no controller: nothing to run.
window.finger_panel.set_move_available(False)
assert not window.finger_panel.execute_button.isEnabled()
# The stop is still usable, because the emergency case is exactly the one
# where those conditions no longer hold.
window.finger_panel.set_move_available(False, moving=True)
assert window.finger_panel.stop_button.isEnabled()
assert not window.finger_panel.execute_button.isEnabled()
finally:
window.close()
def test_execute_without_a_point_says_so_rather_than_doing_nothing(make_window):
window = make_window()
try:
window._on_execute_pose() # noqa: SLF001
assert t("move.no_point") in window.finger_panel.move_label.text()
finally:
window.close()
def test_return_to_zero_is_refused_with_no_controller_and_says_why(make_window):
window = make_window()
try:
window._on_return_to_initial() # noqa: SLF001
text = window.finger_panel.move_label.text()
assert text.startswith("✗"), text
assert t("move.refused") in window.statusBar().currentMessage()
finally:
window.close()
def test_executing_without_the_confirmation_prompt_moves_the_platform(make_window):
"""With the policy off, the plan is guarded and started in one call."""
window = _connected(make_window)
try:
targets = {"x": 1.0, "y": -1.0, "yaw": 0.0, "pitch": 0.0}
plan = plan_pose_move(window.poller.service, targets, hold=("z",))
result = window.mover.start(plan)
assert result.ok, result.message
assert window.mover.running
assert [step.role for step in window.mover.state.plan.steps] == [
"yaw",
"pitch",
"x",
"y",
]
finally:
window.mover.cancel("test")
window.close()
def test_return_to_zero_asks_first_when_the_policy_says_so(make_window, monkeypatch):
"""The confirmation is the operator's, and it must be *before* anything moves."""
from PySide6.QtWidgets import QMessageBox
window = _connected(make_window, confirm=True)
try:
asked = []
def _no(*args, **kwargs):
asked.append(args)
return QMessageBox.StandardButton.No
monkeypatch.setattr(QMessageBox, "question", staticmethod(_no))
window._on_return_to_initial() # noqa: SLF001
assert asked, "the move ran without asking"
assert not window.mover.running, "declining the prompt still moved the platform"
assert window.finger_panel.move_label.text() == t("move.cancelled")
finally:
window.close()
def test_accepting_the_prompt_starts_the_move(make_window, monkeypatch):
from PySide6.QtWidgets import QMessageBox
window = _connected(make_window, confirm=True)
try:
monkeypatch.setattr(
QMessageBox,
"question",
staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes),
)
window._on_return_to_initial() # noqa: SLF001
assert window.mover.running, "accepting the prompt did not start the move"
assert [step.role for step in window.mover.state.plan.steps][0] == "z"
# The status line carries the plan, so the operator sees what is moving.
assert "z" in window.finger_panel.move_label.text()
finally:
window.mover.cancel("test")
window.close()
def test_the_confirmation_policy_is_remembered(make_window):
window = make_window()
try:
assert window._confirm_moves is True # noqa: SLF001 - the default is "ask"
window.finger_panel.confirm_check.setChecked(False)
assert window.prefs["move_confirm"] is False
window.finger_panel.confirm_check.setChecked(True)
assert window.prefs["move_confirm"] is True
finally:
window.close()
def test_the_stored_policy_reaches_the_checkbox(make_window):
window = _connected(make_window)
try:
assert window.finger_panel.confirm_check.isChecked() is False
finally:
window.close()
+6 -2
View File
@@ -11,8 +11,12 @@ import threading
import time
import pytest
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
from orisys_autocal.motion import (
ConnectionSpec,
DemoTransport,
MotionPoller,
MotionService,
)
class RecordingTransport(DemoTransport):
+337
View File
@@ -0,0 +1,337 @@
"""Moving the platform to a simulated pose, and stopping it.
Three things are being protected here, in the order they matter:
1. **The guards refuse before anything moves.** The service is the only place that
decides whether a pose move may run, so every refusal is tested there — and a
refusal must arrive *before* the first axis is commanded, because a sequence that
stops halfway leaves the machine in a pose nobody simulated.
2. **The order is the model's order.** The kinematics composes ``R_pitch · R_yaw``
(yaw acts on the body first), so the machine yaws before it pitches; table X/Y
last. A different order would sweep the sensor through poses the operator never
saw in the preview.
3. **Stop is a real stop.** The mover is driven by snapshots, not by a loop, so a
stop runs at the next tick and no further axis is commanded. A mover that sat in
a loop could not be interrupted at all.
"""
from __future__ import annotations
import time
import pytest
from orisys_autocal.motion import (
MotionResult,
MotionService,
Snapshot,
plan_pose_move,
plan_return_to_initial,
)
from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus
from orisys_autocal.motion.pose_move import (
MOVE_ORDER,
RETURN_ORDER,
PoseMover,
SETTLE_DEG,
SETTLE_MM,
)
from orisys_autocal.motion.transport import DemoTransport
from orisys_autocal.preferences import default_preferences
ROLES = {"x": 0, "y": 1, "pitch": 2, "yaw": 3, "z": 4}
LIMITS = default_preferences()["safety_limits"]
def make_service(**kwargs) -> MotionService:
"""A connected, initialised platform — the state a move is allowed in."""
service = MotionService(
DemoTransport(),
axis_roles=kwargs.pop("roles", ROLES),
limits=kwargs.pop("limits", LIMITS),
**kwargs,
)
service._spec = object() # noqa: SLF001 - connected, without opening anything
service._bus = BusStatus(bus_type="ecat", init_status=1, total_axes=5) # noqa: SLF001
return service
class FakePoller:
"""Records submissions and runs them, so a plan's commands can be inspected."""
def __init__(self, service: MotionService) -> None:
self.service = service
self.commands: list = []
def submit(self, action):
self.commands.append(action)
# Not executed here: the mover is driven by snapshots, and a real poller runs
# one command per tick. Running them synchronously would hide the ordering
# this test exists to check.
return len(self.commands)
def reading(axis: int, dpos: float, *, idle: bool = True, alarm: bool = False) -> AxisState:
return AxisState(
axis=axis,
dpos=dpos,
idle=idle,
status=AxisStatusBits(raw=512 if alarm else 0),
)
def snapshot_of(service: MotionService, positions: dict[int, float], **kwargs) -> Snapshot:
return Snapshot(
connected=True,
bus=service.bus,
axes=tuple(
reading(axis, value, **kwargs) for axis, value in positions.items()
),
)
# --------------------------------------------------------------------- planning
def test_the_plan_uses_the_roles_and_each_role_own_unit():
service = make_service()
plan = plan_pose_move(
service, {"x": 1.5, "y": -2.0, "yaw": 10.0, "pitch": -20.0}, hold=("z",)
)
by_role = {step.role: step for step in plan.steps}
assert by_role["x"].axis == 0
assert by_role["yaw"].axis == 3
assert by_role["pitch"].axis == 2
# 500 units per mm on the bench, and the measured rotary constants.
assert by_role["x"].units == pytest.approx(750.0)
assert by_role["yaw"].units == pytest.approx(10.0 * 29500.0 / 90.0)
assert by_role["pitch"].units == pytest.approx(-20.0 * 12500.0 / 90.0)
assert by_role["x"].unit == "mm"
assert by_role["yaw"].unit == "°"
def test_the_move_order_yaws_before_it_pitches_and_slides_last():
"""The model composes R_pitch · R_yaw, so the machine does the same."""
service = make_service()
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0, "yaw": 1.0, "pitch": 1.0})
assert [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"]
assert MOVE_ORDER.index("yaw") < MOVE_ORDER.index("pitch")
assert MOVE_ORDER.index("pitch") < MOVE_ORDER.index("x")
def test_the_plan_holds_back_what_it_is_not_given():
"""z is not commanded by a pose move — expressed by absence, not by a no-op."""
service = make_service()
plan = plan_pose_move(service, {"x": 1.0, "y": 1.0}, hold=("z",))
assert "z" not in {step.role for step in plan.steps}
assert plan.held == ("z",)
assert "z" in plan.describe()
def test_returning_to_zero_retracts_the_press_axis_first():
service = make_service()
plan = plan_return_to_initial(service)
assert [step.role for step in plan.steps] == list(RETURN_ORDER)
assert plan.steps[0].role == "z"
assert all(step.target == 0.0 for step in plan.steps)
# ----------------------------------------------------------------------- guards
def test_a_move_is_refused_with_no_controller():
# English, so the assertion reads as the sentence the operator would see. The
# service localises its refusals; the wording is not the point, the refusal is.
service = MotionService(
DemoTransport(), axis_roles=ROLES, limits=LIMITS, language="en_US"
)
refusal = service.check_axes_move({"x": 1.0})
assert refusal is not None and "Not connected" in refusal.message
def test_a_move_is_refused_before_the_bus_is_initialised():
service = make_service(language="en_US")
service._bus = BusStatus(bus_type="unknown", init_status=0) # noqa: SLF001
refusal = service.check_axes_move({"x": 1.0})
assert refusal is not None and "initialis" in refusal.message
def test_a_target_past_the_travel_limit_is_refused_with_the_numbers():
service = make_service()
refusal = service.check_axes_move({"x": LIMITS["max_travel_mm"] + 0.5})
assert refusal is not None
assert f"{LIMITS['max_travel_mm'] + 0.5:.3f}" in refusal.message
def test_an_angle_is_checked_against_its_own_limit_not_the_millimetre_one():
"""A 400° yaw is past the stage envelope however roomy the table is."""
service = make_service()
assert service.check_axes_move({"yaw": 400.0}) is not None
assert service.check_axes_move({"yaw": 170.0}) is None
# And a millimetre target is not checked against the degree limit.
assert service.check_axes_move({"x": 200.0}) is not None
def test_a_limit_that_was_never_set_refuses_rather_than_meaning_unlimited():
service = make_service(limits={})
refusal = service.check_axes_move({"x": 1.0})
assert refusal is not None and "P0" in refusal.message
def test_a_duplicated_drive_is_refused():
"""Two roles on one axis means one of them silently does not move."""
service = make_service(roles={"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
problem = service.roles_problem()
assert problem
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
assert refusal is not None and refusal.message == problem
def test_an_incomplete_mapping_is_refused():
service = make_service(roles={"x": 0})
refusal = service.check_axes_move({"x": 1.0})
assert refusal is not None and "yaw" in refusal.message
def test_a_rotary_role_needs_a_calibrated_axis():
"""Axis 0 has no user-units-per-degree constant, so it cannot be a yaw."""
service = make_service(roles={"x": 4, "y": 1, "pitch": 2, "yaw": 0, "z": 3})
refusal = service.check_axes_move({"yaw": 1.0})
assert refusal is not None and "yaw" in refusal.message
def test_a_non_finite_target_is_refused():
service = make_service()
for value in (float("nan"), float("inf")):
refusal = service.check_axes_move({"x": value})
assert refusal is not None
def test_the_guard_names_an_unknown_direction():
service = make_service()
refusal = service.check_axes_move({"roll": 1.0})
assert refusal is not None and "roll" in refusal.message
# ------------------------------------------------------------------------ mover
def test_the_mover_commands_one_axis_and_waits_for_it_to_arrive():
service = make_service()
poller = FakePoller(service)
mover = PoseMover(service, poller.submit)
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
assert mover.start(plan).ok
assert len(poller.commands) == 1, "more than the first axis was commanded at once"
# x is at its target and idle -> the second axis goes out.
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 0.0}))
assert len(poller.commands) == 2
assert mover.state.step.role == "y"
# y arrives -> the plan is done.
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
assert not mover.running
assert mover.state.finished
def test_the_mover_waits_while_an_axis_is_still_moving():
service = make_service()
poller = FakePoller(service)
mover = PoseMover(service, poller.submit)
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
mover.on_snapshot(snapshot_of(service, {0: 990.0, 1: 0.0}, idle=False))
assert len(poller.commands) == 1
assert mover.state.index == 0
def test_the_mover_fails_when_an_axis_stops_short():
"""A drive that has stopped is not going to arrive: say so, do not wait."""
service = make_service()
poller = FakePoller(service)
mover = PoseMover(service, poller.submit)
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
assert mover.running, "gave up before the stall grace elapsed"
mover.state._idle_since = time.monotonic() - 10.0 # noqa: SLF001
mover.on_snapshot(snapshot_of(service, {0: 10.0, 1: 0.0}))
assert not mover.running
assert "stopped short" in mover.state.failure
assert len(poller.commands) == 1, "a further axis was commanded after the failure"
def test_the_mover_stops_on_an_alarm():
service = make_service()
poller = FakePoller(service)
mover = PoseMover(service, poller.submit)
mover.start(plan_pose_move(service, {"x": 2.0}))
mover.on_snapshot(snapshot_of(service, {0: 0.0}, alarm=True))
assert not mover.running
assert "alarm" in mover.state.failure
def test_a_tolerance_accepts_an_axis_that_reports_its_final_position_late():
service = make_service()
poller = FakePoller(service)
mover = PoseMover(service, poller.submit)
plan = plan_pose_move(service, {"x": 2.0, "y": 1.0})
mover.start(plan)
# 1 unit inside SETTLE_MM of 1000 units for 2 mm.
mover.on_snapshot(snapshot_of(service, {0: 1000.0 - 1.0, 1: 0.0}))
assert len(poller.commands) == 2, "a step within tolerance was treated as a miss"
assert SETTLE_MM > 0 and SETTLE_DEG > 0
def test_stopping_cancels_the_rest_of_the_plan():
"""The stop is a separate command, so the mover must stop queueing first."""
service = make_service()
poller = FakePoller(service)
mover = PoseMover(service, poller.submit)
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
assert mover.running
mover.cancel("stopped")
assert not mover.running
assert mover.state.cancelled
# Nothing further, even if the axis happens to arrive afterwards.
mover.on_snapshot(snapshot_of(service, {0: 1000.0, 1: 500.0}))
assert len(poller.commands) == 1
def test_a_second_plan_cannot_start_on_top_of_a_running_one():
service = make_service()
poller = FakePoller(service)
mover = PoseMover(service, poller.submit)
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
second = mover.start(plan_pose_move(service, {"x": 3.0}))
assert not second.ok
assert len(poller.commands) == 1
def test_the_mover_refuses_a_plan_it_would_not_be_allowed_to_run():
service = MotionService(DemoTransport(), axis_roles=ROLES, limits=LIMITS)
mover = PoseMover(service, FakePoller(service).submit)
result = mover.start(plan_pose_move(service, {"x": 1.0}))
assert not result.ok
assert not mover.running
def test_the_progress_line_names_the_step_it_is_on():
service = make_service()
mover = PoseMover(service, FakePoller(service).submit)
mover.start(plan_pose_move(service, {"x": 2.0, "y": 1.0}))
text = mover.progress_text()
assert "1/2" in text and "x" in text
def test_the_units_round_trip_through_the_service():
"""A target planned in mm must arrive at the drive as the same mm."""
service = make_service()
for role, value in (("x", 3.5), ("y", -2.25), ("yaw", 45.0), ("pitch", -30.0)):
units = service.to_units(role, value)
axis = service.role_axis(role)
if role in ("x", "y"):
assert service.to_mm(axis, units) == pytest.approx(value)
else:
from orisys_autocal.motion import user_units_to_deg
assert user_units_to_deg(axis, units) == pytest.approx(value)
-1
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import numpy as np
import pytest
from orisys_autocal.sensor.export_bundle import (
BundleError,
load_bundle,
-1
View File
@@ -12,7 +12,6 @@ import shutil
from pathlib import Path
import pytest
from orisys_autocal.sequence_io import (
DOCUMENT_KIND,
FORMAT_VERSION,
-1
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import math
import pytest
from orisys_autocal.motion.units import (
PULSES_PER_MM,
ROTARY_UNITS_PER_90_DEG,
-1
View File
@@ -12,7 +12,6 @@ from pathlib import Path
import numpy as np
import pytest
from orisys_autocal.sensor import (
DEFAULT_FLAT_SIZE,
load_bundle,
+158 -59
View File
@@ -25,7 +25,6 @@ from __future__ import annotations
import argparse
import math
import math
import os
import sys
from pathlib import Path
@@ -63,7 +62,10 @@ BRAND_H = 36
STRIP_H = 44
RUNBAR_H = 40
STATUS_H = 26
RAIL_LEFT = 330
#: Matches ``MAIN_SPLITTER_SIZES`` in the window: the rail narrowed when the two
#: start-up panels moved into the device-setup dialog, and the mockup has to show the
#: same proportions or it stops being a preview of anything.
RAIL_LEFT = 300
WORKSPACE_W = PAGE_W - RAIL_LEFT - 3 * MARGIN
PREVIEW_W, PREVIEW_H = 640, 340
@@ -110,6 +112,20 @@ STRINGS = {
"nodes": "节点数量",
"axes_count": "轴数量",
"busdata_group": "总线数据 (SDO / RTEX) ▶",
"setup_open": "设备设置",
"setup_title": "设备设置",
"setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。",
"setup_tabs": ["连接", "总线初始化", "轴映射"],
"setup_rows": [
["X(工作台)", "驱动器 0"],
["Y(工作台)", "驱动器 1"],
["Z(压轴)", "驱动器 4"],
["yaw(回转)", "驱动器 3"],
["pitch(俯仰)", "驱动器 2"],
],
"setup_connect": "连接",
"setup_disconnect": "断开连接",
"setup_close": "关闭",
"tabs": ["轴调试", "测试序列", "手指调试", "传感器", "表面分布", "标定点"],
"readout_title": "轴状态",
"enabled": "已使能",
@@ -188,6 +204,11 @@ STRINGS = {
"finger_preview_title": "三维预览",
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
"finger_play": "播放动作",
"move_execute": "执行动作",
"move_stop": "停止",
"move_return": "回初始位",
"move_confirm": "移动前确认",
"move_status": "1/4 yaw → +36.8160 °(回初始位:先退压轴 Z)",
"finger_preview_notes": [
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
@@ -247,6 +268,20 @@ STRINGS = {
"nodes": "Nodes",
"axes_count": "Axes",
"busdata_group": "Bus data (SDO / RTEX) ▶",
"setup_open": "Device setup",
"setup_title": "Device setup",
"setup_hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result.",
"setup_tabs": ["Connection", "Bus initialisation", "Axis mapping"],
"setup_rows": [
["X (table)", "Drive 0"],
["Y (table)", "Drive 1"],
["Z (press)", "Drive 4"],
["yaw (rotation)", "Drive 3"],
["pitch (tilt)", "Drive 2"],
],
"setup_connect": "Connect",
"setup_disconnect": "Disconnect",
"setup_close": "Close",
"tabs": ["Axis debug", "Test sequence", "Finger debug", "Sensor", "Surface map", "Calibration points"],
"readout_title": "Axis state",
"enabled": "Enabled",
@@ -325,6 +360,11 @@ STRINGS = {
"finger_preview_title": "3D preview",
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
"finger_play": "Play motion",
"move_execute": "Execute",
"move_stop": "Stop",
"move_return": "Return to zero",
"move_confirm": "Confirm before moving",
"move_status": "1/4 yaw → +36.8160° (return to zero retracts Z first)",
"finger_preview_notes": [
"What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.",
"Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).",
@@ -543,13 +583,16 @@ def draw_status_strip(painter: QPainter, palette: Palette, s: dict, top: int) ->
_text(
painter,
QRect(PAGE_W - 600, top, 380, STRIP_H),
QRect(PAGE_W - 600, top, 300, STRIP_H),
"上次动作: 已使能轴0 / last action: enable axis 0",
palette,
size=10,
muted=True,
align=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
)
# The device-setup button, before the run-screen buttons: connection and bus
# initialisation live behind it now instead of in the rail.
_button(painter, QRect(PAGE_W - 316, top + 10, 100, 24), s["setup_open"], palette)
_button(painter, QRect(PAGE_W - 210, top + 10, 96, 24), s["pause"], palette)
_button(painter, QRect(PAGE_W - 106, top + 10, 92, 24), s["stop_all"], palette, danger=True)
@@ -565,53 +608,13 @@ def draw_status_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> N
def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None:
conn = QRect(rect.x(), rect.y(), rect.width(), 210)
inner = _panel(painter, conn, palette)
_box_title(painter, conn, s["conn_title"], palette)
y = inner.y() + 6
_text(painter, QRect(inner.x(), y, 76, 22), s["conn_mode"], palette, size=10, muted=True)
_field(painter, QRect(inner.x() + 78, y, inner.width() - 78, 22), s["conn_mode_value"] + " ▾", palette)
y += 30
_text(painter, QRect(inner.x(), y, 30, 22), s["conn_ip"], palette, size=10, muted=True)
_field(painter, QRect(inner.x() + 32, y, 140, 22), "192.168.0.11", palette)
_button(painter, QRect(inner.x() + 180, y, 88, 22), s["scan"], palette)
y += 32
_button(painter, QRect(inner.x(), y, 104, 26), s["connect"], palette, primary=True)
_button(painter, QRect(inner.x() + 112, y, 108, 26), s["disconnect"], palette)
y += 36
_text(painter, QRect(inner.x(), y, inner.width(), 20), s["connected"], palette, size=10, muted=True)
y += 24
_text(painter, QRect(inner.x(), y, inner.width(), 20), "demo (simulated platform)", palette, size=9, muted=True)
"""The rail as it is now: only what is read while working.
bus = QRect(rect.x(), rect.y() + 222, rect.width(), 250)
inner = _panel(painter, bus, palette)
_box_title(painter, bus, s["bus_title"], palette)
y = inner.y() + 6
_text(painter, QRect(inner.x(), y, 60, 22), s["bus_file"], palette, size=10, muted=True)
_field(painter, QRect(inner.x() + 62, y, 140, 22), "…\\bas\\ECAT.bas", palette)
_button(painter, QRect(inner.x() + 208, y, 66, 22), s["choose"], palette)
y += 30
_text(painter, QRect(inner.x(), y, 74, 20), s["bus_state"], palette, size=10, muted=True)
_text(painter, QRect(inner.x() + 76, y, 180, 20), s["bus_ok"], palette, size=10)
y += 24
_text(painter, QRect(inner.x(), y, 74, 20), s["bus_type"], palette, size=10, muted=True)
_text(painter, QRect(inner.x() + 76, y, 180, 20), s["chip_bus"], palette, size=10)
y += 24
_text(painter, QRect(inner.x(), y, 90, 20), s["nodes"], palette, size=10, muted=True)
_text(painter, QRect(inner.x() + 92, y, 40, 20), "5", palette, size=10)
_text(painter, QRect(inner.x() + 146, y, 70, 20), s["axes_count"], palette, size=10, muted=True)
_text(painter, QRect(inner.x() + 218, y, 40, 20), "5", palette, size=10)
y += 32
_button(painter, QRect(inner.x(), y, 146, 26), s["download"], palette, primary=True)
_button(painter, QRect(inner.x() + 154, y, 104, 26), s["reinit"], palette)
group = QRect(rect.x(), rect.y() + 484, rect.width(), 32)
_panel(painter, group.adjusted(0, 0, 0, -8), palette)
_text(painter, QRect(group.x() + 12, group.y(), group.width() - 24, 30), s["busdata_group"], palette, size=10, muted=True)
# The axis readout lives in this rail now, not in a second one on the right:
# two thin rails cost the workspace 240 px at every window size.
readout = QRect(rect.x(), rect.y() + 526, rect.width(), 268)
Connection and bus initialisation are in the device-setup dialog, so the rail
starts with the axis readout instead of burying it under ~460 px of start-up
controls.
"""
readout = QRect(rect.x(), rect.y(), rect.width(), 268)
inner = _panel(painter, readout, palette)
_box_title(painter, readout, s["readout_title"], palette)
y = inner.y() + 6
@@ -629,7 +632,57 @@ def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) ->
y += 24
_button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette)
_button(painter, QRect(inner.x(), y + 40, inner.width(), 28), s["stop_all"], palette, danger=True)
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["clear_alarms"], palette)
# Returning every drive to zero is a machine-level action: here, in the rail that
# is on screen in every tab, rather than repeated inside a tab.
_button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["move_return"], palette)
_button(painter, QRect(inner.x(), y + 112, inner.width(), 28), s["clear_alarms"], palette)
group = QRect(rect.x(), rect.y() + 280, rect.width(), 32)
_panel(painter, group.adjusted(0, 0, 0, -8), palette)
_text(painter, QRect(group.x() + 12, group.y(), group.width() - 24, 30), s["busdata_group"], palette, size=10, muted=True)
def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None:
"""The device-setup dialog: connection on one tab, bus initialisation on the other.
Drawn as its own panel so the proposal can be reviewed in the same image as the
page it came out of.
"""
dialog = QRect(rect.x(), rect.y(), rect.width(), 400)
inner = _panel(painter, dialog, palette)
_box_title(painter, dialog, s["setup_title"], palette)
_text(
painter,
QRect(inner.x(), inner.y(), inner.width(), 34),
s["setup_hint"],
palette,
size=9,
muted=True,
)
tabs = QRect(inner.x(), inner.y() + 38, inner.width(), inner.height() - 74)
tab_w = 120
for index, label in enumerate(s["setup_tabs"]):
tab = QRect(tabs.x() + index * tab_w, tabs.y(), tab_w, 26)
# The axis mapping is the newest page, and the one worth reviewing.
active = label == s["setup_tabs"][-1]
painter.fillRect(tab, palette.window if active else palette.panel)
painter.setPen(QPen(palette.border, 1))
painter.drawRect(tab.adjusted(0, 0, -1, -1))
if active:
painter.setPen(QPen(palette.accent, 2))
painter.drawLine(tab.bottomLeft(), tab.bottomRight())
_text(painter, tab, label, palette, size=10, bold=active, align=Qt.AlignmentFlag.AlignCenter)
# The mapping page: one row per platform direction, each showing the drive that
# moves it. The values are the bench's own wiring.
body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette)
y = body.y() + 6
for label, value in s["setup_rows"]:
_text(painter, QRect(body.x(), y, 150, 22), label, palette, size=10, muted=True)
_text(painter, QRect(body.x() + 156, y, body.width() - 156, 22), value, palette, size=10)
y += 26
_button(painter, QRect(dialog.right() - 110, dialog.bottom() - 34, 96, 26), s["setup_close"], palette)
def draw_tabs(painter: QPainter, palette: Palette, s: dict, workspace: QRect, active: str) -> QRect:
@@ -1189,9 +1242,25 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5)
_text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True)
play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4)
row_y = view.bottom() + checks_h + 2
play = QRect(inner_preview.x(), row_y, 110, play_h - 4)
_button(painter, play, s["finger_play"], palette, primary=True)
notes_y = play.bottom() + 4
# The pose move: execute the selected point's solved pose, stop, and the policy
# that decides whether either asks first.
execute = QRect(play.right() + 8, row_y, 100, play_h - 4)
_button(painter, execute, s["move_execute"], palette)
stop = QRect(execute.right() + 8, row_y, 84, play_h - 4)
_button(painter, stop, s["move_stop"], palette, danger=True)
confirm = QRect(stop.right() + 10, row_y, inner_preview.width() - stop.right() - 10, play_h - 4)
_text(painter, confirm, "☑ " + s["move_confirm"], palette, size=9, muted=True)
_text(
painter,
QRect(inner_preview.x(), row_y + play_h, inner_preview.width(), 16),
s["move_status"],
palette,
size=9,
)
notes_y = row_y + play_h + 18
for text in s["finger_preview_notes"]:
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
notes_y += 14
@@ -1256,6 +1325,22 @@ TAB_PAINTERS = {
#: Tab index -> painter key, for the tab strip labels.
TAB_KEYS = ("axes", "sequence", "finger", "sensor", "surface", "points")
#: Pages that are not workspace tabs. The device setup is a dialog, so it is drawn as
#: a page of its own: it has to be reviewable like everything else.
EXTRA_PAGES = ("setup",)
def draw_setup_page(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> None:
"""The device-setup dialog over the page it was opened from."""
painter.fillRect(rect, palette.window)
draw_page(painter, palette, s, rect, active="axes", with_chrome=True, with_rail=True)
draw_setup_dialog(
painter,
palette,
s,
QRect(rect.center().x() - 300, rect.center().y() - 200, 600, 400),
)
def draw_page(
painter: QPainter,
@@ -1265,15 +1350,21 @@ def draw_page(
*,
active: str = "axes",
with_chrome: bool = True,
with_rail: bool = True,
) -> None:
"""The whole page: chrome, strip, run bar, rail, workspace, status bar."""
"""The whole page: chrome, strip, run bar, rail, workspace, status bar.
``with_rail`` only exists for the setup page, which draws this page as the
background behind the dialog and does not want to draw the rail twice.
"""
painter.fillRect(rect, palette.window)
body_top = rect.y() + MENU_H + BRAND_H + STRIP_H + RUNBAR_H
body_h = rect.height() - (MENU_H + BRAND_H + STRIP_H + RUNBAR_H) - STATUS_H
draw_left_rail(
painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN)
)
if with_rail:
draw_left_rail(
painter, palette, s, QRect(rect.x() + MARGIN, body_top + MARGIN, RAIL_LEFT, body_h - 2 * MARGIN)
)
workspace = QRect(
rect.x() + 2 * MARGIN + RAIL_LEFT,
@@ -1294,6 +1385,14 @@ def draw_page(
draw_status_bar(painter, palette, s, rect.bottom() - STATUS_H)
def draw_full_page(painter: QPainter, palette: Palette, s: dict, rect: QRect, key: str) -> None:
"""One page of the proposal, by key — a workspace tab or the setup dialog."""
if key in EXTRA_PAGES:
draw_setup_page(painter, palette, s, rect)
return
draw_page(painter, palette, s, rect, active=key)
def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None:
"""The run strip: phase, progress and the three controls."""
painter.fillRect(QRect(0, top, PAGE_W, RUNBAR_H), palette.window)
@@ -1356,7 +1455,7 @@ def main() -> int:
default=TAB_KEYS[0],
# Derived, not listed: a literal here silently rejects any tab added to
# TAB_KEYS, which is how the finger page first went missing.
choices=(*TAB_KEYS, "all"),
choices=(*TAB_KEYS, *EXTRA_PAGES, "all"),
help="which page to draw; 'all' writes one full-size image per page",
)
args = parser.parse_args()
@@ -1367,7 +1466,7 @@ def main() -> int:
base = Path(args.out).resolve()
base.parent.mkdir(parents=True, exist_ok=True)
pages = TAB_KEYS if args.page == "all" else (args.page,)
pages = (*TAB_KEYS, *EXTRA_PAGES) if args.page == "all" else (args.page,)
failures = 0
for key in pages:
target = base if args.page != "all" else base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}")
@@ -1376,7 +1475,7 @@ def main() -> int:
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
try:
draw_page(painter, palette, s, QRect(0, 0, args.width, args.height), active=key)
draw_full_page(painter, palette, s, QRect(0, 0, args.width, args.height), key)
finally:
painter.end()
if pixmap.save(str(target)):
+311 -28
View File
@@ -76,16 +76,14 @@ def _visible_descendants(widget):
def main() -> int:
from PySide6 import __version__ as pyside_version
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.theme import build_qss, load_colors
from orisys_autocal.motion import ConnectionSpec
from orisys_autocal.ui.axis_panel import COLUMNS
from orisys_autocal.ui.bus_panel import default_bas_dir
from orisys_autocal.ui.window import AutoCalMainWindow
from PySide6 import __version__ as pyside_version
from PySide6.QtWidgets import QApplication
checker = Checker()
app = QApplication.instance() or QApplication([])
@@ -115,7 +113,14 @@ def main() -> int:
set_locale("zh_CN")
# ---- window ----------------------------------------------------------
from orisys_autocal.motion import DemoTransport
import math
from orisys_autocal.motion import (
DemoTransport,
plan_pose_move,
plan_return_to_initial,
user_units_to_deg,
)
for theme in ("dark", "light"):
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
@@ -132,28 +137,30 @@ def main() -> int:
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
oversize = []
for child in _visible_descendants(window):
if _inside_scroll_area(child):
continue # a rail's content is meant to exceed its viewport
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if (
geometry.width() > parent.width() + 1
or geometry.height() > parent.height() + 1
):
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > {parent.width()}x{parent.height()}"
)
checker.that(
not oversize, f"{theme} {width}x{height} tab={key}: {oversize}"
not _overflowing(window),
f"{theme} {width}x{height} tab={key}: {_overflowing(window)}",
)
window.workspace.set_current_key("axes")
print(f" {width}x{height}: every tab fits")
# The device setup is a separate top-level window now, so the sweep has
# to be run over it as well: a dialog whose own controls do not fit is
# exactly the defect the sweep exists for, one window further out.
window._show_device_setup() # noqa: SLF001 - the documented entry point
app.processEvents()
for tab in ("connection", "bus"):
window.device_setup.select_tab(tab)
app.processEvents()
checker.that(
not _overflowing(window.device_setup),
f"{theme} {width}x{height} setup tab={tab}: "
f"{_overflowing(window.device_setup)}",
)
window.device_setup.close()
app.processEvents()
print(f" {width}x{height}: the device setup fits")
# ---- the permanent shell ----------------------------------------
checker.that(
window.workspace.page_keys()
@@ -243,6 +250,102 @@ def main() -> int:
window.left_rail.widget() is window.rail_content,
"the setup rail is not scrollable",
)
# The rail carries what is read while working. Connection and bus
# initialisation moved into the device-setup dialog: back in the rail they
# pushed the axis readout below the fold.
rail_children = {
type(child).__name__ for child in window.rail_content.findChildren(object)
}
checker.that(
"AxisReadoutPanel" in rail_children,
f"the axis readout is not in the rail: {sorted(rail_children)}",
)
checker.that(
"BusDataPanel" in rail_children,
"the bus-data monitor is not in the rail",
)
checker.that(
"ConnectionPanel" not in rail_children and "BusPanel" not in rail_children,
f"a start-up panel is still in the rail: {sorted(rail_children)}",
)
checker.that(
not window.device_setup.isVisible(),
"the device setup is open before anyone asked for it",
)
# Where the three move controls live, and why. Execute is about the selected
# point, so it belongs to the finger screen; the stop is repeated next to it
# *and* stays in the strip, because an emergency stop has to be where the
# operator's eyes are; returning to zero is a machine action wanted after any
# tab's work, so it lives in the always-visible rail and is not duplicated.
checker.that(
window.finger_panel.execute_button.parent() is not None
and window.finger_panel.stop_button.parent() is not None,
"the finger screen is missing an execute or stop button",
)
checker.that(
window.status_strip.stop_button.isEnabled() is not None,
"the strip has no stop button",
)
checker.that(
window.axis_readout.return_button.text() == t("move.return_initial"),
f"the rail return button says "
f"{window.axis_readout.return_button.text()!r}",
)
checker.that(
window.axis_readout.return_button.parent() is window.axis_readout,
"the return button is not in the always-visible rail",
)
# The mapping page is the third tab of the device setup, and the service
# already holds the mapping it shows.
checker.that(
window.device_setup.tabs.count() == 3,
f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3",
)
checker.that(
window.device_setup.tabs.tabText(2) == t("setup.tab_axes"),
f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}",
)
# With no controller, a move must be refused with a reason and the execute
# button must say so rather than appearing to work.
disconnected = window.poller.service.check_axes_move({"x": 1.0})
checker.that(
disconnected is not None,
"a move was allowed with no controller connected",
)
checker.that(
not window.finger_panel.execute_button.isEnabled(),
"execute is enabled with no controller connected",
)
checker.that(
window.device_setup.connection_panel is window.connection_panel
and window.device_setup.bus_panel is window.bus_panel,
"the window's panel references are not the ones in the dialog",
)
# Three ways in, all of them the operator's, all landing on one method.
window.status_strip.setup_button.click()
app.processEvents()
checker.that(
window.device_setup.isVisible(),
"the setup button did not open the device setup",
)
window.device_setup.close()
window.status_strip.chip_bus.clicked.emit()
app.processEvents()
checker.that(
window.device_setup.isVisible()
and window.device_setup.current_tab() == "bus",
"the bus chip did not open the bus tab",
)
window.device_setup.close()
window.brand_bar.connectionChipClicked.emit()
app.processEvents()
checker.that(
window.device_setup.isVisible()
and window.device_setup.current_tab() == "connection",
"the brand-bar chip did not open the connection tab",
)
window.device_setup.close()
app.processEvents()
checker.that(
window.run_bar.isVisible(),
"the run bar is not visible",
@@ -347,14 +450,14 @@ def main() -> int:
# A full simulated session: connect, initialise from the shipped BAS.
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
_wait_for(app, lambda w=window: w._last_snapshot.connected, 6.0)
checker.that(
window._last_snapshot.connected, "the simulated controller did not connect"
)
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
window._on_download_bas()
ok = _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
ok = _wait_for(app, lambda w=window: w._last_snapshot.bus.init_status == 1, 8.0)
checker.that(ok, "the simulated bus did not report initialised")
if ok:
checker.that(
@@ -399,12 +502,167 @@ def main() -> int:
or "5" in strip.chip_axes.text(),
f"strip axis chip says {strip.chip_axes.text()!r}",
)
checker.that(
window.axis_readout._rows[0]["position"].text().startswith("0.000"),
f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}",
)
window.workspace.set_current_key("axes")
# ---- the pose move ------------------------------------------------
# The whole path end to end, on the simulated controller: solve a pose
# from a picked point, plan it, run it, and check the drives arrived.
# Nothing else exercises the order, the units and the guards together.
window.workspace.set_current_key("finger")
app.processEvents()
finger = window.finger_panel
checker.that(
finger.execute_button.isEnabled(),
"execute is disabled on a connected, initialised platform",
)
checker.that(
finger.stop_button.isEnabled(),
"the local stop is disabled while the platform may move",
)
# A point to solve: pick the centre of the flat image, which is the
# operator's own gesture, so the pose the check plans is a real one.
if not finger.points():
view = finger.view
window._on_pick_point(view.width() // 2, view.height() // 2) # noqa: SLF001
app.processEvents()
checker.that(
bool(finger.points()),
"picking the middle of the patch produced no point",
)
solve = finger.solved_pose()
checker.that(
solve is not None,
"the finger screen solved no pose from its point list",
)
if solve is not None:
targets, held = solve
checker.that(
"z" in held and "z" not in targets,
f"execute does not hold the press axis back: "
f"{sorted(targets)} held={held}",
)
plan = plan_pose_move(window.poller.service, targets, hold=held)
checker.that(
[step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"],
f"the move order is {[step.role for step in plan.steps]}",
)
checker.that(
all(math.isfinite(step.units) for step in plan.steps),
"a planned step does not convert to user units",
)
# Straight to the mover: the confirmation box is the operator's, and
# a modal dialog in a headless check is a hang (it has been one).
result = window.mover.start(plan)
checker.that(result.ok, f"the mover refused the plan: {result.message}")
moved = _wait_for(app, lambda w=window: not w.mover.running, 25.0)
checker.that(moved, "the pose move did not finish")
checker.that(
not window.mover.state.failure,
f"the pose move failed: {window.mover.state.failure}",
)
for step in plan.steps:
state = window._last_snapshot.axis(step.axis)
if state is None:
continue
if step.unit == "mm":
actual = window.poller.service.to_mm(step.axis, state.dpos)
else:
actual = user_units_to_deg(step.axis, state.dpos)
checker.that(
abs(actual - step.target) <= 0.05,
f"{step.role} ended at {actual:.4f}{step.unit}, "
f"target {step.target:.4f}{step.unit}",
)
print(
" move: "
+ ", ".join(f"{s.role}={s.target:+.3f}{s.unit}" for s in plan.steps)
+ " reached"
)
# Returning to the initial state, press axis first.
back = plan_return_to_initial(window.poller.service)
checker.that(
[step.role for step in back.steps]
== ["z", "pitch", "yaw", "x", "y"],
f"the return order is {[step.role for step in back.steps]}",
)
result = window.mover.start(back)
checker.that(result.ok, f"the return was refused: {result.message}")
returned = _wait_for(app, lambda w=window: not w.mover.running, 30.0)
checker.that(
returned and not window.mover.state.failure,
f"the return did not finish: {window.mover.state.failure}",
)
zeroed = [
window._last_snapshot.axis(step.axis).dpos
for step in back.steps
if window._last_snapshot.axis(step.axis) is not None
]
checker.that(
all(abs(value) <= 1.0 for value in zeroed),
f"the axes did not return to zero: {zeroed}",
)
print(" move: every axis returned to zero")
# Stop cancels the queue: no further axis may be commanded.
window.mover.start(plan)
window._on_stop_all()
app.processEvents()
checker.that(not window.mover.running, "stop left the move queued")
checker.that(
window.mover.state.cancelled,
"stop did not mark the move cancelled",
)
print(" move: stop cancels the queue")
# The guards, on the same connected session: a mapping that claims one
# drive twice must refuse motion rather than quietly pick a role.
service = window.poller.service
saved = service.axis_roles
service.set_axis_roles({"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4})
refusal = service.check_axes_move({"x": 1.0, "y": 1.0})
checker.that(
refusal is not None,
f"a duplicated drive was not refused: {refusal}",
)
service.set_axis_roles(saved)
refusal = service.check_axes_move({"x": 500.0})
checker.that(
refusal is not None and "500" in refusal.message,
f"a target past the travel limit was not refused: {refusal}",
)
refusal = service.check_axes_move({"yaw": 400.0})
checker.that(
refusal is not None,
f"a yaw past the stage envelope was not refused: {refusal}",
)
# The mapping page must agree with the service it feeds.
page = window.device_setup.roles_page
checker.that(
page.roles() == saved,
f"the mapping page shows {page.roles()}, service holds {saved}",
)
page.combos["y"].setCurrentIndex(page.combos["y"].findData(0))
app.processEvents()
checker.that(
bool(page.problem()), "the mapping page accepted two roles on one drive"
)
checker.that(
service.roles_problem() != "",
"the service did not pick up the duplicated mapping",
)
page.combos["y"].setCurrentIndex(page.combos["y"].findData(1))
app.processEvents()
checker.that(
page.problem() == "" and service.roles_problem() == "",
"correcting the mapping did not clear the refusal",
)
print(" move: guards refuse a duplicate mapping and an out-of-range target")
window.workspace.set_current_key("axes")
app.processEvents()
try:
window.poller.shutdown()
except Exception: # noqa: BLE001 - a shutdown must not mask the result
@@ -415,6 +673,31 @@ def main() -> int:
return checker.report()
def _overflowing(root) -> list[str]:
"""Visible descendants that are larger than the widget that contains them.
Scroll-area content is exempt: a rail's content is meant to exceed its viewport.
Everything else that does not fit its parent is a clipped control.
"""
oversize = []
for child in _visible_descendants(root):
if _inside_scroll_area(child):
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if (
geometry.width() > parent.width() + 1
or geometry.height() > parent.height() + 1
):
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > {parent.width()}x{parent.height()}"
)
return oversize
def _inside_scroll_area(widget) -> bool:
"""Whether ``widget`` is scroll-area content rather than a laid-out child."""
from PySide6.QtWidgets import QAbstractScrollArea
+26 -3
View File
@@ -50,18 +50,22 @@ def main() -> int:
action="store_true",
help="drive a simulated session so the shot shows live values",
)
parser.add_argument(
"--setup-dialog",
default="",
choices=("", "connection", "bus", "axes"),
help="photograph the device-setup dialog on this tab instead of the window",
)
args = parser.parse_args()
width, height = (int(part) for part in args.size.lower().split("x"))
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
from orisys_autocal.motion import ConnectionSpec, DemoTransport
from orisys_autocal.preferences import load_preferences
from orisys_autocal.ui.bus_panel import default_bas_dir
from orisys_autocal.ui.window import AutoCalMainWindow
from PySide6.QtWidgets import QApplication
set_locale(args.language)
app = QApplication.instance() or QApplication([])
@@ -89,6 +93,25 @@ def main() -> int:
window._on_move_abs(0, 1000.0)
_wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.0)
if args.setup_dialog:
# The dialog is a separate top-level window, so it is grabbed on its own
# rather than inside the page shot.
window._show_device_setup(args.setup_dialog) # noqa: SLF001
app.processEvents()
output = Path(args.out).resolve()
target = window.device_setup
if not target.grab().save(str(output)):
print(f"could not write {output}", file=sys.stderr)
return 1
print(
f"wrote {output} ({target.width()}x{target.height()}, theme={args.theme}, "
f"tab={args.setup_dialog})"
)
target.close()
window.close()
app.processEvents()
return 0
app.processEvents()
if args.compare:
+1 -4
View File
@@ -96,14 +96,12 @@ def main() -> int:
)
args = parser.parse_args()
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
from orisys_autocal.preferences import load_preferences
from orisys_autocal.ui.pose_preview import configure_compatible_gl
from orisys_autocal.ui.window import AutoCalMainWindow
from PySide6.QtWidgets import QApplication
width, height = (int(part) for part in args.size.lower().split("x"))
@@ -223,7 +221,6 @@ def main() -> int:
# pixels, because a projection that disagrees with the render looks
# perfectly healthy on its own — that is how the labels drifted off
# their arrows and the scene sat off to one side without a word.
from orisys_autocal.ui.pose_preview import COLOUR
for label, point, colour in _landmarks(panel):
screen = panel.preview.project(point)
+21
View File
@@ -46,6 +46,9 @@
"stop": "Cancel acquisition",
"preview": "Preview / Stop preview",
"reset": "Reset tracker",
"reset_view": "Reset views",
"reset_view_tip": "Put the chart window, cursor and 3D camera back to their defaults (F8). Touches neither the device nor the zero estimate.",
"pause": "Pause / resume (F7)",
"help": "Help",
"about": "About"
},
@@ -53,14 +56,22 @@
"plot_settings_title": "Plot settings",
"plot_window": "Time window",
"plot_window_tip": "Force plot time window (seconds)",
"plot_fn_min": "FN min",
"plot_fn_min_tip": "Normal-force axis minimum (Newton mode). It may be negative: leaving room below zero keeps the idle curve off the FX frame and makes small presses visible",
"plot_fn_max": "FN max",
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"plot_fn_raw_min": "FN raw min",
"plot_fn_raw_min_tip": "Normal-force axis minimum in raw (uncalibrated) mode; may be negative",
"plot_fn_raw_max": "FN raw max",
"plot_fn_raw_max_tip": "Normal-force axis max in raw (uncalibrated) mode. 0 = auto range on the data; the default is sized so idle jitter is invisible in the plot",
"plot_shear_raw_max": "Shear raw max",
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
"plot_deadband_n": "Zero threshold (N)",
"plot_deadband_n_tip": "Display deadband floor in Newton mode: a reading inside the threshold shows as 0 and the curve flattens on the zero line. 0 = auto (the noise estimated from the quiet startup window; Newton mode has no fixed floor). Display only — the record and forces.csv keep the measured values",
"plot_deadband_raw": "Zero threshold (raw)",
"plot_deadband_raw_tip": "Display deadband floor in raw mode (raw counts). 0 = auto (the built-in 50-count floor). Raise it by hand when the calibration data is suspect; display only",
"reset_defaults": "Reset to defaults",
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
"filter_preset": "Preset",
@@ -115,6 +126,13 @@
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video",
"playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)",
"reset": "Reset",
"reset_tip": "Zero the device tracker. This affects the zero point only, never the view — to put the views back use View → Reset views.",
"pause": "⏸ Pause",
"pause_resume": "▶",
"pause_tip": "Pause the computation and the display (F7): no flow, depth or contact is computed, the picture holds its last frame, and the device stays connected for an immediate resume. A recording that spans a pause has a gap.",
"resume_tip": "Resume the computation and the display (F7), from the newest frame.",
"resume_label": "▶ Resume",
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
"stop_playback": "Stop",
"stop_playback_tip": "Stop the replay: plain playback and a “recompute from the video” pass both stop here (Esc does the same).",
"stopped": "Stopped: {what}",
@@ -400,6 +418,9 @@
"camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset",
"reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…",
"view_reset": "Views reset",
"paused": "Paused: nothing is computed, the device stays connected",
"resumed": "Resumed",
"finger_run_unavailable": "Run is not available for this sensor type",
"video_required": "Select a video file first",
"config_missing": "Sensor config not found for {type}",
+22 -1
View File
@@ -46,6 +46,9 @@
"stop": "取消采集",
"preview": "预览 / 停止预览",
"reset": "重置追踪器",
"reset_view": "复位视图",
"reset_view_tip": "把曲线窗口、游标与三维视角恢复为默认(F8)。不改动设备,也不清除零点估计。",
"pause": "暂停 / 继续(F7)",
"help": "帮助",
"about": "关于"
},
@@ -53,14 +56,22 @@
"plot_settings_title": "绘图设置",
"plot_window": "时间窗口",
"plot_window_tip": "力曲线时间窗口(秒)",
"plot_fn_min": "FN 下限",
"plot_fn_min_tip": "法向力纵轴下限(牛顿模式)。可以是负数:留出零点以下的空间,静止曲线不会压在 FX 框线上,小压力也更清楚",
"plot_fn_max": "FN 上限",
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"plot_fn_raw_min": "FN 原始下限",
"plot_fn_raw_min_tip": "未标定(原始)模式下的法向力纵轴下限,可为负",
"plot_fn_raw_max": "FN 原始上限",
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
"plot_shear_raw_max": "原始剪切上限",
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
"plot_deadband_n": "归零阈值(N)",
"plot_deadband_n_tip": "牛顿模式下的显示死区下限:读数在阈值内即显示为 0,曲线压平在零线上。0 = 自动(用启动静默窗口估计的噪声,牛顿模式无固定下限)。只影响显示,记录与 forces.csv 仍是实测值",
"plot_deadband_raw": "归零阈值(原始)",
"plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示",
"reset_defaults": "恢复默认",
"plot_filters": "滤波(作用于采集,界面与记录同步)",
"filter_preset": "预设",
@@ -107,7 +118,7 @@
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_disabled_sensor": "此传感器类型暂不支持运行",
"playback": "▶ 回放",
"playback": "▶ 播放",
"playback_pause": "⏸ 暂停",
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
@@ -115,6 +126,13 @@
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值",
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
"reset": "重置",
"reset_tip": "把设备追踪器归零(清零)。只影响零点,不动界面;想恢复视图请用「视图 → 复位视图」。",
"pause": "⏸ 暂停",
"pause_resume": "▶",
"pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。",
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
"resume_label": "▶ 继续",
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
"stop_playback": "停止",
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。",
"stopped": "已停止:{what}",
@@ -400,6 +418,9 @@
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"reset_pending": "已请求归零,等待采集线程确认…",
"view_reset": "视图已复位",
"paused": "已暂停:不再计算,设备保持连接",
"resumed": "已继续",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",
+5 -1
View File
@@ -15,17 +15,21 @@
"scene3d_display": {
"deform_enabled": false,
"display_gain": 0.0,
"housing_visible": true,
"housing_visible": false,
"max_fps": 30
},
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_min": 0.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"plot_fn_raw_min": 0.0,
"plot_fn_raw_max": 1000000.0,
"plot_shear_raw_max": 500000.0,
"plot_deadband_n": 0.0,
"plot_deadband_raw": 0.0,
"image_view_mode": "arrows_pure",
"arrow_rotation_deg": 90,
"settings_expanded": false,
@@ -0,0 +1,44 @@
{
"name": "mems-prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365, 263, 123, 63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1, 2, 3, 6, 7, 8, 11, 12, 13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3, 3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend": "numba",
"blend_list": ["numpy", "numba", "none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 5,
"div_threshold": 0.05,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
@@ -6,7 +6,7 @@
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"source_scale": 0.42,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
+4 -2
View File
@@ -218,8 +218,10 @@ outputs/recordings/<UTC 时间戳>_session<NNNN>/
## 7. 第一、二阶段评审 P1/P2 的整改(数据语义、终态、标定身份、深度重算)
评审脚本 `D:/orisys_ws/_review_phase12/reproduce.py` 的十条复现条件已全部翻转,改后
输出见同目录 `reproduce_after.log`。以下按发现逐条说明改法。
评审提出的十条复现条件已全部翻转。**原评审脚本
`D:/orisys_ws/_review_phase12/reproduce.py` 与其 `reproduce_after.log` 属于一次性工作区临时物,
已在 2026-09-27 的工作区整理中删除**;十条条件与整改要求逐条记录在归档的评审正文
`D:\orisys_ws\docs\archive\reviews\phase12-review.md`("发现与整改条件"一节)。以下按发现逐条说明改法。
### 7.1 记录语义(P1-1)
+286 -72
View File
@@ -9,27 +9,13 @@ from pathlib import Path
from typing import Any
import numpy as np
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.acquisition.image_backend import (
DEPTH,
SOURCE_LIVE,
SOURCE_PREVIEW,
SOURCE_RECOMPUTE,
DomainMaskLatch,
ImageFrame,
plan_image,
render_image,
@@ -38,24 +24,33 @@ from orisys_app_shared.force import select_display_forces
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
from orisys_app_shared.force.filters import FilterSettings
from orisys_app_shared.force.session import SessionStore
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
from orisys_app_shared.prefs import save_preferences
from orisys_app_shared.record import RecordConfig
from orisys_app_shared.record.sample import to_record_sample
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup, QIcon
from PySide6.QtWidgets import (
QApplication,
QMainWindow,
QMessageBox,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
)
from orisys_studio import session_metadata
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.display_prefs import DisplayPrefsController
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
from orisys_studio.replay import ReplayEngine
from orisys_studio.replay_controller import ReplayController
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.display_prefs import DisplayPrefsController
from orisys_studio.view_layout import ViewLayoutController
from orisys_studio import session_metadata
from orisys_studio.session_controller import SessionController
from orisys_studio.scene3d.prefs import settings_to_prefs
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
from orisys_app_shared.prefs import save_preferences
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
from orisys_studio.session_controller import SessionController
from orisys_studio.ui import record_status as record_states
from orisys_studio.ui.about_dialog import AboutDialog
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
@@ -73,11 +68,16 @@ from orisys_studio.ui.plot_settings_dialog import (
)
from orisys_studio.ui.sensor_picker import SensorPickerWidget
from orisys_studio.ui.toolbar import ToolbarWidget
from orisys_studio.view_layout import ViewLayoutController
logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
#: Types the Run button accepts. ``finger-mems`` is a studio-side product (deprecated in the
#: SDK) that reuses the box/mini pipeline, so it is runnable exactly like box.
RUNNABLE_TYPES = frozenset({"box", "mini", "finger", "finger-mems"})
#: Types whose 2D arrow view is rotated 90° (the sensor is mounted that way). finger-mems is
#: a box-style stitched camera, so it rotates like box until the device says otherwise.
ARROW_ROTATE_TYPES = frozenset({"box", "mini", "finger-mems"})
DEFAULT_SPLITTER_SIZES = (620, 320, 500)
MIN_PANEL_WIDTHS = {
# The 3D pane used to be the one panel whose content was a stack of form fields, so
@@ -204,6 +204,10 @@ class StudioMainWindow(QMainWindow):
self.session.tick.connect(self._on_run_tick)
# The phase is the session's, not the window's; the chip is one view of it.
self.session.phaseChanged.connect(self._on_session_phase_changed)
#: True while the operator has paused the live session. The acquisition thread
#: stops computing; this flag stops the *display* half of the run tick, so a
#: frame that was already in flight cannot repaint the panels they paused on.
self._display_paused = False
self._active_calibration_id = ""
#: Last display-unit decision (``None`` before the first sample): the
#: status rail's calibration readout is derived from it, because an axis
@@ -214,6 +218,11 @@ class StudioMainWindow(QMainWindow):
self._last_calibration_export = ""
self._depth_range_value: tuple[float, float] | None = None
self._last_depth_frame = None
#: The 2D domain boundary, latched for the session the way the depth range is:
#: the island is the device's own geometry and is rebuilt from the same remap
#: every frame, so re-deriving the edge per frame redrew a boundary that had not
#: moved (see ``image_backend.DomainMaskLatch``).
self._domain_mask = DomainMaskLatch()
icon = QIcon(str(logo_path()))
if not icon.isNull():
@@ -240,6 +249,11 @@ class StudioMainWindow(QMainWindow):
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
# The island latch is *upstream of both views*: the 2D canvas paints the
# latched boundary, and the 3D pane's validity texture is that same mask. Two
# views, one decision about where the measurement is — see
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
self.mesh3d_panel.set_domain_latch(self._domain_mask)
self.arrow_panel = ArrowPanel(theme=self._theme)
self.force_plots = ForcePlotsPanel(theme=self._theme)
# The charts browse the whole session, not just their rolling buffer.
@@ -738,6 +752,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.replay_video_clicked.connect(self.replay_ctl.on_open_recording)
self.toolbar.recompute_clicked.connect(self.replay_ctl.on_recompute_button)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.stop_clicked.connect(self._on_stop_clicked)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self.display_ctl.on_units_changed)
@@ -974,6 +989,22 @@ class StudioMainWindow(QMainWindow):
self.act_reset.setShortcut("Ctrl+R")
self.act_reset.triggered.connect(self._on_reset)
self.view_menu.addAction(self.act_reset)
# The graphics half of the old 重置: curves, window, cursor and the 3D camera.
# Separate because wanting a fresh device baseline is not the same intention as
# wanting the views back, and only the first one belongs to the device.
self.act_reset_view = QAction(self)
self.act_reset_view.setShortcut("F8")
self.act_reset_view.triggered.connect(self._on_reset_view)
self.view_menu.addAction(self.act_reset_view)
# Pause has a shortcut as well as a rail button: an operator who is holding a
# sample still wants to stop the numbers moving without reaching for the mouse.
self.act_pause = QAction(self)
self.act_pause.setShortcut("F7")
self.act_pause.setCheckable(True)
self.act_pause.toggled.connect(self._on_pause_action_toggled)
self.view_menu.addAction(self.act_pause)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
@@ -1111,6 +1142,12 @@ class StudioMainWindow(QMainWindow):
return self.layout_ctl.view_preset
def _update_run_enabled(self) -> None:
"""What the rail's transport, stop and reset can do in the current view.
One place decides it for both views: the rail's buttons mean the same
things there (the transport is a play/pause toggle, stop ends what is
running), so a second copy of the rule is a second thing to keep in step.
"""
if self.toolbar.run_role() == "playback":
# In the replay view the button is a transport: it needs something to
# play — a recording, or a bare video — not a runnable sensor. While it
@@ -1131,14 +1168,16 @@ class StudioMainWindow(QMainWindow):
reason = t("toolbar.playback_disabled")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(loaded or self.replay_ctl.is_playing())
# The stop button is offered whenever there is something to stop; the
# operator must never have to guess whether that is the case.
if self.toolbar.reset_role() == "stop":
self.toolbar.set_reset_enabled(
self.replay_ctl.is_playing()
or self._recompute_running
or self._session_active
)
# Stop leaves the replay, whatever it is doing: plain playback, a
# recompute pass, or a session the pass started. The operator must never
# have to work out which of those is stuck before they can stop it.
self.toolbar.set_stop_enabled(
self.replay_ctl.is_playing()
or self._recompute_running
or self._session_active
)
# The tracker reset belongs to a device session; this view has none.
self.toolbar.set_reset_enabled(False)
self.toolbar.set_record_enabled(
self._running or self.recorder.is_recording
)
@@ -1155,10 +1194,16 @@ class StudioMainWindow(QMainWindow):
source_ok = False
reason = t("toolbar.run_disabled_video")
self.toolbar.set_run_tooltip_reason(reason)
# While a session exists the transport stays enabled — pausing and resuming
# are the whole point of it being checked, not an exception to an idle rule.
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
# Recording needs a live session; while recording the button stays
# enabled so it can always be stopped.
self.toolbar.set_record_enabled(self._running or self.recorder.is_recording)
# Stop releases the device and ends the session. It covers a device open
# that hangs as well, so it is offered for as long as a session exists at
# all — including the moment the worker is still winding down.
self.toolbar.set_stop_enabled(self._session_active)
if not self._running:
self.toolbar.refresh_labels()
self._sync_shortcut_actions()
@@ -1189,6 +1234,10 @@ class StudioMainWindow(QMainWindow):
self.act_stop.setText(t("menu.stop"))
self.act_preview.setText(t("menu.preview"))
self.act_reset.setText(t("menu.reset"))
self.act_reset_view.setText(t("menu.reset_view"))
self.act_reset_view.setToolTip(t("menu.reset_view_tip"))
self.act_pause.setText(t("menu.pause"))
self._sync_pause_action()
self.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light"))
@@ -1494,10 +1543,17 @@ class StudioMainWindow(QMainWindow):
dialog = PlotSettingsDialog(
self,
window_sec=settings["window_sec"],
# FN's lower bound may be negative: that is the "show me below zero" the
# operator asked for, and it is what puts the 0 tick back on the axis.
fn_min=settings["fn_min"],
fn_max=settings["fn_max"],
shear_max=settings["shear_max"],
fn_raw_min=settings["fn_raw_min"],
fn_raw_max=settings["fn_raw_max"],
shear_raw_max=settings["shear_raw_max"],
# Display-only zeroing floors; 0 means "use the estimated band".
deadband_n=settings["deadband_n"],
deadband_raw=settings["deadband_raw"],
units=settings["units"],
filters=self._filter_settings(),
# Asked *now*: the answer is a property of this moment, and the
@@ -1507,16 +1563,9 @@ class StudioMainWindow(QMainWindow):
)
if dialog.exec() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.display_ctl.apply_plot_settings(
{
"window_sec": window_sec,
"fn_max": fn_max,
"shear_max": shear_max,
"fn_raw_max": fn_raw_max,
"shear_raw_max": shear_raw_max,
}
)
# One call for the whole picture: the dialog owns the schema, the controller
# writes what it was given and re-reads the rest.
self.display_ctl.apply_plot_settings(dialog.settings())
filters = dialog.filter_values()
# The same keys ``_filter_settings()`` reads back, so what the dialog
# shows next time is what the session will actually be given.
@@ -1718,20 +1767,43 @@ class StudioMainWindow(QMainWindow):
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
# The menu's Stop mirrors the rail's Stop button, not the acquisition
# session alone: a replay or a recompute pass is something to stop.
#
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
# did not) and never delivers the shortcut of a disabled action, so a Stop that
# is disabled while a replay is playing is a keyboard escape hatch that does not
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
self.act_stop.setEnabled(
self._session_active
or self.replay_ctl.is_playing()
or self._recompute_running
)
self.act_stop.setEnabled(self.toolbar.btn_stop.isEnabled())
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
# A live pause is only meaningful while the pipeline is running; in the replay
# view its own transport owns play/pause.
self.act_pause.setEnabled(
self.toolbar.run_role() != "playback" and self._running
)
self.act_pause.setToolTip(
t("toolbar.resume_tip") if self.act_pause.isChecked() else t("toolbar.pause_tip")
)
def _sync_pause_action(self) -> None:
"""The View-menu action follows the session; it never leads it.
The rail's transport owns the pause state (checked = acquiring and not
paused, unchecked with a session = paused), so the action is a mirror and a
shortcut into it — two controls that cannot disagree.
"""
action = getattr(self, "act_pause", None)
if action is None:
return
paused = bool(self._running and self._display_paused)
if action.isChecked() != paused:
blocked = action.blockSignals(True)
action.setChecked(paused)
action.blockSignals(blocked)
action.setToolTip(
t("toolbar.resume_tip") if paused else t("toolbar.pause_tip")
)
def _on_play_shortcut(self) -> None:
"""Space plays or pauses the recording, if there is one to move through."""
@@ -1753,6 +1825,14 @@ class StudioMainWindow(QMainWindow):
``_sync_shortcut_actions`` enables the action for, so the action can never be
enabled with nothing behind it.
"""
self._stop_activity()
def _stop_activity(self) -> None:
"""End whatever this view is doing. The rail's Stop and Esc share this body.
One implementation, because the button and the shortcut are the same act:
two copies would be two answers to "what does Stop stop".
"""
if self.toolbar.run_role() == "playback":
if (
self.replay_ctl.is_playing()
@@ -1765,6 +1845,10 @@ class StudioMainWindow(QMainWindow):
return
self._stop_run(reason="user_stop")
def _on_stop_clicked(self) -> None:
"""The rail's Stop button — the same escape hatch Esc reaches."""
self._stop_activity()
def _on_preview_shortcut(self) -> None:
if not self.toolbar.btn_preview.isEnabled():
return
@@ -1876,14 +1960,28 @@ class StudioMainWindow(QMainWindow):
self._show_image_frame(ImageFrame(frame, source=SOURCE_PREVIEW))
def _on_run_toggled(self, checked: bool) -> None:
"""The rail's transport. One button, three states, routed by the session.
``checked`` means "acquiring and not paused" in the live view and "playing"
in the replay view. Unchecked is ambiguous on its own — it is *idle* when no
session exists and *paused* when one does — so whether a session is already
running is what decides between starting and resuming, and between pausing
and doing nothing. Stopping is never this button's job: it has its own.
"""
if self.toolbar.run_role() == "playback":
# The primary button of the replay view is its transport.
self.replay_ctl.on_transport_toggled(bool(checked))
return
if checked:
self._start_run()
else:
self._stop_run(reason="user_stop")
if self._running:
# A live session already exists and the button was unchecked: that
# is the paused state, so checking it again means resume.
self._on_pause_toggled(False)
else:
self._start_run()
return
if self._running:
self._on_pause_toggled(True)
def _start_run(self) -> None:
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
@@ -1919,11 +2017,11 @@ class StudioMainWindow(QMainWindow):
self.force_plots.configure_presentation(
sensor_type=sensor_type,
unit=units,
enabled=(
sensor_type == "finger"
and units == "raw"
and bool(self.prefs.get("raw_auto_zero", True))
),
# The operator's own switch, and only that: the aid covers every sensor type and
# both units, which is the point of it (an offset reads as an offset in newtons
# too). This used to be a ``finger and raw`` conjunction, which silently disabled
# it for every other product.
enabled=bool(self.prefs.get("raw_auto_zero", True)),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
@@ -1936,6 +2034,10 @@ class StudioMainWindow(QMainWindow):
self._last_display_available = None
self._depth_range_value = None
self._last_depth_frame = None
# The new session has not established a domain boundary yet; the previous
# session's island must not survive into it (a different sensor is a different
# island, and the latch compares against what it holds).
self._domain_mask.reset()
# The session's own bookkeeping (its counter and telemetry) is the
# controller's: telling it a run starts is what drops the previous run's
# numbers, so none of them can be read as this run's.
@@ -1975,6 +2077,12 @@ class StudioMainWindow(QMainWindow):
# The session is now connecting: it owns the phase, the connect clock and the
# run timer's cadence (the timer *is* the session, so it lives with it).
self.session.mark_running()
# A session exists and is not paused, which is exactly what the transport's
# checked state means: stated here as well as by the click, because starting
# is not only ever reached through the button (a recompute pass starts the
# same session), and an unchecked transport beside a live session would read
# as "paused".
self.toolbar.set_run_checked(True)
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
@@ -2013,6 +2121,12 @@ class StudioMainWindow(QMainWindow):
self.toolbar.refresh_labels()
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
# The session is over, so the transport must not stay latched in "paused" —
# the next run starts computing. The worker clears its own flag on start as
# well, so both sides agree even if this path is skipped (a stop from the
# acquisition thread).
self._display_paused = False
self._sync_pause_action()
self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.set_picker_enabled(True)
@@ -2064,29 +2178,111 @@ class StudioMainWindow(QMainWindow):
self._notify(t("toolbar.nothing_to_stop"))
def _on_reset(self) -> None:
if self.toolbar.reset_role() == "stop":
# In the replay view that button is the operator's way out of a replay or
# a recompute pass, whatever state the transport is in.
self.replay_ctl.stop_activity()
return
"""Zero the device tracker. Graphics are reset separately (``_on_reset_view``).
This used to call ``force_plots.reset()`` as well, so wanting a fresh baseline
also threw away the history, the window and the cursor on screen — two different
operator intentions on one button (and only one of them recorded, see
``note_reset_applied``). The display baseline is re-estimated instead of wiped:
a new device zero means the old idle estimate is stale, not that the operator
wants to lose what they were looking at.
It is only ever this: ending a replay or a recompute pass is the rail's Stop
button (the same act as Esc), so this button is disabled wherever there is no
device session to zero.
"""
if self._session_phase != "running" or self._active_worker is None:
return
try:
# A request, not the zeroing itself: the acquisition thread applies it
# between two frames and reports the frame and time it took effect.
self._active_worker.reset()
self.force_plots.reset()
self.force_plots.request_presentation_reestimate()
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.reset_pending"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_pause_toggled(self, paused: bool) -> None:
"""Pause the pipeline and the display; the device stays open.
Not a display freeze: the acquisition thread stops *computing* as well, so nothing
is spent on flow, depth or contact while an operator reads a result. What it
deliberately does not do is release the device — that is 停止 — so resuming is
immediate and the camera is not re-enumerated.
Consequence, stated rather than hidden: with no packets there is nothing to write,
so a recording that spans a pause has a **gap**. The store's own time base shows it
(sample times jump), and 停止 still ends the session normally.
Reached from the rail's transport (which owns the state) and from the View-menu
action / F7 (which mirrors it); never from two controls of its own.
"""
paused = bool(paused)
self._display_paused = paused
self._sync_pause_action()
worker = self._active_worker
if worker is not None:
try:
worker.pause() if paused else worker.resume()
except Exception as exc: # noqa: BLE001 - report, never raise from a click
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
if paused:
self.statusBar().showMessage(t("status.paused"))
else:
# The next packet is a new sample again: without this the first one after a
# resume can be swallowed as a repeat of the last key the tick saw.
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.resumed"))
def _on_pause_action_toggled(self, paused: bool) -> None:
"""The menu/shortcut half of pause: drive the rail's transport, which owns it.
The action is checked exactly while the session is paused, so pressing it
toggles the transport button — and that click is what routes to pause or
resume. Keeping one control authoritative means the button and the shortcut
can never disagree about whether the session is paused.
"""
paused = bool(paused)
if self.toolbar.run_role() == "playback" or not self._running:
# Nothing here to pause: put the action back rather than letting it
# advertise a state the session is not in.
self._sync_pause_action()
return
wanted = not paused
if self.toolbar.btn_run.isChecked() != wanted:
# ``setChecked`` emits, so the click takes the window's own transport
# path (start/resume/pause) instead of a second copy of that decision.
self.toolbar.btn_run.setChecked(wanted)
def _on_reset_view(self) -> None:
"""Put the views back to their defaults: curves, window, cursor and 3D camera.
Pressures the same state ``force_plots.reset()`` always cleared, now on its own
action, plus the 3D pane's camera. It touches nothing the device owns, so it is
always available — including while a replay is on screen.
"""
self.force_plots.reset()
panel = getattr(self, "mesh3d_panel", None)
if panel is not None:
try:
panel.reset_view()
except Exception: # noqa: BLE001 - a pane with no scene has no view to reset
logger.debug("3D view reset skipped", exc_info=True)
self.statusBar().showMessage(t("status.view_reset"))
def _on_run_tick(self) -> None:
# The 3D pane advances on this timer rather than one of its own: the window's
# refresh rate is then the only one that exists, and hiding or disabling the
# pane cannot leave a timer behind. It starts at most one compose on the
# pane's own worker and takes at most one finished frame.
self.mesh3d_panel.tick()
if not self._display_paused:
# While paused the pane holds the frame it had. The acquisition thread has
# already stopped computing, so this only matters for a frame that was in
# flight when the operator pressed pause — which must not be the one frame
# they cannot see.
self.mesh3d_panel.tick()
if self._active_worker is None:
return
# Record bookkeeping happens before anything that can return early: a
@@ -2141,7 +2337,7 @@ class StudioMainWindow(QMainWindow):
# source slot names the camera, so "运行中(0)" was a second copy of
# the same fact that then stayed on screen for the rest of the session.
if is_new:
if is_new and not self._display_paused:
self._last_plot_packet_key = packet_key
# The live view consumes the *same* standardised measurement as the
# CSV and the session store: one conversion decides what a value
@@ -2227,8 +2423,9 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(dropped_message)
mode = self.arrow_panel.view_mode()
if (is_new or mode != self._last_rendered_view_mode) and not (
self._replay_owns_image()
if not self._display_paused and (
(is_new or mode != self._last_rendered_view_mode)
and not (self._replay_owns_image())
):
self._render_run_image(packet)
if is_new:
@@ -2274,6 +2471,19 @@ class StudioMainWindow(QMainWindow):
return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
def _latched_domain_mask(self, frame: ImageFrame):
"""The 2D domain boundary to draw, latched for the session.
Exactly the treatment ``_depth_range`` gives the colour scale, and for the same
reason: the boundary is not a per-frame measurement. It is the device's own UV
island — geometry, rebuilt from the same remap on every frame — and the depth
view paints it as its "no depth" grey, so rebuilding it per frame redrew an edge
that had not moved while any difference in ``frame.valid_mask`` repainted the
whole boundary, which reads as lag. The latch holds the boundary until a frame
no longer describes the same island (see ``DomainMaskLatch``).
"""
return self._domain_mask.update(frame.valid_mask)
def _replay_owns_image(self) -> bool:
"""True while the 2D panel shows the replay, not the live camera.
@@ -2331,7 +2541,10 @@ class StudioMainWindow(QMainWindow):
draw_style = self._draw_style()
# The legend is decided by the same inputs as the picture: the arrow
# magnification of this sensor's draw style, and whether this frame
# carries a valid-area mask.
# carries a valid-area mask. It stays the *frame's* statement rather than the
# latched boundary's, because that is what the chip claims: the arrows are
# gated by this frame's own mask on every path, latched boundary or not.
boundary = self._latched_domain_mask(frame)
self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
if mode == DEPTH:
@@ -2347,6 +2560,7 @@ class StudioMainWindow(QMainWindow):
draw_style=draw_style,
depth_range=self._depth_range(),
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
domain_mask=boundary,
)
if rendered is None:
# Never substitute another picture here: a missing depth map that showed
@@ -15,7 +15,7 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Optional
from typing import Any
from orisys_app_shared.force.calibration_io import (
CHANNEL_KEYS,
@@ -37,14 +37,14 @@ logger = logging.getLogger(__name__)
class CalibrationService:
"""Owns the calibration session's files and its prepared model."""
def __init__(self, *, store: Optional[CalSessionStore] = None) -> None:
def __init__(self, *, store: CalSessionStore | None = None) -> None:
self.store = store or CalSessionStore()
#: The one prepared model the panel displays and exports.
self.prepared: Any = None
#: Device identity of a reopened record, while it is open.
self.record_identity: Optional[dict] = None
self.record_identity: dict | None = None
#: The record currently open, if any.
self.open_record: Optional[CalibrationSessionRecord] = None
self.open_record: CalibrationSessionRecord | None = None
# --------------------------------------------------------------- identity
def export_meta(
+108 -46
View File
@@ -19,12 +19,42 @@ repaint when the 2D view mode changes.
from __future__ import annotations
import logging
from typing import Any, Optional, Protocol
from typing import Any, Protocol
from PySide6.QtCore import QObject
from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_DEADBAND_N,
DEFAULT_PLOT_DEADBAND_RAW,
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_FN_MIN,
DEFAULT_PLOT_FN_RAW_MAX,
DEFAULT_PLOT_FN_RAW_MIN,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_SHEAR_RAW_MAX,
DEFAULT_PLOT_WINDOW_SEC,
)
logger = logging.getLogger(__name__)
#: The chart range, per channel and per unit. ``fn_min`` is the one that may be
#: negative (FN is asymmetric); FX/FY take one absolute magnitude each.
PLOT_RANGE_DEFAULTS = {
"window_sec": DEFAULT_PLOT_WINDOW_SEC,
"fn_min": DEFAULT_PLOT_FN_MIN,
"fn_max": DEFAULT_PLOT_FN_MAX,
"shear_max": DEFAULT_PLOT_SHEAR_MAX,
"fn_raw_min": DEFAULT_PLOT_FN_RAW_MIN,
"fn_raw_max": DEFAULT_PLOT_FN_RAW_MAX,
"shear_raw_max": DEFAULT_PLOT_SHEAR_RAW_MAX,
}
#: Display deadband floors, per unit. 0 = auto (the estimate from the quiet
#: startup window, with the presentation layer's own floor under it).
PLOT_DEADBAND_DEFAULTS = {
"deadband_n": DEFAULT_PLOT_DEADBAND_N,
"deadband_raw": DEFAULT_PLOT_DEADBAND_RAW,
}
class DisplayHost(Protocol):
"""What the display controller needs from the window.
@@ -36,7 +66,7 @@ class DisplayHost(Protocol):
prefs: dict
@property
def _latest_fps(self) -> Optional[float]: ...
def _latest_fps(self) -> float | None: ...
def _persist_prefs(self) -> None: ...
@@ -64,7 +94,7 @@ class DisplayPrefsController(QObject):
arrow_rotate_types: frozenset,
mesh_panel: Any = None,
rotate_arrows_action: Any = None,
parent: Optional[QObject] = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._host = host
@@ -84,7 +114,9 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: Finger-raw value cards: startup auto baseline / deadband (display only).
#: The display baseline / deadband (``raw_auto_zero``): a display aid
#: that holds an idle channel at zero, for every sensor type and both
#: units. It never reaches the session store or forces.csv.
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
@@ -143,50 +175,69 @@ class DisplayPrefsController(QObject):
# --------------------------------------------------------------------- plot
def plot_settings(self) -> dict:
"""The chart range and window as the settings dialog should show them."""
"""The chart range, deadband and window as the settings dialog shows them."""
prefs = self._host.prefs
return {
"window_sec": float(
prefs.get("plot_window_sec") or self._plot_defaults["window_sec"]
),
"fn_max": float(prefs.get("plot_fn_max") or self._plot_defaults["fn_max"]),
"shear_max": float(
prefs.get("plot_shear_max") or self._plot_defaults["shear_max"]
),
"fn_raw_max": float(
prefs.get("plot_fn_raw_max") or self._plot_defaults["fn_raw_max"]
),
"shear_raw_max": float(
prefs.get("plot_shear_raw_max") or self._plot_defaults["shear_raw_max"]
),
"units": self._toolbar.units(),
settings = {
key: self._number(
f"plot_{key}", self._plot_defaults.get(key, fallback)
)
for key, fallback in {**PLOT_RANGE_DEFAULTS, **PLOT_DEADBAND_DEFAULTS}.items()
}
settings["units"] = self._toolbar.units()
return settings
def _number(self, key: str, fallback: float) -> float:
"""A stored number, or the fallback when the preference says nothing.
0 is a value here, not "unset": the raw bounds and the deadband floors
all give 0 a meaning of its own.
"""
value = self._host.prefs.get(key)
if value is None or value == "":
return float(fallback)
try:
return float(value)
except (TypeError, ValueError):
return float(fallback)
def apply_plot_settings(self, values: dict) -> None:
"""Remember the chart range the operator chose, and show it at once."""
for key in ("window_sec", "fn_max", "shear_max", "fn_raw_max", "shear_raw_max"):
self._host.prefs[f"plot_{key}"] = values[key]
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
"""Remember the chart range the operator chose, and show it at once.
A caller that knows only some of the keys leaves the rest as stored, so
this can never silently reset a range it was not told about.
"""
for key in (*PLOT_RANGE_DEFAULTS, *PLOT_DEADBAND_DEFAULTS):
if values.get(key) is not None:
self._host.prefs[f"plot_{key}"] = values[key]
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._host._persist_prefs()
def apply_plot_prefs_to_panel(self) -> None:
"""Show the stored chart range without asking anything (startup path)."""
values = self.plot_settings()
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
settings = self.plot_settings()
self._charts.set_window_sec(float(settings["window_sec"]))
self._apply_ranges(settings)
self._charts.set_fixed_newton(self._toolbar.units() == "newton")
def _apply_ranges(self, settings: dict) -> None:
"""Push one complete range + deadband picture onto the charts."""
self._charts.set_axis_limits(
fn_min=float(settings["fn_min"]),
fn_max=float(settings["fn_max"]),
shear_max=float(settings["shear_max"]),
fn_raw_min=float(settings["fn_raw_min"]),
fn_raw_max=float(settings["fn_raw_max"]),
shear_raw_max=float(settings["shear_raw_max"]),
)
self._push_deadband_floors(settings["deadband_n"], settings["deadband_raw"])
def _push_deadband_floors(self, newton: float, raw: float) -> None:
floors = getattr(self._charts, "set_deadband_floors", None)
if callable(floors):
floors(newton=float(newton), raw=float(raw))
def device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for."""
from orisys_studio import session_metadata
@@ -271,7 +322,11 @@ class DisplayPrefsController(QObject):
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether Finger raw value cards auto-baseline at session start."""
"""Persist whether the display baseline / deadband is on at all.
The preference keeps its name; it is no longer Finger-raw-only (see
:meth:`apply_presentation_prefs`).
"""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
@@ -281,20 +336,27 @@ class DisplayPrefsController(QObject):
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the current baseline policy onto the charts (Finger raw only)."""
"""Push the display baseline policy onto the charts.
Every sensor type and both units. The aid holds an idle channel at zero:
a newton offset left in the numbers is the same display problem as a raw
count, so which device produced the reading is not what decides it.
"""
configure = getattr(self._charts, "configure_presentation", None)
if not callable(configure):
return
sensor = (
self._picker.sensor_type()
if hasattr(self._picker, "sensor_type")
else "box"
)
unit = "N" if self._toolbar.units() == "newton" else "raw"
configure(
sensor_type=sensor,
unit=unit,
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
if callable(configure):
configure(sensor_type=sensor, unit=unit)
enable = getattr(self._charts, "set_presentation_enabled", None)
if callable(enable):
enable(bool(self._raw_auto_zero))
self._push_deadband_floors(
self._number("plot_deadband_n", PLOT_DEADBAND_DEFAULTS["deadband_n"]),
self._number("plot_deadband_raw", PLOT_DEADBAND_DEFAULTS["deadband_raw"]),
)
def request_raw_reestimate(self) -> None:
+5 -4
View File
@@ -11,17 +11,18 @@ import sys
# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
from PySide6.QtWidgets import QApplication # noqa: E402
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared import API_VERSION
from orisys_app_shared import __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.logging import init_logging, install_exception_hooks
from orisys_app_shared.paths import prefer_local_sensor_bundles
from orisys_app_shared.prefs import load_preferences
from orisys_app_shared.theme import build_qss
from PySide6.QtWidgets import QApplication # noqa: E402
from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version
from orisys_studio import REQUIRES_SHARED_API
from orisys_studio import __version__ as app_version
from orisys_studio.app_window import StudioMainWindow
logger = logging.getLogger(__name__)
+8 -7
View File
@@ -16,8 +16,9 @@ here, where they can be tested without a display.
from __future__ import annotations
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Any
from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordReport
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
@@ -34,7 +35,7 @@ class RecordingCoordinator:
*,
session_store,
cal_recorder,
recorder: Optional[ExperimentRecorder] = None,
recorder: ExperimentRecorder | None = None,
) -> None:
self.session_store = session_store
self.cal_recorder = cal_recorder
@@ -47,7 +48,7 @@ class RecordingCoordinator:
self._alarms: list[str] = []
#: A stop was requested and the writer has not closed its files yet.
self.report_pending = False
self.last_report: Optional[RecordReport] = None
self.last_report: RecordReport | None = None
self._active_calibration_id = ""
self._record_fps = 30.0
self.recorder.set_alarm_handler(self._on_alarm)
@@ -210,7 +211,7 @@ class RecordingCoordinator:
self.report_pending = False
return Path(directory)
def stop(self, *, wait: bool = True) -> Optional[RecordReport]:
def stop(self, *, wait: bool = True) -> RecordReport | None:
"""Stop and return the report, or ``None`` when nothing was started."""
if self.recorder.directory is None and self.recorder.report is None:
self.report_pending = False
@@ -221,7 +222,7 @@ class RecordingCoordinator:
self.publish(report)
return report
def publish(self, report: Optional[RecordReport]) -> None:
def publish(self, report: RecordReport | None) -> None:
"""Record a report; a completed one becomes ``last_report``."""
if report is None:
return
@@ -232,7 +233,7 @@ class RecordingCoordinator:
self.report_pending = False
self.last_report = report
def poll(self) -> Optional[RecordReport]:
def poll(self) -> RecordReport | None:
"""The terminal report, once the writer has actually closed the files."""
if not self.report_pending:
return None
@@ -256,7 +257,7 @@ def record_config_from_prefs(
*,
mode: str,
fps: float,
on_problem: Optional[Callable[[str], None]] = None,
on_problem: Callable[[str], None] | None = None,
) -> RecordConfig:
"""Build the record config the toolbar/prefs describe."""
return RecordConfig(
+3 -5
View File
@@ -21,13 +21,11 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
import numpy as np
from PySide6.QtCore import QObject, Signal
from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader, VideoOnlySource
from PySide6.QtCore import QObject, Signal
logger = logging.getLogger(__name__)
@@ -64,7 +62,7 @@ class ReplayEngine(QObject):
self._position = 0.0
#: The frame cache: one video image per cursor position.
self._video_position: float | None = None
self._video_bgr: Optional[np.ndarray] = None
self._video_bgr: np.ndarray | None = None
self._video_reason = ""
self._warnings = ""
@@ -209,7 +207,7 @@ class ReplayEngine(QObject):
return self._unit
# ----------------------------------------------------------------- frames
def video_frame_bgr(self) -> Optional[np.ndarray]:
def video_frame_bgr(self) -> np.ndarray | None:
"""The recorded frame under the cursor, as BGR.
The engine hands over pixels, not a prepared widget image: the single 2D
@@ -25,15 +25,14 @@ import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Protocol
from typing import Any, Protocol
import numpy as np
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QFileDialog
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QFileDialog
logger = logging.getLogger(__name__)
@@ -111,7 +110,7 @@ class ReplayController(QObject):
charts: Any,
image_panel: Any,
mesh_panel: Any,
parent: Optional[QObject] = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._engine = engine
@@ -120,7 +119,7 @@ class ReplayController(QObject):
self._charts = charts
self._image_panel = image_panel
self._mesh_panel = mesh_panel
self._actions: Optional[ReplayActions] = None
self._actions: ReplayActions | None = None
#: True while a recompute pass is running: it is a *run* over a recording, so
#: the acquire pipeline owns the panels and the transport stops the pass.
self._recompute_running = False
@@ -501,7 +500,7 @@ class ReplayController(QObject):
self._host._notify(t("toolbar.nothing_to_stop"))
# ---------------------------------------------------------------- recompute
def video_segment(self) -> Optional[Path]:
def video_segment(self) -> Path | None:
"""The recorded video a recompute pass should read."""
reader = self._engine.reader()
segments = sorted(reader.segments()) if reader is not None else []
@@ -33,10 +33,9 @@ Two things the v1 adapters deliberately do **not** do:
from __future__ import annotations
from typing import Any, Optional
from typing import Any
import numpy as np
from orisys_app_shared.acquisition.models import SdkPacket
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
@@ -45,7 +44,6 @@ from orisys_app_shared.scene3d.contracts import (
FrameIdentity,
SurfaceFieldFrame,
diagnostic,
error,
freeze,
)
from orisys_app_shared.scene3d.sampling import remap_to_domain
@@ -84,7 +82,7 @@ def _refusal_rank(item: Diagnostic) -> int:
return _REFUSAL_RANK.get(str(item.code), 0)
def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
def flow_magnitude(packet: SdkPacket) -> np.ndarray | None:
"""``|flow|`` as float32, or ``None`` when the packet carries no flow."""
flow = getattr(packet, "flow", None)
if flow is None:
@@ -97,11 +95,11 @@ def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
return None
def _shape_of(array: Optional[np.ndarray]) -> str:
def _shape_of(array: np.ndarray | None) -> str:
return "none" if array is None else "x".join(str(int(v)) for v in array.shape[:2])
def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Optional[Diagnostic]:
def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Diagnostic | None:
"""Whether the packet may claim ``field`` for *this* frame.
Three answers, and the middle one is the point: ``None`` on the packet means the
@@ -142,13 +140,22 @@ def _computed_frame_id(packet: SdkPacket, field: str, frame_id: int) -> int:
def _device_mask(
packet: SdkPacket, shape_hw: tuple[int, int]
) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]:
packet: SdkPacket,
shape_hw: tuple[int, int],
latch: Any = None,
) -> tuple[np.ndarray | None, Diagnostic | None]:
"""The device's own validity, or a diagnostic when it describes another grid.
Both silent choices are wrong: stretching a mask from another grid marks samples
valid that the device never claimed, and ignoring it *widens* the trusted area to
the whole UV domain. So a mismatch refuses the frame and names both shapes.
``latch`` is the session's ``DomainMaskLatch`` when the window has one. The mask it
returns is the *session's* island — the same array the 2D canvas paints its island
and its "no depth" grey from — so the surface's validity texture is that island and
not a second, independently rebuilt copy of it. The latch is idempotent for a
repeated mask (an IoU of 1 is always held), which is what makes it safe for both
views to consult it from different threads and different frames.
"""
explicit = getattr(packet, "valid_mask", None)
if explicit is None:
@@ -161,10 +168,14 @@ def _device_mask(
mask=_shape_of(array),
domain=f"{int(shape_hw[0])}x{int(shape_hw[1])}",
)
if latch is not None:
held = latch.update(array)
if held is not None:
array = np.asarray(held, dtype=bool)
return array, None
def _intersect(mask: np.ndarray, device: Optional[np.ndarray]) -> np.ndarray:
def _intersect(mask: np.ndarray, device: np.ndarray | None) -> np.ndarray:
"""Inside the UV island AND the device's own valid area, when it stated one."""
if device is None:
return freeze(mask, dtype=np.bool_)
@@ -205,8 +216,8 @@ class FingerFrameAdapter:
geometry_id: str,
field_domain_id: str,
shape_hw: tuple[int, int],
map_x: Optional[np.ndarray] = None,
map_y: Optional[np.ndarray] = None,
map_x: np.ndarray | None = None,
map_y: np.ndarray | None = None,
preferred_field: str = "depth",
) -> None:
self.geometry_id = str(geometry_id)
@@ -214,7 +225,7 @@ class FingerFrameAdapter:
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
self.map_x = map_x
self.map_y = map_y
self.last_diagnostic: Optional[Diagnostic] = None
self.last_diagnostic: Diagnostic | None = None
#: Which quantity the operator asked for (``"depth"`` or ``"flow"``). The other
#: one is tried when this one cannot be coloured, and the substitution is stated
#: rather than silent — a frame that shows another quantity than the setting says
@@ -223,13 +234,18 @@ class FingerFrameAdapter:
self.preferred_field = _field_name(preferred_field)
#: Set (and cleared) by every snapshot: the reason the picture is not the field
#: the operator chose. ``None`` when the preferred field was the one drawn.
self.last_substitution: Optional[Diagnostic] = None
self.last_substitution: Diagnostic | None = None
#: The session's island latch (``image_backend.DomainMaskLatch``), when the
#: window has one. See ``_device_mask``: the surface's validity is the *latched*
#: island — the same mask the 2D canvas paints its island and its "no depth"
#: grey from — so the two views cannot disagree about where the measurement is.
self.domain_latch: Any = None
def set_preferred_field(self, name: str) -> None:
self.preferred_field = _field_name(name)
@classmethod
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
def from_domain(cls, domain: Any, *, geometry_id: str) -> FingerFrameAdapter:
"""Build from a ``LegacyFieldDomain`` (which carries the map, when loaded)."""
return cls(
geometry_id=geometry_id,
@@ -241,7 +257,7 @@ class FingerFrameAdapter:
def snapshot(
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
) -> Optional[SurfaceFieldFrame]:
) -> SurfaceFieldFrame | None:
"""Colour the surface with the field the operator chose, or say why not.
Two quantities can be drawn and the operator picks one (``DisplaySettings.field``);
@@ -288,7 +304,7 @@ class FingerFrameAdapter:
identity: FrameIdentity,
name: str,
refused: list[Diagnostic],
) -> Optional[SurfaceFieldFrame]:
) -> SurfaceFieldFrame | None:
"""One quantity, or ``None`` with the reason appended to ``refused``."""
if name == "depth":
depth = getattr(packet, "depth", None)
@@ -383,11 +399,11 @@ class FingerFrameAdapter:
mask: np.ndarray,
descriptor: FieldDescriptor,
refused: list[Diagnostic],
) -> Optional[SurfaceFieldFrame]:
) -> SurfaceFieldFrame | None:
"""Intersect with the device's own mask and build the frame."""
# Inside the UV island is the only place a value means anything: the tracker is
# looking at masked black outside it.
device, mask_problem = _device_mask(packet, self.shape_hw)
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
if mask_problem is not None:
refused.append(mask_problem)
return None
@@ -407,7 +423,7 @@ class FingerFrameAdapter:
arr = np.asarray(flow, dtype=np.float32)
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
flow_uv = freeze(arr, dtype=np.float32)
device_for_flow, _ = _device_mask(packet, self.shape_hw)
device_for_flow, _ = _device_mask(packet, self.shape_hw, self.domain_latch)
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
if not np.isfinite(fn):
@@ -451,11 +467,13 @@ class BoxFrameAdapter:
self.geometry_id = str(geometry_id)
self.field_domain_id = str(field_domain_id)
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
self.last_diagnostic: Optional[Diagnostic] = None
self.last_diagnostic: Diagnostic | None = None
#: See ``FingerFrameAdapter.domain_latch``: one island, both views.
self.domain_latch: Any = None
def snapshot(
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
) -> Optional[SurfaceFieldFrame]:
) -> SurfaceFieldFrame | None:
if tuple(domain.shape_hw) != self.shape_hw:
self.last_diagnostic = diagnostic(
"field_domain_mismatch",
@@ -468,11 +486,11 @@ class BoxFrameAdapter:
("depth", getattr(packet, "depth", None)),
("flow", flow_magnitude(packet)),
)
device, mask_problem = _device_mask(packet, self.shape_hw)
device, mask_problem = _device_mask(packet, self.shape_hw, self.domain_latch)
if mask_problem is not None:
self.last_diagnostic = mask_problem
return None
refused: Optional[Diagnostic] = None
refused: Diagnostic | None = None
for name, field in candidates:
if field is None:
continue
@@ -28,12 +28,11 @@ exactly the kind of thing this feature is not allowed to cause.
from __future__ import annotations
import logging
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional
from PySide6.QtCore import QObject, Signal
from typing import TYPE_CHECKING, Any
from orisys_app_shared.paths import config_dir
from orisys_app_shared.scene3d.contracts import (
@@ -43,6 +42,8 @@ from orisys_app_shared.scene3d.contracts import (
diagnostic,
error,
)
from PySide6.QtCore import QObject, Signal
from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement
if TYPE_CHECKING: # the description is a value object; the callable returns one
@@ -58,7 +59,7 @@ PACKAGE_SUFFIX = ".oryscene"
COMPILE_HINT = (
"python tools/scene_assets/compile_scene.py "
"--legacy-dir config/sensors/finger/assets/30fps-0703 "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz "
"--map config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.42.npz "
"--meters-per-source-unit <stated> --output "
"config/sensors/finger/scene/finger-display-1.0.0.oryscene"
)
@@ -76,7 +77,7 @@ class SceneSource:
return f"{self.kind}: {self.path}"
def scene_dir(sensor_type: str, *, root: Optional[Path] = None) -> Path:
def scene_dir(sensor_type: str, *, root: Path | None = None) -> Path:
"""Where a compiled package for this sensor belongs."""
base = Path(root) if root is not None else config_dir()
return base / "sensors" / str(sensor_type) / "scene"
@@ -86,8 +87,8 @@ def find_package(
sensor_type: str,
*,
override: str | Path | None = None,
root: Optional[Path] = None,
) -> Optional[SceneSource]:
root: Path | None = None,
) -> SceneSource | None:
"""The package to load, or ``None``. Never raises: absence is a state."""
if override:
path = Path(str(override)).expanduser()
@@ -126,8 +127,8 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None)
def load_package(
source: SceneSource,
expected: Optional[ExpectedGeometry] = None,
keys: "Optional[SceneKeys]" = None,
expected: ExpectedGeometry | None = None,
keys: SceneKeys | None = None,
) -> SceneAssets:
"""Read and validate a package. Runs on a worker thread; raises ``ThreeDError``.
@@ -153,10 +154,10 @@ def load_package(
def _resolve_and_load(
source: SceneSource,
expected: Optional[ExpectedGeometry],
provider: "Optional[Callable[[], DeviceGeometry]]",
keys: "Optional[SceneKeys]" = None,
) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]:
expected: ExpectedGeometry | None,
provider: Callable[[], DeviceGeometry] | None,
keys: SceneKeys | None = None,
) -> tuple[SceneAssets, DeviceGeometry | None]:
"""Worker-thread half: build the *device's* description, then read the package.
Deriving the description digests the unroll map the runtime loads, so it belongs on
@@ -183,21 +184,21 @@ class SceneAssetLoader(QObject):
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
failed = Signal(object, int) # Diagnostic, request id
def __init__(self, parent: Optional[QObject] = None) -> None:
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._executor: Optional[ThreadPoolExecutor] = None
self._executor: ThreadPoolExecutor | None = None
self._inflight = False
self._request_id = 0
#: The newest request that could not start yet (source, expected, provider, id).
self._pending: Optional[tuple[SceneSource, Optional[ExpectedGeometry], Any, int]] = None
self._pending: tuple[SceneSource, ExpectedGeometry | None, Any, int] | None = None
def load(
self,
source: SceneSource,
expected: Optional[ExpectedGeometry] = None,
expected: ExpectedGeometry | None = None,
*,
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
keys: "Optional[SceneKeys]" = None,
expected_provider: Callable[[], DeviceGeometry] | None = None,
keys: SceneKeys | None = None,
) -> int:
"""Ask for a scene; returns the request id this call was given."""
self._request_id += 1
@@ -15,14 +15,12 @@ from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from orisys_app_shared.scene3d.contracts import (
F32,
CameraMatrices,
CameraPreset,
F32,
freeze,
)
@@ -54,7 +52,7 @@ class OrbitCamera:
vertical_fov_rad: float = 0.6
aspect: float = 1.0
_fitted_radius_m: float = 0.05
_preset_name: Optional[str] = None
_preset_name: str | None = None
# ------------------------------------------------------------------- setup
def fit(self, bounds_sensor_m: F32, aspect: float) -> None:
@@ -36,9 +36,10 @@ from __future__ import annotations
import threading
import time
from collections.abc import Callable
from concurrent.futures import CancelledError, Executor, Future
from dataclasses import dataclass
from typing import Any, Callable, Optional
from typing import Any
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
@@ -106,27 +107,27 @@ class ThreeDCoordinator:
#: withdrawn: a stop, a hide, a new session, a new scene. A compose result
#: whose epoch is not this one is thrown away instead of presented.
self._epoch = 0
self._assets: Optional[SceneAssets] = None
self._assets: SceneAssets | None = None
self._source_id = ""
self._session_id: Optional[int] = None
self._session_id: int | None = None
#: The newest request id seen in this epoch: a producer that falls behind
#: (a seek UI, a queue that reorders) must not put an older frame on screen.
self._last_request_id = 0
self._visible = False
self._settings = DisplaySettings(max_fps=self._max_fps)
self._inflight: Optional[_Inflight] = None
self._inflight: _Inflight | None = None
self._stale_frames = 0
self._invalid_frames = 0
self._last_diagnostic: Optional[Diagnostic] = None
self._last_diagnostic: Diagnostic | None = None
self._closed = False
self._settings_dirty = False
self._last_composed_identity: Optional[FrameIdentity] = None
self._last_composed_identity: FrameIdentity | None = None
#: What is on screen and when it got there, for the pane's frame counter.
self._last_presented: Optional[tuple[FrameIdentity, float]] = None
self._last_presented: tuple[FrameIdentity, float] | None = None
#: When the current measurement stopped being current, or ``None`` while the
#: view is live. Set by a field that could not be built, a compose that
#: failed, or a frame whose array belongs to an earlier acquisition frame.
self._stale_since: Optional[float] = None
self._stale_since: float | None = None
#: Whether the reference model is already on screen for the current claim.
#: Without this the pane re-uploaded the same rest frame on every 16 ms tick
#: while it waited for the first measurement.
@@ -138,7 +139,7 @@ class ThreeDCoordinator:
self._last_result_latency_ms = 0.0
#: The field frame currently on screen, so a gain change can redraw exactly
#: it rather than a newer instant.
self._last_field_frame: Optional[SurfaceFieldFrame] = None
self._last_field_frame: SurfaceFieldFrame | None = None
# ------------------------------------------------------------------- state
@property
@@ -155,7 +156,7 @@ class ThreeDCoordinator:
"""The display claim results are accepted under (see :class:`_Inflight`)."""
return self._epoch
def last_presented(self) -> Optional[tuple[FrameIdentity, float]]:
def last_presented(self) -> tuple[FrameIdentity, float] | None:
"""What is on screen, and the monotonic time it was handed to the widget."""
return self._last_presented
@@ -190,7 +191,7 @@ class ThreeDCoordinator:
return self._invalid_frames
@property
def last_diagnostic(self) -> Optional[Diagnostic]:
def last_diagnostic(self) -> Diagnostic | None:
return self._last_diagnostic
@property
@@ -355,7 +356,7 @@ class ThreeDCoordinator:
self._last_request_id = max(self._last_request_id, identity.request_id)
return self._incoming.offer(frame)
def note_field_failure(self, item: Optional[Diagnostic] = None) -> None:
def note_field_failure(self, item: Diagnostic | None = None) -> None:
"""A producer could not build a field frame from this packet.
Called from the acquisition thread by the sink that owns the adapter: when the
@@ -373,7 +374,7 @@ class ThreeDCoordinator:
@staticmethod
def _reject_reason(
frame: SurfaceFieldFrame, assets: SceneAssets
) -> Optional[Diagnostic]:
) -> Diagnostic | None:
"""The cheap checks a producer thread may do: identity and shape only."""
if frame.geometry_id != assets.geometry_id:
return diagnostic(
@@ -452,7 +453,7 @@ class ThreeDCoordinator:
# A frame arrived with its own fields: whatever went stale is current
# again, and the grace starts over next time it fails.
self._stale_since = None
self._set_state("live" if composed.valid.any() else "no_data")
self._set_state("live" if composed.field.valid.any() else "no_data")
elif self._state in ("ready", "loading"):
# Nothing has arrived yet: show the reference model rather than a blank
# window, and say that no measurement is being displayed. Only once per
@@ -482,6 +483,19 @@ class ThreeDCoordinator:
# held field frame so the operator sees the change without waiting.
self._settings_dirty = True
def set_ramp(self, ramp: str) -> None:
"""Hand the theme's colour ramp to the composer.
The composer stamps it on every ``DisplayField`` it builds, and the GL layer
builds its lookup table from the field it is handed. That is how the 3D
surface and the 2D canvas end up coloured by one table: the ramp is resolved
once, from the theme, and travels with the measurement.
"""
try:
self._composer.ramp = str(ramp or "")
except AttributeError: # pragma: no cover - a composer stand-in without state
pass
def show_rest(self) -> None:
"""Put the reference model back on screen and stop claiming a measurement.
@@ -12,24 +12,27 @@ Import discipline, and why it is not negotiable:
* :meth:`GLRenderer.release` is idempotent and requires the owning context to be
current — resources are freed by the context that made them.
Two parts, two programs, one draw call per part. The sensing surface is re-uploaded
per frame and its colour comes from ``scalar`` through a LUT texture with the range
as uniforms, so Python never builds an RGB array per frame; the housing is uploaded
once and never touched again. A frame with no measurement (``valid == 0``) is drawn
in the neutral colour rather than in colour zero — the reference model is not a
measurement and must not look like one.
Two parts, two programs, one draw call per part. The sensing surface's colour comes
from the *field texture* — the acquisition frame's own ``[H,W]`` grid, uploaded with
``glTexSubImage2D`` and read per fragment through the mesh's UVs — so Python never
builds an RGB array per frame and never gathers the field onto the vertices; the
vertex buffer is uploaded once and re-uploaded only when a display deformation
actually moves a vertex. The housing is uploaded once and never touched again. A
fragment whose texel is invalid is drawn in the neutral colour rather than in colour
zero: the reference model is not a measurement and must not look like one.
"""
from __future__ import annotations
import logging
import math
import time
from typing import Any, Optional
from typing import Any
import numpy as np
from orisys_app_shared.scene3d.contracts import (
CameraMatrices,
Diagnostic,
DisplaySettings,
GLCapabilities,
MeshPart,
@@ -41,7 +44,8 @@ from orisys_app_shared.scene3d.contracts import (
error,
freeze,
)
from orisys_app_shared.scene3d.contracts import Diagnostic
logger = logging.getLogger(__name__)
#: Renderer names that are not a GPU. A board must not be signed off on one of
#: these, and neither must a laptop with a broken driver.
@@ -61,6 +65,16 @@ INVALID_COLOR = (0.18, 0.18, 0.20, 1.0)
#: LUT size. 256 is enough for a sequential ramp and cheap to rebuild.
LUT_SIZE = 256
#: The ramp used when a field does not name one. It is the dark theme's own
#: ``flow_ramp``: the 2D canvas and the 3D pane must be coloured by the same stops,
#: and a default that differed from either would be the bug this replaces.
DEFAULT_FIELD_RAMP = "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
#: Bytes per texel the GPU budget charges for the field textures: ``R16F`` scalar
#: (nominal; the ``R32F`` fallback is 4) plus the ``R8`` validity.
FIELD_BYTES_PER_TEXEL = 2
VALID_BYTES_PER_TEXEL = 1
def configure_surface_format(profile: Profile = "desktop33") -> None:
"""Select the default GL surface format. Call before ``QApplication``.
@@ -112,33 +126,42 @@ def profile_from_renderable_type(value: int) -> Profile:
# --------------------------------------------------------------------- shaders
#: Shared bodies: the version header is the only difference, so the maths cannot
#: drift between the desktop and the embedded build.
#:
#: The surface is **texture-first**. The vertex stage carries geometry and one UV
#: pair per vertex — nothing about the measurement — and the fragment stage reads the
#: field and its validity from two textures on the domain's own grid. There used to
#: be ``a_scalar``/``a_valid`` attributes here, gathered per *vertex* on the CPU and
#: interpolated across the triangles; a vertex whose taps touched one invalid pixel
#: became ``NaN`` and the ``isnan`` rule below painted the whole triangle grey
#: (measured: 30.15 % of the island). Sampling per fragment removes the interpolation
#: step entirely: the fragment reads the same texel the 2D canvas paints at that
#: pixel, so the two views agree by construction.
_VERTEX_BODY = """
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in float a_scalar;
layout(location = 3) in float a_valid;
layout(location = 4) in vec2 a_uv;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec3 v_normal;
out float v_scalar;
out float v_valid;
out vec2 v_uv;
void main() {
v_normal = a_normal;
v_scalar = a_scalar;
v_valid = a_valid;
v_uv = a_uv;
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
}
"""
_SCALAR_FRAGMENT_BODY = """
in vec3 v_normal;
in float v_scalar;
in float v_valid;
in vec2 v_uv;
uniform sampler2D u_lut;
uniform sampler2D u_field;
uniform sampler2D u_valid;
uniform vec2 u_inv_domain_size;
uniform float u_lower;
uniform float u_upper;
uniform vec4 u_invalid_color;
@@ -146,20 +169,25 @@ uniform vec4 u_invalid_color;
out vec4 frag_color;
void main() {
if (v_valid < 0.5 || isnan(v_scalar)) {
// ``v_uv`` is already on the domain's pixel grid (the compiler applied the field's
// own flip and scale, once, in ``legacy.uvs_to_pixels``), so the texel this
// fragment owns is the rounded pixel, centred by half a texel and normalised by
// the domain size -- the only conversion left, and it is exact rather than a
// resampling. NEAREST filtering on both textures then makes "valid" a property of
// that one texel, which is what the 2D view paints.
vec2 texel = (floor(v_uv + 0.5) + 0.5) * u_inv_domain_size;
if (texture(u_valid, texel).r < 0.5) {
frag_color = u_invalid_color;
return;
}
float span = max(u_upper - u_lower, 1e-12);
float t = clamp((v_scalar - u_lower) / span, 0.0, 1.0);
float t = clamp((texture(u_field, texel).r - u_lower) / span, 0.0, 1.0);
frag_color = vec4(texture(u_lut, vec2(t, 0.5)).rgb, 1.0);
}
"""
_DIFFUSE_FRAGMENT_BODY = """
in vec3 v_normal;
in float v_scalar;
in float v_valid;
uniform vec3 u_light_direction;
uniform vec4 u_base_color;
@@ -235,7 +263,7 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
return header + vertex, header + body
def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
def _valid_scalar_range(scalar, valid) -> tuple[float, float] | None:
"""Robust P5/P95 range of *valid* samples, or ``None`` when there are none.
Using min/max made empty-field noise paint the whole LUT. A percentile span
@@ -268,11 +296,11 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
def _blend_scale_range(
previous: Optional[tuple[float, float]],
current: Optional[tuple[float, float]],
previous: tuple[float, float] | None,
current: tuple[float, float] | None,
*,
alpha: float = 0.15,
) -> Optional[tuple[float, float]]:
) -> tuple[float, float] | None:
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
if current is None:
return previous
@@ -284,7 +312,7 @@ def _blend_scale_range(
(1.0 - a) * float(previous[1]) + a * float(current[1]),
)
def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]:
def _scale_bounds(scale, automatic: tuple[float, float] | None) -> tuple[float, float]:
"""The colour range to draw with: the operator's, or the data's own.
``automatic`` mode exists because the quantities differ in magnitude by orders
@@ -301,7 +329,7 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
else:
fallback = (float(scale.lower), float(scale.upper))
lower, upper = float(fallback[0]), float(fallback[1])
if not (lower == lower) or not (upper == upper): # NaN
if math.isnan(lower) or math.isnan(upper):
return 0.0, 1.0
if upper <= lower:
# A constant field still has to be visible rather than all one clipped colour.
@@ -310,106 +338,134 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa
return lower, upper
def lut_rgba(lut_id: str = "sequential") -> np.ndarray:
"""A 256x1 RGBA8 lookup table.
def lut_rgba(lut_id: str = "sequential", ramp: str = "") -> np.ndarray:
"""A 256x1 RGBA8 lookup table, built from the **one** ramp both views share.
Hand-rolled rather than imported: the runtime must not grow a plotting
dependency for one texture, and the ramp is part of what the colours *mean*.
``sequential`` is perceptually increasing in lightness (safe for magnitude);
``diverging`` is the same ramp mirrored through a neutral midpoint (safe for a
signed quantity, and never used for an unsigned one).
``ramp`` is the resolved ``#RRGGBB,#RRGGBB,...`` string the field carries
(``acquisition.arrows.ramp_for_theme``); empty means this build's default ramp,
which is the dark theme's own. Either way the table is the *same* array the 2D
canvas colours its pixels with — ``arrows.ramp_rgb_table`` is the single
interpolation and the single rounding, so the two legends and the two pictures
cannot disagree.
That was not true before: this function had its own hand-written stop list
(a viridis-like ramp) while the 2D side used the theme's ``flow_ramp``, and the
two disagreed by up to 218 of 255 per channel. A measurement drawn purple in one
pane and navy in the other is two different measurements as far as an operator is
concerned.
``diverging`` is the same ramp mirrored through its midpoint: safe for a signed
quantity, and never used for an unsigned one.
"""
stops = np.array(
[
[0.267, 0.005, 0.329],
[0.283, 0.141, 0.458],
[0.254, 0.265, 0.530],
[0.207, 0.372, 0.553],
[0.164, 0.471, 0.558],
[0.128, 0.567, 0.551],
[0.135, 0.659, 0.518],
[0.267, 0.749, 0.441],
[0.478, 0.821, 0.318],
[0.741, 0.873, 0.150],
[0.993, 0.906, 0.144],
],
dtype=np.float32,
)
if lut_id == "diverging":
stops = np.concatenate([stops[::-1], stops], axis=0)
positions = np.linspace(0.0, 1.0, stops.shape[0], dtype=np.float32)
grid = np.linspace(0.0, 1.0, LUT_SIZE, dtype=np.float32)
rgba = np.empty((LUT_SIZE, 1, 4), dtype=np.uint8)
for channel in range(3):
rgba[:, 0, channel] = np.clip(
np.interp(grid, positions, stops[:, channel]) * 255.0, 0, 255
).astype(np.uint8)
rgba[:, 0, 3] = 255
return rgba
from orisys_app_shared.acquisition.arrows import ramp_rgba_table
return ramp_rgba_table(str(ramp or DEFAULT_FIELD_RAMP), diverging=lut_id == "diverging")
# ---------------------------------------------------------------- vertex packing
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + scalar(1)
#: + valid(1). One buffer per part, so a per-frame upload is one glBufferSubData.
#: Floats per vertex in the interleaved buffer: position(3) + normal(3) + uv(2). One
#: buffer per part, uploaded once. The stride is the same 8 floats it was when it
#: carried a per-vertex scalar and validity — the measurement moved to its own
#: texture, and the UV that addresses it took their place.
STRIDE_FLOATS = 8
#: Where in a vertex row the UV starts.
UV_OFFSET_FLOATS = 6
def pack_part(part: MeshPart, *, scalar: Optional[np.ndarray] = None,
valid: Optional[np.ndarray] = None) -> np.ndarray:
#: Texture units. The LUT keeps unit 0; the field and its validity get their own so a
#: driver cannot alias them.
LUT_TEXTURE_UNIT = 0
FIELD_TEXTURE_UNIT = 1
VALID_TEXTURE_UNIT = 2
def _extend_edges(values: np.ndarray, usable: np.ndarray) -> np.ndarray:
"""Every unusable texel's one-texel edge extension: the mean of its usable neighbours.
Slicing rather than ``np.roll``: a wrapped neighbour would carry a measurement in
from the opposite edge of the domain, which is a value from somewhere else entirely.
A texel with no usable neighbour — the whole field is a hole — keeps 0.0, and the
validity channel is what decides whether anything is drawn there.
"""
height, width = values.shape
total = np.zeros_like(values)
counts = np.zeros_like(values)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dy == 0 and dx == 0:
continue
source_y = slice(max(dy, 0), height + min(dy, 0))
source_x = slice(max(dx, 0), width + min(dx, 0))
target_y = slice(max(-dy, 0), height + min(-dy, 0))
target_x = slice(max(-dx, 0), width + min(-dx, 0))
neighbour_values = values[source_y, source_x]
neighbour_usable = usable[source_y, source_x]
total[target_y, target_x] += np.where(neighbour_usable, neighbour_values, 0.0)
counts[target_y, target_x] += neighbour_usable
filled = np.divide(total, counts, out=np.zeros_like(values), where=counts > 0)
return np.where(usable, values, filled)
def pack_part(part: MeshPart, *, uvs: np.ndarray | None = None) -> np.ndarray:
"""Interleave one part into the layout the shaders read.
The static case (no scalar yet) marks every vertex invalid, which the shader
draws as the neutral colour: a scene that has never received a measurement
must not be coloured as if it had.
``uvs`` are the render-vertex UVs on the domain's pixel grid
(``SurfaceBinding.uvs``), in the same order as ``part.positions_sensor_m``. A part
with no UV mapping (the housing) is never coloured by the measurement, so it gets
zeros and its own diffuse program; the attribute exists in the shared layout but
nothing reads it there.
"""
count = int(part.positions_sensor_m.shape[0])
rows = np.empty((count, STRIDE_FLOATS), dtype=np.float32)
rows[:, 0:3] = np.asarray(part.positions_sensor_m, dtype=np.float32)
rows[:, 3:6] = np.asarray(part.normals_sensor, dtype=np.float32)
if scalar is None:
rows[:, 6] = np.nan
if uvs is None:
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = 0.0
else:
values = np.asarray(scalar, dtype=np.float32).reshape(-1)
if values.size != count:
values = np.asarray(uvs, dtype=np.float32).reshape(-1, 2)
if values.shape[0] != count:
raise error(
"geometry_mismatch",
f"{part.part_id}: {values.size} scalars for {count} vertices",
f"{part.part_id}: {values.shape[0]} UVs for {count} vertices",
)
rows[:, 6] = values
if valid is None:
rows[:, 7] = 0.0
else:
flags = np.asarray(valid, dtype=bool).reshape(-1)
if flags.size != count:
raise error(
"geometry_mismatch",
f"{part.part_id}: {flags.size} validity flags for {count} vertices",
)
rows[:, 7] = flags.astype(np.float32)
rows[:, UV_OFFSET_FLOATS : UV_OFFSET_FLOATS + 2] = values
return freeze(rows, dtype=np.float32)
def pack_scene_part(part: MeshPart) -> tuple[np.ndarray, np.ndarray]:
def pack_scene_part(
part: MeshPart, *, uvs: np.ndarray | None = None
) -> tuple[np.ndarray, np.ndarray]:
"""``(vertices, indices)`` as the GPU wants them, both read-only."""
indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1)
return pack_part(part), freeze(indices, dtype=np.uint32)
return pack_part(part, uvs=uvs), freeze(indices, dtype=np.uint32)
def estimate_gpu_bytes(assets: SceneAssets, *, dynamic_part_id: str) -> int:
"""A rough static-plus-one-dynamic budget, for the stats line."""
"""A rough static-plus-one-dynamic budget, for the stats line.
The two field textures are part of it now: the scalar as 16-bit floats and the
validity as one byte per texel, over the scene's own UV domain. They are counted
at their nominal size (``R16F``), and the ``R32F`` fallback a driver without
``R16F`` forces is twice the scalar half — noted rather than guessed at, because
the budget is a *report*, and the code that picks the format is in
:meth:`GLRenderer._ensure_field_textures`.
"""
total = 0
for part in assets.parts:
vertices = int(part.positions_sensor_m.shape[0])
indices = int(part.triangles.size)
total += vertices * STRIDE_FLOATS * 4 + indices * 4
if part.part_id == dynamic_part_id:
# A second copy for the frame in flight.
# A second copy for the frame in flight. Only ever re-written when a display
# deformation actually moves the vertices (``RenderFrame.deformed``).
total += vertices * STRIDE_FLOATS * 4
total += LUT_SIZE * 4
height, width = int(assets.shape_hw[0]), int(assets.shape_hw[1])
total += max(height, 0) * max(width, 0) * (FIELD_BYTES_PER_TEXEL + VALID_BYTES_PER_TEXEL)
return total
def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
def rest_surface(assets: SceneAssets, identity: Any, *, ramp: str = "") -> RenderFrame:
"""The asset's own geometry, with nothing measured.
Used to *clear* a measurement — after a refused frame, on connect, or on a
@@ -419,7 +475,7 @@ def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
"""
from orisys_app_shared.scene3d.composer import build_rest_frame
return build_rest_frame(assets, identity)
return build_rest_frame(assets, identity, ramp=ramp)
# ------------------------------------------------------------------- dispatch
@@ -543,9 +599,20 @@ def _leading_int(text: str) -> int:
class _PartBuffers:
"""One part's GPU state. Kept dumb on purpose: no Qt, no ownership games."""
__slots__ = ("part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices")
__slots__ = (
"ebo",
"part_id",
"role",
"rows",
"triangles",
"vao",
"vbo",
"vertices"
)
def __init__(self, part: MeshPart, vao: int, vbo: int, ebo: int) -> None:
def __init__(
self, part: MeshPart, vao: int, vbo: int, ebo: int, rows: np.ndarray
) -> None:
self.part_id = part.part_id
self.role = part.role
self.vao = vao
@@ -553,23 +620,34 @@ class _PartBuffers:
self.ebo = ebo
self.triangles = int(part.triangles.shape[0])
self.vertices = int(part.positions_sensor_m.shape[0])
#: The interleaved rows as uploaded. A display deformation rewrites positions
#: and normals into a copy of this; nothing else ever re-uploads a vertex.
self.rows = rows
class GLRenderer:
"""Shaders, buffers and draw calls for one scene."""
def __init__(self) -> None:
self._capabilities: Optional[GLCapabilities] = None
self._capabilities: GLCapabilities | None = None
self._programs: dict[str, int] = {}
self._uniforms: dict[str, dict[str, int]] = {}
self._parts: dict[str, _PartBuffers] = {}
self._lut: int = 0
self._lut_id: str = ""
#: Range of the valid samples of the frame currently uploaded, for the
#: The ramp the LUT was built from, so a theme change rebuilds it once.
self._lut_ramp: str = ""
#: The two field textures. Created once, sub-uploaded per frame: no per-frame
#: allocation and no per-frame vertex traffic.
self._field_texture: int = 0
self._valid_texture: int = 0
self._field_shape: tuple[int, int] | None = None
self._field_internal_format: int = 0
#: Range of the valid samples of the field currently uploaded, for the
#: automatic colour scale. ``None`` until a frame carries valid data.
self._scalar_range: Optional[tuple[float, float]] = None
self._scalar_range: tuple[float, float] | None = None
#: The colour bounds of the last draw and how they were chosen.
self._last_scale: Optional[tuple[float, float]] = None
self._last_scale: tuple[float, float] | None = None
self._last_scale_mode = ""
self._sensing_part_id: str = ""
self._estimated_gpu_bytes: int = 0
@@ -621,8 +699,11 @@ class GLRenderer:
self._scalar_range = None
self._last_scale = None
self._last_scale_mode = ""
uvs = np.asarray(assets.binding.uvs, dtype=np.float32)
for part in assets.parts:
vertices, indices = pack_scene_part(part)
vertices, indices = pack_scene_part(
part, uvs=uvs if part.role == "sensing" else None
)
vao = int(GL.glGenVertexArrays(1))
vbo = int(GL.glGenBuffers(1))
ebo = int(GL.glGenBuffers(1))
@@ -639,13 +720,24 @@ class GLRenderer:
GL.glBindVertexArray(0)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, 0)
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo)
self._parts[part.part_id] = _PartBuffers(part, vao, vbo, ebo, vertices)
self._estimated_gpu_bytes = estimate_gpu_bytes(
assets, dynamic_part_id=self._sensing_part_id
)
def update_surface(self, frame: RenderFrame) -> None:
"""Update the sensing part's vertices in place: no index rebuild, ever."""
def update_field(self, frame: RenderFrame) -> None:
"""Upload this frame's field as two textures. No vertex traffic.
This replaced ``update_surface``, which interleaved a per-vertex scalar and
validity flag and pushed the whole vertex buffer through
``glBufferSubData`` on every frame. The measurement no longer has anything to
do with the vertices: the field goes into two textures on the domain's own
grid and the fragment reads it through the UV that was already in the buffer.
A *display deformation* still moves vertices, so that case — and only that
case, ``frame.deformed`` — re-uploads the sensing part's rows. No adapter in
this build publishes a displacement, so the application never takes it.
"""
from OpenGL import GL
self._require_initialized()
@@ -655,26 +747,132 @@ class GLRenderer:
"geometry_mismatch",
f"the scene has no part {frame.sensing_part_id!r} to update",
)
rows = pack_part(
_PartLayout(frame.sensing_part_id, frame.positions_sensor_m, frame.normals_sensor),
scalar=frame.scalar,
valid=frame.valid,
)
if int(rows.shape[0]) != buffers.vertices:
field = frame.field
if int(buffers.vertices) != int(frame.positions_sensor_m.shape[0]):
raise error(
"geometry_mismatch",
f"{buffers.part_id}: {rows.shape[0]} vertices for "
f"{buffers.part_id}: {frame.positions_sensor_m.shape[0]} vertices for "
f"{buffers.vertices} uploaded",
)
started = time.perf_counter()
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
# The field carries the theme's ramp, so the lookup table follows the same
# string the 2D canvas built its table from. Checked here rather than plumbed
# through the view: whatever field this renderer is handed is what it draws.
self.set_ramp(getattr(field, "ramp", "") or "")
if frame.deformed:
rows = np.array(buffers.rows, copy=True)
rows[:, 0:3] = np.asarray(frame.positions_sensor_m, dtype=np.float32)
rows[:, 3:6] = np.asarray(frame.normals_sensor, dtype=np.float32)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, buffers.vbo)
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self._upload_field_textures(field)
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
measured = _valid_scalar_range(frame.scalar, frame.valid)
measured = _valid_scalar_range(field.scalar, field.valid)
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
self._upload_overlays(frame)
def _upload_field_textures(self, field: Any) -> None:
"""``glTexSubImage2D`` of the scalar and validity channels.
The scalar is ``R16F`` (``R32F`` when the driver will not take a half float) and
is filtered linearly, so the picture is smooth rather than blocky when the plate
is magnified. The validity is ``R8`` with ``NEAREST`` filtering, because a
validity that a linear filter could blend is exactly the "partly valid triangle"
that used to be painted grey.
**A texel with no measurement must not contribute to a filtered sample.** Linear
filtering reaches one texel in each direction, so every invalid texel adjacent to
the island is given the mean of its valid neighbours (a one-texel edge extension).
Without it a valid fragment on the island's rim blended its own measurement with
a placeholder's zero and was drawn in a colour that is neither the 2D pixel's nor
the invalid colour — measured on the shipped asset as a texel painted both valid
and not, depending on which fragment landed on it. One texel of margin is exactly
enough for a 2x2 filter, and the validity channel is untouched, so the painted
*region* is still decided per texel.
"""
from OpenGL import GL
shape = (int(field.shape_hw[0]), int(field.shape_hw[1]))
self._ensure_field_textures(shape)
measured = np.asarray(field.scalar, dtype=np.float32)
usable = np.asarray(field.valid, dtype=bool) & np.isfinite(measured)
scalars = np.where(usable, measured, 0.0).astype(np.float32)
if usable.any() and not usable.all():
scalars = np.where(usable, scalars, _extend_edges(scalars, usable))
valid = np.ascontiguousarray(np.asarray(field.valid, dtype=bool).astype(np.uint8) * 255)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
GL.glTexSubImage2D(
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
GL.GL_RED, GL.GL_FLOAT, np.ascontiguousarray(scalars),
)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
GL.glTexSubImage2D(
GL.GL_TEXTURE_2D, 0, 0, 0, shape[1], shape[0],
GL.GL_RED, GL.GL_UNSIGNED_BYTE, valid,
)
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
def _ensure_field_textures(self, shape: tuple[int, int]) -> None:
"""Allocate the two field textures once, for one domain shape."""
from OpenGL import GL
if self._field_texture and self._field_shape == shape:
return
self._release_field_textures()
height, width = int(shape[0]), int(shape[1])
if height <= 0 or width <= 0:
raise error(
"field_domain_mismatch", f"the field has no extent: {height}x{width}"
)
capabilities = self._capabilities
if capabilities is not None and capabilities.max_texture_size:
if width > capabilities.max_texture_size or height > capabilities.max_texture_size:
raise error(
"gpu_budget_exceeded",
f"the field is {width}x{height} but this GPU takes at most "
f"{capabilities.max_texture_size} texels per side",
)
self._field_texture = int(GL.glGenTextures(1))
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
# ``R16F`` is the size the design asks for and every 3.3/ES3 context has it;
# a driver that refuses it raises, and ``R32F`` is the documented fallback
# rather than a silent no-texture render.
for internal_format in (GL.GL_R16F, GL.GL_R32F):
try:
GL.glTexImage2D(
GL.GL_TEXTURE_2D, 0, internal_format, width, height, 0,
GL.GL_RED, GL.GL_FLOAT, None,
)
self._field_internal_format = internal_format
break
except Exception: # noqa: BLE001 - a refused internal format
while GL.glGetError() != GL.GL_NO_ERROR:
pass
if not self._field_internal_format:
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
self._release_field_textures()
raise error(
"gl_unsupported",
"this context accepts neither R16F nor R32F for the field texture",
)
self._valid_texture = int(GL.glGenTextures(1))
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
GL.glTexImage2D(
GL.GL_TEXTURE_2D, 0, GL.GL_R8, width, height, 0,
GL.GL_RED, GL.GL_UNSIGNED_BYTE, None,
)
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
self._field_shape = (height, width)
def _upload_overlays(self, frame: RenderFrame) -> None:
"""Upload merged flow / contact arrow meshes for this frame."""
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
@@ -737,8 +935,12 @@ class GLRenderer:
GL.glDeleteVertexArrays(1, [int(vao)])
GL.glDeleteBuffers(1, [int(vbo)])
GL.glDeleteBuffers(1, [int(ebo)])
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001 - best-effort delete, reported below
# A context that has already been lost refuses the delete, and this also
# runs on the live overlay-replacement path: raising here would replace a
# frame with a crash. Reported rather than swallowed, because a delete
# that keeps failing leaks GPU memory for the rest of the session.
logger.warning("could not free the %s overlay's GL objects: %s", kind, exc)
def draw(
self,
@@ -768,7 +970,9 @@ class GLRenderer:
# panel has to be able to ask what the picture means.
self._last_scale = (float(lower), float(upper))
self._last_scale_mode = str(getattr(scale, "mode", "") or "")
self._ensure_lut("diverging" if (scale and scale.descriptor.signed) else "sequential")
self._ensure_lut(
"diverging" if (scale and scale.descriptor.signed) else "sequential"
)
for part in self._parts.values():
if part.role == "housing" and not settings.housing_visible:
continue
@@ -781,10 +985,7 @@ class GLRenderer:
_set_float(GL, locations.get("u_lower"), lower)
_set_float(GL, locations.get("u_upper"), upper)
_set_vec4(GL, locations.get("u_invalid_color"), INVALID_COLOR)
if locations.get("u_lut") is not None:
GL.glActiveTexture(GL.GL_TEXTURE0)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
GL.glUniform1i(locations["u_lut"], 0)
self._bind_field_textures(GL, locations)
else:
_set_vec3(GL, locations.get("u_light_direction"), (0.3, -0.5, 0.8))
_set_vec4(GL, locations.get("u_base_color"), _base_color(part.part_id))
@@ -833,10 +1034,53 @@ class GLRenderer:
scale_mode=self._last_scale_mode,
)
def effective_scale(self) -> Optional[tuple[float, float]]:
def effective_scale(self) -> tuple[float, float] | None:
"""The colour bounds the last draw actually used (``None`` before any draw)."""
return self._last_scale
def set_ramp(self, ramp: str) -> None:
"""Adopt the theme's ramp. Rebuilds the LUT at the next draw, once.
The ramp is the *only* thing the 3D pane needs from the theme: it is what
makes its colour table the same table the 2D canvas paints with.
"""
wanted = str(ramp or "")
if wanted != self._lut_ramp:
self._lut_ramp = wanted
self._lut_id = ""
def _bind_field_textures(self, GL: Any, locations: dict[str, int]) -> None:
if locations.get("u_lut") is not None:
GL.glActiveTexture(GL.GL_TEXTURE0 + LUT_TEXTURE_UNIT)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
GL.glUniform1i(locations["u_lut"], LUT_TEXTURE_UNIT)
if locations.get("u_field") is not None:
GL.glActiveTexture(GL.GL_TEXTURE0 + FIELD_TEXTURE_UNIT)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._field_texture)
GL.glUniform1i(locations["u_field"], FIELD_TEXTURE_UNIT)
if locations.get("u_valid") is not None:
GL.glActiveTexture(GL.GL_TEXTURE0 + VALID_TEXTURE_UNIT)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._valid_texture)
GL.glUniform1i(locations["u_valid"], VALID_TEXTURE_UNIT)
if locations.get("u_inv_domain_size") is not None:
height, width = self._field_shape or (1, 1)
GL.glUniform2f(
locations["u_inv_domain_size"],
1.0 / max(float(width), 1.0),
1.0 / max(float(height), 1.0),
)
GL.glActiveTexture(GL.GL_TEXTURE0)
@property
def field_shape(self) -> tuple[int, int] | None:
"""The allocated field texture's ``(H, W)``, or ``None`` before the first frame."""
return self._field_shape
@property
def field_internal_format(self) -> int:
"""The internal format the scalar texture actually got (``R16F`` or ``R32F``)."""
return self._field_internal_format
def release(self) -> None:
"""Delete every GPU resource. Idempotent; the owning context must be current."""
from OpenGL import GL
@@ -858,6 +1102,7 @@ class GLRenderer:
pass
self._lut = 0
self._lut_id = ""
self._release_field_textures()
self._initialized = False
self._estimated_gpu_bytes = 0
self._scalar_range = None
@@ -865,6 +1110,20 @@ class GLRenderer:
self._last_scale_mode = ""
self._paint_calls = 0
def _release_field_textures(self) -> None:
from OpenGL import GL
for name in ("_field_texture", "_valid_texture"):
handle = int(getattr(self, name))
if handle:
try:
GL.glDeleteTextures([handle])
except Exception: # noqa: BLE001
pass
setattr(self, name, 0)
self._field_shape = None
self._field_internal_format = 0
def release_parts(self) -> None:
from OpenGL import GL
@@ -890,7 +1149,7 @@ class GLRenderer:
if self._lut:
GL.glDeleteTextures([int(self._lut)])
self._lut = 0
data = lut_rgba(lut_id)
data = lut_rgba(lut_id, self._lut_ramp)
self._lut = int(GL.glGenTextures(1))
GL.glBindTexture(GL.GL_TEXTURE_2D, self._lut)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
@@ -904,7 +1163,7 @@ class GLRenderer:
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
self._lut_id = lut_id
def capabilities(self) -> Optional[GLCapabilities]:
def capabilities(self) -> GLCapabilities | None:
return self._capabilities
@property
@@ -916,17 +1175,6 @@ class GLRenderer:
return self._initialized
class _PartLayout:
"""A minimal stand-in for :class:`MeshPart` when updating an existing buffer."""
__slots__ = ("part_id", "positions_sensor_m", "normals_sensor")
def __init__(self, part_id: str, positions: np.ndarray, normals: np.ndarray) -> None:
self.part_id = part_id
self.positions_sensor_m = positions
self.normals_sensor = normals
#: Base colours for non-sensing parts, by part id: the housing is grey/graphite so
#: it never competes with the measurement colour scale.
_PART_COLORS: dict[str, tuple[float, float, float, float]] = {
@@ -939,15 +1187,23 @@ def _base_color(part_id: str) -> tuple[float, float, float, float]:
def _bind_attributes(GL: Any) -> None:
"""Position, normal and the UV the field texture is addressed by.
Attribute locations 2 and 3 are deliberately left unbound: they were the
per-vertex scalar and validity flags, and nothing in the surface program declares
them any more. Re-using 2 or 3 for something else later would be a silent
reinterpretation of a slot an old shader variant could still expect, so the UV
goes to 4 instead.
"""
stride = STRIDE_FLOATS * 4
GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
GL.glEnableVertexAttribArray(1)
GL.glVertexAttribPointer(1, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
GL.glEnableVertexAttribArray(2)
GL.glVertexAttribPointer(2, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(6))
GL.glEnableVertexAttribArray(3)
GL.glVertexAttribPointer(3, 1, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(7))
GL.glEnableVertexAttribArray(4)
GL.glVertexAttribPointer(
4, 2, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(UV_OFFSET_FLOATS)
)
def _offset(floats: int) -> Any:
@@ -985,8 +1241,9 @@ def _compile_program(GL: Any, profile: Profile, name: str) -> int:
def _uniform_locations(GL: Any, program: int) -> dict[str, int]:
names = (
"u_view", "u_projection", "u_scalar", "u_lower", "u_upper",
"u_invalid_color", "u_lut", "u_light_direction", "u_base_color",
"u_view", "u_projection", "u_lower", "u_upper",
"u_invalid_color", "u_lut", "u_field", "u_valid", "u_inv_domain_size",
"u_light_direction", "u_base_color",
)
out: dict[str, int] = {}
for name in names:
@@ -1002,7 +1259,7 @@ def _decode(value: Any) -> str:
return str(value).strip()
def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None:
def _set_mat4(GL: Any, location: int | None, values: np.ndarray) -> None:
if location is None:
return
# Column-major upload, explicitly: the layout of the NumPy array must not
@@ -1010,17 +1267,17 @@ def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None:
GL.glUniformMatrix4fv(location, 1, GL.GL_FALSE, np.ascontiguousarray(values.T))
def _set_float(GL: Any, location: Optional[int], value: float) -> None:
def _set_float(GL: Any, location: int | None, value: float) -> None:
if location is not None:
GL.glUniform1f(location, float(value))
def _set_vec3(GL: Any, location: Optional[int], values: tuple[float, float, float]) -> None:
def _set_vec3(GL: Any, location: int | None, values: tuple[float, float, float]) -> None:
if location is not None:
GL.glUniform3f(location, float(values[0]), float(values[1]), float(values[2]))
def _set_vec4(GL: Any, location: Optional[int], values: tuple[float, float, float, float]) -> None:
def _set_vec4(GL: Any, location: int | None, values: tuple[float, float, float, float]) -> None:
if location is not None:
GL.glUniform4f(
location, float(values[0]), float(values[1]), float(values[2]), float(values[3])
@@ -16,9 +16,10 @@ paths and booleans so a recording can say how a package was authenticated.
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping, Optional
from typing import Any
from orisys_app_shared.scene3d.contracts import Diagnostic, diagnostic
@@ -36,7 +37,7 @@ PREF_REQUIRE_SIGNATURE = "scene3d_require_signature"
class SceneKeys:
"""What the loader needs to authenticate a package — and how strict to be."""
verify_key_pem: Optional[bytes] = None
verify_key_pem: bytes | None = None
provider: Any = None
require_signature: bool = False
#: Paths/ids only, for logs and for the recording: never material.
@@ -59,7 +60,7 @@ class SceneKeys:
}
def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
def resolve_scene_keys(prefs: Mapping[str, Any] | None) -> SceneKeys:
"""Build the key material the preferences describe. Never raises for absence.
A missing or unreadable file is a *diagnostic-bearing state*, not a crash: the 2D
@@ -68,7 +69,7 @@ def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
"""
prefs = dict(prefs or {})
sources: dict[str, str] = {}
verify_pem: Optional[bytes] = None
verify_pem: bytes | None = None
provider = None
verify_path = str(prefs.get(PREF_VERIFY_KEY) or "").strip()
@@ -113,7 +114,7 @@ def resolve_scene_keys(prefs: Optional[Mapping[str, Any]]) -> SceneKeys:
)
def check_release_requirement(path: Path, keys: Optional[SceneKeys]) -> Optional[Diagnostic]:
def check_release_requirement(path: Path, keys: SceneKeys | None) -> Diagnostic | None:
"""Refuse an unsigned package when the application is in release mode.
Development builds accept a plain package (the shipped engineering one is exactly
+52 -28
View File
@@ -18,10 +18,20 @@ Two rules it exists to enforce in the interface:
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
import numpy as np
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import (
ColorScale,
Diagnostic,
DisplaySettings,
FieldDescriptor,
RenderStats,
SceneAssets,
SceneCapabilities,
SceneState,
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QImage
from PySide6.QtWidgets import (
@@ -38,18 +48,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import (
ColorScale,
Diagnostic,
DisplaySettings,
FieldDescriptor,
RenderStats,
SceneAssets,
SceneCapabilities,
SceneState,
)
from orisys_studio.scene3d.prefs import FPS_CHOICES, DEFAULT_FIELD
from orisys_studio.scene3d.prefs import DEFAULT_FIELD, FPS_CHOICES
from orisys_studio.ui.fit_text import ShrinkableLabel
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
@@ -82,7 +81,7 @@ class ColorScaleBar(QLabel):
because in automatic mode the spin boxes beside it held values nobody was using.
"""
def __init__(self, parent: Optional[QWidget] = None) -> None:
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("colorScaleBar")
self.setFixedHeight(12)
@@ -90,6 +89,9 @@ class ColorScaleBar(QLabel):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setVisible(False)
self._lut_id = "sequential"
#: The theme's own ramp, so this legend is drawn from the same table the 2D
#: canvas and the 3D surface are coloured by.
self._ramp = ""
def set_lut(self, lut_id: str) -> None:
wanted = "diverging" if str(lut_id) == "diverging" else "sequential"
@@ -98,6 +100,13 @@ class ColorScaleBar(QLabel):
self._lut_id = wanted
self.update()
def set_ramp(self, ramp: str) -> None:
wanted = str(ramp or "")
if wanted == self._ramp:
return
self._ramp = wanted
self.update()
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
from PySide6.QtGui import QPainter
@@ -105,7 +114,9 @@ class ColorScaleBar(QLabel):
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
# which is what QImage's borrowed buffer needs.
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
rgba = np.ascontiguousarray(
lut_rgba(self._lut_id, self._ramp)[:, 0, :]
).tobytes()
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
painter = QPainter(self)
painter.drawImage(self.rect(), image)
@@ -124,23 +135,23 @@ class ThreeDPanel(QFrame):
self,
coordinator: ThreeDCoordinator,
widget: Sensor3DWidget,
parent: Optional[QWidget] = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._coordinator = coordinator
self._widget = widget
self._assets: Optional[SceneAssets] = None
self._descriptor: Optional[FieldDescriptor] = None
self._assets: SceneAssets | None = None
self._descriptor: FieldDescriptor | None = None
self._state: SceneState = "disabled"
self._source_kind = "live"
self._syncing = False
#: What the data source can provide (see ``contracts.SceneCapabilities``): an
#: unavailable capability disables its control and says why, rather than
#: letting the operator operate an input that does not exist.
self._capabilities: Optional[SceneCapabilities] = None
self._capabilities: SceneCapabilities | None = None
#: The bounds the last draw actually used (the widget's RenderStats).
self._effective_scale: Optional[RenderStats] = None
self._effective_scale: RenderStats | None = None
#: Whether the pane explains the pipeline around the picture (the window's
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
self._show_diagnostics = False
@@ -180,8 +191,9 @@ class ThreeDPanel(QFrame):
self.housing_check = QCheckBox()
# Checked to match ``DisplaySettings.housing_visible``'s default: the panel's
# starting state and the settings it reports must be the same thing.
self.housing_check.setChecked(True)
# starting state and the settings it reports must be the same thing. That default is
# now unchecked — the housing is a mechanical check, not the measurement.
self.housing_check.setChecked(False)
self.housing_check.toggled.connect(self._emit_settings)
form.addRow(self.housing_check)
@@ -342,7 +354,7 @@ class ThreeDPanel(QFrame):
self.set_state("disabled")
# ------------------------------------------------------------------ inputs
def set_scene(self, assets: Optional[SceneAssets]) -> None:
def set_scene(self, assets: SceneAssets | None) -> None:
"""Fill the preset list from the asset: its default view is the asset's."""
self._assets = assets
self._syncing = True
@@ -364,7 +376,7 @@ class ThreeDPanel(QFrame):
self.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count()))
self.reset_button.setEnabled(assets is not None)
def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None:
def set_descriptor(self, descriptor: FieldDescriptor | None) -> None:
"""The measurement currently on screen — or none, which hides the bar."""
if descriptor != self._descriptor:
self._effective_scale = None
@@ -392,7 +404,19 @@ class ThreeDPanel(QFrame):
self._render_scale_bounds()
self._render_legend()
def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None:
def set_ramp(self, ramp: str) -> None:
"""Adopt the theme's resolved colour ramp for the legend.
The legend and the picture are drawn from one table
(``gl_backend.lut_rgba``), and that table is built from this ramp — the same
string the 2D canvas colours its pixels from. Without this the 3D pane's
legend would keep the default theme's colours after a theme switch while the
surface (which gets the ramp on the frame it is handed) changed, which is the
disagreement the one-ramp change exists to remove.
"""
self.scale_bar.set_ramp(ramp)
def set_capabilities(self, capabilities: SceneCapabilities | None) -> None:
"""Offer only the controls this data source can actually feed.
Today's adapters colour a scalar in place and publish no displacement at all, so
@@ -426,7 +450,7 @@ class ThreeDPanel(QFrame):
else t("scene3d.contact_arrow_tip")
)
def capabilities(self) -> Optional[SceneCapabilities]:
def capabilities(self) -> SceneCapabilities | None:
return self._capabilities
def _render_legend(self) -> None:
@@ -526,7 +550,7 @@ class ThreeDPanel(QFrame):
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
self.source_label.setVisible(self._show_diagnostics)
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
def set_diagnostic(self, diagnostic: Diagnostic | None) -> None:
"""One diagnostic line. The 2D views stay usable behind it.
Warnings and errors are always stated: they are claims about the *measurement*
@@ -19,13 +19,15 @@ file cannot relabel a measurement as another quantity.
from __future__ import annotations
from typing import Any, Mapping, Optional
import math
from collections.abc import Mapping
from typing import Any
from orisys_app_shared.scene3d.contracts import (
UNAVAILABLE_DESCRIPTOR,
ColorScale,
DisplaySettings,
FieldDescriptor,
UNAVAILABLE_DESCRIPTOR,
)
#: Frame rates the pane offers. A saved value outside this set is repaired to the
@@ -51,7 +53,7 @@ def _number(value: Any, fallback: float) -> float:
number = float(value)
except (TypeError, ValueError):
return fallback
if number != number or number in (float("inf"), float("-inf")): # NaN / inf
if math.isnan(number) or number in (float("inf"), float("-inf")): # NaN / inf
return fallback
return number
@@ -102,9 +104,9 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
def settings_from_prefs(
raw: Optional[Mapping[str, Any]],
raw: Mapping[str, Any] | None,
*,
descriptor: Optional[FieldDescriptor] = None,
descriptor: FieldDescriptor | None = None,
) -> DisplaySettings:
"""Rebuild the display settings, repairing anything unusable.
@@ -121,7 +123,7 @@ def settings_from_prefs(
field = str(raw.get("field") or DEFAULT_FIELD)
if field not in FIELD_CHOICES:
field = DEFAULT_FIELD
scale: Optional[ColorScale] = None
scale: ColorScale | None = None
saved = raw.get("color_scale")
if isinstance(saved, Mapping):
mode = str(saved.get("mode") or "fixed")
@@ -158,7 +160,7 @@ def settings_from_prefs(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
color_scale=scale,
housing_visible=_flag(raw.get("housing_visible"), True),
housing_visible=_flag(raw.get("housing_visible"), False),
max_fps=max_fps,
field=field,
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
@@ -14,8 +14,9 @@ widgets — and one set of signals — whether or not the dialog has ever been o
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
@@ -25,8 +26,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
if TYPE_CHECKING: # the panel is passed in; only its interfaces are used here
from orisys_studio.scene3d.panel import ThreeDPanel
@@ -35,7 +34,7 @@ class Scene3dSettingsDialog(QDialog):
"""A modeless home for the 3D pane's controls."""
def __init__(
self, panel: "ThreeDPanel", parent: Optional[QWidget] = None
self, panel: ThreeDPanel, parent: QWidget | None = None
) -> None:
super().__init__(parent)
self.setObjectName("scene3dSettings")
+76 -33
View File
@@ -25,11 +25,9 @@ description disagrees with the asset is refused with both shapes named.
from __future__ import annotations
import logging
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Optional
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QVBoxLayout, QWidget
from typing import Any
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
@@ -47,6 +45,9 @@ from orisys_app_shared.scene3d.contracts import (
diagnostic,
validate_assets,
)
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QVBoxLayout, QWidget
from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
@@ -90,17 +91,24 @@ class Scene3DView(QWidget):
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
enabledChanged = Signal(bool)
def __init__(self, parent: Optional[QWidget] = None) -> None:
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._available, self._reason = gl_available()
self._widget: Optional[Sensor3DWidget] = None
self._panel: Optional[ThreeDPanel] = None
self._coordinator: Optional[ThreeDCoordinator] = None
self._widget: Sensor3DWidget | None = None
self._panel: ThreeDPanel | None = None
self._coordinator: ThreeDCoordinator | None = None
self._camera = OrbitCamera()
self._executor: Optional[ThreadPoolExecutor] = None
self._assets: Optional[SceneAssets] = None
self._expected: Optional[ExpectedGeometry] = None
self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None
self._executor: ThreadPoolExecutor | None = None
self._assets: SceneAssets | None = None
self._composer: SceneComposer | None = None
#: The session's island latch, shared with the 2D canvas when the window hands
#: one over (``image_backend.DomainMaskLatch``). One latch, two views: the
#: surface's validity texture and the canvas's island are the same mask, so
#: they cannot drift apart. ``None`` means "no session": the adapter then uses
#: each frame's own mask, which is what the tests and the PoC want.
self._domain_latch: Any = None
self._expected: ExpectedGeometry | None = None
self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None
#: The adapter behind ``_snapshot``: it owns the reason a packet was refused,
#: and (for a source that cannot provide a displacement) what the pane may
#: offer. Kept rather than closed over so those answers can be asked.
@@ -108,11 +116,11 @@ class Scene3DView(QWidget):
self._source_id = ""
self._enabled = False
self._source_kind = "live"
self._last_diagnostic: Optional[Diagnostic] = None
self._last_descriptor: Optional[FieldDescriptor] = None
self._last_stats: Optional[RenderStats] = None
self._last_diagnostic: Diagnostic | None = None
self._last_descriptor: FieldDescriptor | None = None
self._last_stats: RenderStats | None = None
#: The coordinator's last diagnostic as *this* view has already reported it.
self._reported_diagnostic: Optional[Diagnostic] = None
self._reported_diagnostic: Diagnostic | None = None
#: Whether the loaded asset was compared against the device's own description.
self._device_verified = False
#: Frames the producer did not even build (a frame was already waiting).
@@ -136,8 +144,9 @@ class Scene3DView(QWidget):
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="scene3d")
self._widget = Sensor3DWidget(GLRenderer(), self._camera)
self._composer = SceneComposer()
self._coordinator = ThreeDCoordinator(
self._widget, SceneComposer(), self._executor, max_fps=30
self._widget, self._composer, self._executor, max_fps=30
)
self._panel = ThreeDPanel(self._coordinator, self._widget)
# The picture must not be squeezed out of existence by the controls above it:
@@ -165,7 +174,7 @@ class Scene3DView(QWidget):
self,
assets: SceneAssets,
source_id: str,
expected: Optional[ExpectedGeometry] = None,
expected: ExpectedGeometry | None = None,
) -> bool:
"""Adopt a validated scene. Returns whether the view accepted it.
@@ -234,8 +243,8 @@ class Scene3DView(QWidget):
return not violations
def _make_snapshot(
self, assets: SceneAssets, expected: Optional[ExpectedGeometry]
) -> Optional[Callable[[Any, FrameIdentity], Any]]:
self, assets: SceneAssets, expected: ExpectedGeometry | None
) -> Callable[[Any, FrameIdentity], Any] | None:
"""Bind the adapter that fits this scene, or refuse with a diagnostic.
The two adapters have different call signatures because they need different
@@ -253,8 +262,7 @@ class Scene3DView(QWidget):
asset_id = str(getattr(assets, "asset_id", "") or "")
self._adapter = None
device = expected
common = {
"geometry_id": str(
common = { "geometry_id": str(
device.geometry_id if device is not None and device.geometry_id
else assets.geometry_id
),
@@ -285,9 +293,44 @@ class Scene3DView(QWidget):
# (measured: apps/studio/tests/test_scene3d_adapters.py), so no remap is
# passed here. Remapping it again would index the flow at source pixels.
adapter = FingerFrameAdapter(**common)
if self._domain_latch is not None:
# The window's own latch, so the island the 2D canvas paints and the island
# the surface's validity texture carries are one decision, taken once.
adapter.domain_latch = self._domain_latch
self._adapter = adapter
return lambda packet, identity: adapter.snapshot(packet, None, identity)
def set_domain_latch(self, latch: Any) -> None:
"""Share the 2D canvas's island latch with this pane's adapter.
``image_backend.DomainMaskLatch`` is *upstream of both views*: the canvas paints
the latched boundary and the adapter marks exactly that boundary valid, so
"which pixels carry a measurement" is one answer rather than two that happen to
agree. Called before the scene is resolved in practice (the window builds the
latch first); re-binding an already-built adapter updates it in place.
"""
self._domain_latch = latch
if self._adapter is not None and hasattr(self._adapter, "domain_latch"):
self._adapter.domain_latch = latch
def set_theme(self, theme: str) -> None:
"""Adopt the application theme's colour ramp.
The 2D canvas colours its pixels from ``arrows.flow_colormap(theme)``; the 3D
surface is coloured from whatever ramp the field it draws carries. Setting both
from this one call is what makes them the *same* table rather than two tables
that were written to look alike.
"""
from orisys_app_shared.acquisition.arrows import ramp_for_theme
ramp = ramp_for_theme(theme)
if self._composer is not None:
self._composer.ramp = ramp
if self._panel is not None:
self._panel.set_ramp(ramp)
if self._coordinator is not None:
self._coordinator.set_ramp(ramp)
# --------------------------------------------------------------- producer
def submit_packet(self, packet: Any) -> None:
"""Called from the acquisition thread. Must stay O(1) and Qt-free."""
@@ -316,12 +359,12 @@ class Scene3DView(QWidget):
except Exception: # noqa: BLE001 - a display sink must never break capture
logger.exception("the 3D sink failed for one packet")
def _snapshot_diagnostic(self) -> Optional[Diagnostic]:
def _snapshot_diagnostic(self) -> Diagnostic | None:
"""Why the adapter refused the last packet (the adapter owns the answer)."""
item = getattr(self._adapter, "last_diagnostic", None)
return item if isinstance(item, Diagnostic) else None
def identity_for(self, packet: Any) -> Optional[FrameIdentity]:
def identity_for(self, packet: Any) -> FrameIdentity | None:
"""The identity a field frame built from this packet would carry."""
if self._coordinator is None or self._assets is None:
return None
@@ -406,7 +449,7 @@ class Scene3DView(QWidget):
self._panel.set_source_kind(kind)
self._publish_capabilities()
def capabilities(self) -> Optional[SceneCapabilities]:
def capabilities(self) -> SceneCapabilities | None:
"""What this data source can provide, for the panel's controls.
Read off the adapter that is actually bound: an adapter that colours a scalar in
@@ -496,27 +539,27 @@ class Scene3DView(QWidget):
return self._enabled
@property
def assets(self) -> Optional[SceneAssets]:
def assets(self) -> SceneAssets | None:
return self._assets
@property
def coordinator(self) -> Optional[ThreeDCoordinator]:
def coordinator(self) -> ThreeDCoordinator | None:
return self._coordinator
@property
def gl_widget(self) -> Optional[Sensor3DWidget]:
def gl_widget(self) -> Sensor3DWidget | None:
return self._widget
@property
def panel(self) -> Optional[ThreeDPanel]:
def panel(self) -> ThreeDPanel | None:
return self._panel
@property
def last_diagnostic(self) -> Optional[Diagnostic]:
def last_diagnostic(self) -> Diagnostic | None:
return self._last_diagnostic
@property
def last_descriptor(self) -> Optional[FieldDescriptor]:
def last_descriptor(self) -> FieldDescriptor | None:
return self._last_descriptor
def state(self) -> SceneState:
@@ -526,7 +569,7 @@ class Scene3DView(QWidget):
return "disabled"
return self._coordinator.state
def render_stats(self) -> Optional[RenderStats]:
def render_stats(self) -> RenderStats | None:
return self._last_stats
def describe(self) -> dict:
@@ -709,7 +752,7 @@ class Scene3DView(QWidget):
self._panel.set_state(state)
self.stateChanged.emit(state)
def _publish_coordinator_diagnostic(self, item: Optional[Diagnostic]) -> None:
def _publish_coordinator_diagnostic(self, item: Diagnostic | None) -> None:
"""Raise a coordinator diagnostic once, when it changes."""
if item is None or item == self._reported_diagnostic:
return
+21 -20
View File
@@ -17,23 +17,24 @@ two-dimensional application carries on.
from __future__ import annotations
import time
from typing import Optional
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QMouseEvent, QWheelEvent
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import QWidget
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
DisplaySettings,
FrameIdentity,
GLCapabilities,
RenderFrame,
RenderStats,
SceneAssets,
diagnostic,
)
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QMouseEvent, QWheelEvent
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import QWidget
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import (
GLDispatch,
@@ -62,27 +63,27 @@ class Sensor3DWidget(QOpenGLWidget):
self,
renderer: GLRenderer,
camera: OrbitCamera,
parent: Optional[QWidget] = None,
parent: QWidget | None = None,
*,
dispatch: Optional[GLDispatch] = None,
dispatch: GLDispatch | None = None,
) -> None:
super().__init__(parent)
self._renderer = renderer
self._camera = camera
self._dispatch = dispatch or GLDispatch()
self._assets: Optional[SceneAssets] = None
self._frame: Optional[RenderFrame] = None
self._assets: SceneAssets | None = None
self._frame: RenderFrame | None = None
self._settings = DisplaySettings()
self._capabilities: Optional[GLCapabilities] = None
self._diagnostic: Optional[Diagnostic] = None
self._capabilities: GLCapabilities | None = None
self._diagnostic: Diagnostic | None = None
self._needs_scene_upload = False
self._needs_surface_upload = False
self._last_stats_at = 0.0
#: What reached the screen, counted by identity, and what it cost to compose.
self._unique_frames = 0
self._last_frame_identity: Optional[FrameIdentity] = None
self._last_frame_identity: FrameIdentity | None = None
self._last_compose_ms = 0.0
self._last_press: Optional[tuple[float, float]] = None
self._last_press: tuple[float, float] | None = None
self._unavailable = False
#: Frames the display never showed, set by the coordinator. Never a
#: recording figure.
@@ -109,7 +110,7 @@ class Sensor3DWidget(QOpenGLWidget):
self._last_frame_identity = frame.identity
self._unique_frames += 1
self._last_compose_ms = float(getattr(frame, "compose_ms", 0.0) or 0.0)
self.descriptorChanged.emit(frame.scalar_descriptor)
self.descriptorChanged.emit(frame.field.descriptor)
self.update()
def set_settings(self, settings: DisplaySettings) -> None:
@@ -119,7 +120,7 @@ class Sensor3DWidget(QOpenGLWidget):
def camera(self) -> OrbitCamera:
return self._camera
def capabilities(self) -> Optional[GLCapabilities]:
def capabilities(self) -> GLCapabilities | None:
return self._capabilities
def displayed_frames(self) -> int:
@@ -130,7 +131,7 @@ class Sensor3DWidget(QOpenGLWidget):
"""What the frame on screen cost to compose, measured where it was composed."""
return float(self._last_compose_ms)
def diagnostic(self) -> Optional[Diagnostic]:
def diagnostic(self) -> Diagnostic | None:
return self._diagnostic
@property
@@ -216,11 +217,11 @@ class Sensor3DWidget(QOpenGLWidget):
)
)
if self._assets is not None:
self._renderer.update_surface(
self._renderer.update_field(
rest_surface(self._assets, frame.identity)
)
else:
self._renderer.update_surface(frame)
self._renderer.update_field(frame)
stats = self._renderer.draw(
self._camera.matrices(self.viewport_pixels()),
self._settings,
@@ -24,11 +24,12 @@ from __future__ import annotations
import logging
import time
from typing import Any, Callable, Optional
from PySide6.QtCore import QObject, QTimer, Signal
from collections.abc import Callable
from typing import Any
from orisys_app_shared.i18n import t
from PySide6.QtCore import QObject, QTimer, Signal
from orisys_studio import session_metadata
logger = logging.getLogger(__name__)
@@ -58,7 +59,7 @@ class SessionController(QObject):
*,
recording: Any,
packet_for_3d: Callable[[Any], None],
packet_sink: Optional[Callable[[Any], None]] = None,
packet_sink: Callable[[Any], None] | None = None,
sensor_type: str = "box",
) -> None:
super().__init__()
@@ -82,9 +83,9 @@ class SessionController(QObject):
self._connect_slow_notified = False
self._raw_fallback_notified = False
# --- per-session telemetry (read by the rail, the plots and the record) -----
self.last_sample_valid: Optional[bool] = None
self.latest_fps: Optional[float] = None
self.latest_process_ms: Optional[float] = None
self.last_sample_valid: bool | None = None
self.latest_fps: float | None = None
self.latest_process_ms: float | None = None
self.latest_capture_fps = 0.0
self.latest_capture_dropped = 0
#: How many readouts failed this session, for the status line's count.
@@ -380,7 +381,7 @@ class SessionController(QObject):
return consumer() if callable(consumer) else None
# ------------------------------------------------------------- presenter help
def check_capture_negotiation(self) -> Optional[str]:
def check_capture_negotiation(self) -> str | None:
"""A once-per-session warning when the camera delivers less than configured.
The evidence is the *measured* capture rate, not the driver's reported one:
@@ -429,7 +430,7 @@ class SessionController(QObject):
"""The status line for a failed readout (the phase is deliberately untouched)."""
return t("status.sample_invalid", count=self.record_error_count, reason=reason)
def take_dropped_message(self, dropped: int) -> Optional[str]:
def take_dropped_message(self, dropped: int) -> str | None:
"""One message per new batch of samples the memory bound discarded."""
if int(dropped) <= self.dropped_reported or int(dropped) <= 0:
return None
@@ -25,9 +25,10 @@ from __future__ import annotations
import hashlib
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping
from typing import Any
from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping
from orisys_app_shared.record import RecordConfig
@@ -198,7 +199,8 @@ class SessionFacts:
def build_session_metadata(config: RecordConfig, facts: SessionFacts) -> dict:
"""The ``session.json`` metadata written when a recording starts."""
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared import API_VERSION
from orisys_app_shared import __version__ as shared_version
from orisys_studio import __version__ as app_version
@@ -2,13 +2,12 @@
from __future__ import annotations
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_studio import __version__ as app_version
@@ -10,9 +10,14 @@ where the picture comes from (live / replay), which words the footer.
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import QSize, Qt, Signal
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
ImagePlan,
)
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
from PySide6.QtWidgets import (
QComboBox,
@@ -23,15 +28,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
ImagePlan,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from orisys_studio.ui.fit_text import ShrinkableLabel
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
@@ -340,7 +336,7 @@ class ArrowPanel(QFrame):
self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder)
def set_plan(self, plan: Optional[ImagePlan]) -> None:
def set_plan(self, plan: ImagePlan | None) -> None:
"""Adopt what the frame on screen can be shown as.
The modes come from the frame's own fields (see ``image_backend.plan_image``),
@@ -14,13 +14,12 @@ from __future__ import annotations
import logging
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_app_shared.theme import color
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
logger = logging.getLogger(__name__)
+6 -13
View File
@@ -3,6 +3,12 @@
from __future__ import annotations
import numpy as np
from orisys_app_shared.force.calibration_io import (
ChannelKey,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir
from orisys_app_shared.theme import color
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase
from PySide6.QtWidgets import (
@@ -28,19 +34,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
ChannelKey,
meta_from_sensor_json,
)
from orisys_app_shared.force.calibration_session import (
session_filename,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir
from orisys_app_shared.theme import color
from orisys_studio.calibration_service import CalibrationService
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
@@ -7,7 +7,8 @@ the three-panel layout, while a single point can still be examined in full.
from __future__ import annotations
import numpy as np
from PySide6.QtCore import Qt
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import (
QDialog,
@@ -17,10 +18,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from orisys_app_shared.force import NA
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
+250 -41
View File
@@ -5,7 +5,19 @@ from __future__ import annotations
import time
import numpy as np
from PySide6.QtCore import Qt, QSize, Signal
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import (
DEFAULT_MIN_DEADBAND_N,
DEFAULT_MIN_DEADBAND_RAW,
ForcePresentation,
)
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.record.sample import plottable_axis_mask
from orisys_app_shared.theme import color
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PySide6.QtWidgets import (
QFrame,
@@ -15,14 +27,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import ForcePresentation
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from orisys_studio.ui import icons
from orisys_studio.ui.icons import ICON_SIZE
@@ -65,10 +69,33 @@ def _window_brush(theme: str):
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
"""Evenly spaced Y ticks that always carry a zero label when zero is inside.
The zero label is what makes "not pressing" readable: on an axis whose range
starts below zero the resting curve has no reference point without it.
"""
if hi <= lo:
return [(lo, f"{lo:g}")]
step = (hi - lo) / max(1, count - 1)
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
ticks = [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
if lo <= 0.0 <= hi and all(abs(value) > abs(step) * 1e-6 for value, _ in ticks):
ticks.append((0.0, "0"))
ticks.sort(key=lambda tick: tick[0])
return ticks
def _ordered_range(lower: float, upper: float) -> tuple[float, float]:
"""A usable ``(ymin, ymax)``: an empty or inverted range is widened upward.
Nothing stops an operator from asking for FN 5…2 N in the dialog, and an
inverted range collapses the axis in pyqtgraph. Widening keeps the chart
readable while the dialog keeps showing what was asked for.
"""
lower = float(lower)
upper = float(upper)
if upper <= lower:
upper = lower + max(0.1, abs(lower) * 0.05)
return lower, upper
def format_force(
@@ -102,11 +129,11 @@ def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
class _Sync:
"""Marks a block of range changes as programmatic (not a user gesture)."""
def __init__(self, panel: "ForcePlotsPanel") -> None:
def __init__(self, panel: ForcePlotsPanel) -> None:
self._panel = panel
self._previous = panel._syncing
def __enter__(self) -> "_Sync":
def __enter__(self) -> _Sync:
self._panel._syncing = True
return self
@@ -133,8 +160,12 @@ class ForcePlotsPanel(QFrame):
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
# One range per channel, per unit. FN is asymmetric and its lower bound
# may be negative; FX/FY take one absolute magnitude each.
self._fn_min = 0.0
self._fn_max = 20.0
self._shear_max = 20.0
self._fn_raw_min = 0.0
self._fn_raw_max = 1000000.0
self._shear_raw_max = 500000.0
self._fixed_newton = True
@@ -142,8 +173,13 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for Finger raw value cards.
#: Display-only baseline / deadband for the value cards *and* the curves.
self.presentation = ForcePresentation(enabled=False)
#: The operator's master switch for that baseline. It is owned by
#: ``display_prefs`` (the ``raw_auto_zero`` preference) and kept here so a
#: caller that does not know it cannot switch the aid off (see
#: ``configure_presentation``).
self._presentation_enabled = False
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
@@ -261,6 +297,10 @@ class ForcePlotsPanel(QFrame):
self.plots = []
self.curves = []
self.cursors = []
#: Horizontal line at y = 0, one per channel: the origin the axis labels
#: alone could not give, and what keeps the idle FN curve off the subplot
#: border it shares with FX.
self.zero_lines = []
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0)
@@ -283,6 +323,14 @@ class ForcePlotsPanel(QFrame):
)
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
plot.addItem(cursor, ignoreBounds=True)
zero_line = pg.InfiniteLine(
pos=0.0,
angle=0,
movable=False,
pen=pg.mkPen(color(theme, "text_muted"), width=1),
)
zero_line.setZValue(-10)
plot.addItem(zero_line, ignoreBounds=True)
# ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
# also fires when a resize propagates through the X links, which is
@@ -291,6 +339,7 @@ class ForcePlotsPanel(QFrame):
self.plots.append(plot)
self.curves.append(curve)
self.cursors.append(cursor)
self.zero_lines.append(zero_line)
# Whole-session overview: the full envelope plus the current window.
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
@@ -441,6 +490,8 @@ class ForcePlotsPanel(QFrame):
self.overview_region.setBrush(_window_brush(theme))
for cursor in (*self.cursors, self.overview_cursor):
cursor.setPen(pg.mkPen(muted, width=1))
for zero_line in self.zero_lines:
zero_line.setPen(pg.mkPen(muted, width=1))
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
@@ -473,31 +524,77 @@ class ForcePlotsPanel(QFrame):
def set_axis_limits(
self,
*,
fn_min: float,
fn_max: float,
shear_max: float,
fn_raw_max: float | None = None,
shear_raw_max: float | None = None,
fn_raw_min: float,
fn_raw_max: float,
shear_raw_max: float,
) -> None:
"""The Y range of every channel, per display unit.
FN is asymmetric — ``fn_min`` may be negative, which is what gives the
axis a visible origin below the resting curve — while FX/FY each take one
absolute magnitude and are drawn symmetrically around zero.
"""
self._fn_min = float(fn_min)
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
if fn_raw_max is not None:
self._fn_raw_max = max(0.0, float(fn_raw_max))
if shear_raw_max is not None:
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._fn_raw_min = float(fn_raw_min)
self._fn_raw_max = max(0.0, float(fn_raw_max))
self._shear_raw_max = max(0.0, float(shear_raw_max))
self._apply_axis_mode()
def set_deadband_floors(self, *, newton: float | None = None, raw: float | None = None) -> None:
"""The operator's display deadband floors; 0 means "auto (estimated)".
A floor, not a replacement: the estimate from the quiet window still
applies underneath, so this is how an operator widens the zero zone (or
pins one when the calibration data is suspect) without re-fitting.
"""
if newton is not None:
value = max(0.0, float(newton))
self.presentation.set_min_deadband(
"N", value if value > 0.0 else DEFAULT_MIN_DEADBAND_N
)
if raw is not None:
value = max(0.0, float(raw))
self.presentation.set_min_deadband(
"raw", value if value > 0.0 else DEFAULT_MIN_DEADBAND_RAW
)
self._render_value_cards()
def set_presentation_enabled(self, enabled: bool) -> None:
"""The operator's master switch for the display baseline and deadband."""
self._presentation_enabled = bool(enabled)
self.presentation.set_enabled(self._presentation_enabled, unit=self._unit)
# Switching the aid off must show the measurement again, and switching it
# on must show the policy — from the same values the curve is drawn with,
# or a card and its own curve would disagree for a frame.
self._refresh_display_values()
self._render_value_cards()
def set_fixed_newton(self, enabled: bool) -> None:
self._fixed_newton = bool(enabled)
self._apply_axis_mode()
def reset(self) -> None:
"""Back to the view defaults: empty curves, follow mode, unit ``raw``.
The display baseline is deliberately *kept*: a view reset is not a
statement about the device, and the operator's zero estimate (or its
absence) survives it. A new session restarts the estimate through
``configure_presentation``.
"""
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._display_values = (0.0, 0.0, 0.0)
self._display_suppressed = (False, False, False)
self._available = (True, True, True)
self.presentation.reset_session(unit=self._unit)
self._unit = "raw"
self.presentation.unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
self._cursor_t = 0.0
@@ -519,20 +616,29 @@ class ForcePlotsPanel(QFrame):
unit: str,
enabled: bool | None = None,
) -> None:
"""Enable Finger-raw auto baseline for value cards only."""
"""Point the display baseline at the sensor and unit now being shown.
The aid is for every sensor type and both units — an idle channel that
reads a raw count or an offset in newtons is a display problem whichever
device produced it — so the sensor type only names what is drawn.
``enabled`` sets the master switch directly when given; the operator's
``raw_auto_zero`` preference is what the run-start call site passes.
Omitting it leaves the current switch alone.
"""
self._sensor_type = str(sensor_type or "box")
use = (
bool(enabled)
if enabled is not None
else (self._sensor_type == "finger" and unit != "N")
)
self.presentation.enabled = use
if enabled is not None:
self._presentation_enabled = bool(enabled)
self.presentation.enabled = bool(self._presentation_enabled)
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display cards only)."""
"""Restart the idle baseline window (display only: cards and curves)."""
self.presentation.request_reestimate()
self._display_values = self._last_values
# A restarted window starts from nothing, so the cards go back to the
# measurement for the couple of samples it takes to re-learn the offset
# — derived by the same call the curve uses, never left stale.
self._refresh_display_values()
self._render_value_cards()
def observe_for_baseline(
@@ -543,8 +649,13 @@ class ForcePlotsPanel(QFrame):
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
unit: str | None = None,
) -> None:
"""Feed the startup estimator; curves keep the raw measurement."""
"""Feed the startup estimator in the unit these values are expressed in.
The curve and the record keep the raw measurement; the estimate is only
ever a display copy.
"""
if not self.presentation.enabled:
return
self.presentation.observe(
@@ -553,6 +664,7 @@ class ForcePlotsPanel(QFrame):
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
unit=unit,
)
def push(
@@ -596,6 +708,9 @@ class ForcePlotsPanel(QFrame):
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
# The values are in ``unit``, and the estimate is kept per unit, so
# the sample must be filed under the unit it was measured in.
unit=unit,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
@@ -617,8 +732,9 @@ class ForcePlotsPanel(QFrame):
if state is not None:
self._syncing = True
try:
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
finally:
self._syncing = False
@@ -648,7 +764,10 @@ class ForcePlotsPanel(QFrame):
return
self._syncing = True
try:
values = state.values.copy()
# ...and draw the *display* copy of it, so a resting channel is a
# flat line on zero. ``history`` — and the session store and
# forces.csv behind it — keep the measurement.
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
@@ -661,6 +780,7 @@ class ForcePlotsPanel(QFrame):
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
self._sync_zero_lines()
finally:
self._syncing = False
# The buffer already carries session-relative time, so the cursor is the
@@ -686,6 +806,9 @@ class ForcePlotsPanel(QFrame):
if unit == self._unit:
return
self._unit = unit
# The display policy keeps one estimate per unit, so it has to know which
# one is on screen: a stale "raw" is ready does not mean newtons are.
self.presentation.unit = unit
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
# is in the same session-relative base, so the X axis must not restart.
self.history.clear()
@@ -696,6 +819,17 @@ class ForcePlotsPanel(QFrame):
self.refresh_from_store()
else:
self._reload_from_store()
# ``_last_values`` were measured in the unit being left. The
# rebuilt buffer holds the same samples in the unit now being
# shown, so its last row is what the cards describe — otherwise
# the card would carry the old unit's numbers while its own curve
# is drawn from the new one.
state = self.history.state()
if state is not None and len(state.values):
self._last_values = tuple(
float(value) for value in state.values[-1]
)
self._refresh_display_values()
self._render_value_cards()
self._apply_axis_mode()
# Re-read the point under the cursor in the new unit: while browsing, or
@@ -735,6 +869,7 @@ class ForcePlotsPanel(QFrame):
scale.
"""
self._unit = unit
self.presentation.unit = unit
self.history.clear()
self._clear_curves()
self._overview_dirty = True
@@ -826,9 +961,14 @@ class ForcePlotsPanel(QFrame):
for curve in self.curves:
curve.setData([], [])
else:
# The read is of the *measured* values (the session store or the
# recording); presenting them here is what keeps a browsed window
# looking like the live one — the record itself is untouched.
presented = self._curve_values(window.values)
mask = plottable_axis_mask(presented, window.valid)
for index, curve in enumerate(self.curves):
times, values = window.plottable_axis(index)
curve.setData(times, values)
column = mask[:, index]
curve.setData(window.times[column], presented[column, index])
self._availability_from_window(window)
for plot in self.plots:
plot.setXRange(t0, t1, padding=0.0)
@@ -913,7 +1053,7 @@ class ForcePlotsPanel(QFrame):
self._window = (x0, x1)
self.refresh_from_store()
def _guard(self) -> "_Sync":
def _guard(self) -> _Sync:
"""Context manager marking range changes as programmatic."""
return _Sync(self)
@@ -962,6 +1102,14 @@ class ForcePlotsPanel(QFrame):
self._available = tuple(
bool(valid and np.isfinite(value)) for value in values
)
# The cards describe this point, so they carry the display copy of it —
# including while browsing or replaying, where the values did not come
# from the live sample the last ``push`` presented.
presented, suppressed = self.presentation.present(
np.asarray([values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in presented[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
self._render_value_cards()
def _update_selection_readout(self) -> None:
@@ -1056,6 +1204,10 @@ class ForcePlotsPanel(QFrame):
buckets=self._overview_points(), unit=self._unit
)
values = np.asarray(window.values, dtype=float)
# The envelope is drawn from the same store measurement as the window
# above, so it goes through the same presentation: the overview thumb
# must not show a jittering baseline the main chart has flattened.
values = self._curve_values(values)
column = values[:, 0]
finite = np.isfinite(column)
self.overview_curve.setData(window.times[finite], column[finite])
@@ -1164,8 +1316,9 @@ class ForcePlotsPanel(QFrame):
state = self.history.state()
if state is None:
return
values = self._curve_values(state.values)
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
def _availability_from_window(self, window) -> None:
@@ -1194,12 +1347,20 @@ class ForcePlotsPanel(QFrame):
return self._fn_raw_max > 0.0 and self._shear_raw_max > 0.0
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
"""The configured Y range of one channel, in the active unit.
FN's lower bound may be negative — that is the "below zero threshold"
the operator asked for, and it is what keeps the resting curve off the
frame it shares with FX.
"""
if self._unit == "N":
fn_max, shear_max = self._fn_max, self._shear_max
fn_min, fn_max, shear_max = self._fn_min, self._fn_max, self._shear_max
else:
fn_max, shear_max = self._fn_raw_max, self._shear_raw_max
fn_min = self._fn_raw_min
fn_max = self._fn_raw_max
shear_max = self._shear_raw_max
if channel_index == 0:
return 0.0, fn_max
return _ordered_range(fn_min, fn_max)
return -shear_max, shear_max
def _preview_fixed_y(self) -> bool:
@@ -1223,6 +1384,49 @@ class ForcePlotsPanel(QFrame):
if self._mode == "follow":
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
self._sync_zero_lines()
def _sync_zero_lines(self) -> None:
"""Show the zero line only where zero is inside the visible range.
pyqtgraph does not clip a layout item to its own view box, so a line at
0 painted while the axis sits entirely above it would be drawn across the
neighbouring subplot.
"""
for zero_line, plot in zip(self.zero_lines, self.plots):
try:
ymin, ymax = plot.getViewBox().viewRange()[1]
except Exception: # noqa: BLE001 - a view box mid-construction
continue
zero_line.setVisible(bool(ymin <= 0.0 <= ymax))
def _curve_values(self, values) -> np.ndarray:
"""The values the curves draw: the measurement through the display policy.
``history``, the session store and ``forces.csv`` keep the measurement;
the baseline and deadband are applied here, on the way to the plot only.
That is what makes a resting channel a flat line at exactly zero without
the record being edited. Browsing and a replay read the store, so they go
through this same presentation — the curves show the operator's display
policy, the store shows what was measured.
"""
displayed, _suppressed = self.presentation.present(values, unit=self._unit)
return displayed
def _refresh_display_values(self) -> None:
"""Re-derive the card values from the policy the curves are drawn with.
``push`` fills these from :meth:`ForcePresentation.accept`, whose
hysteresis is what keeps a *live* reading steady. Every other path — a
re-estimate, the master switch, a unit switch — goes through the same
stateless :meth:`ForcePresentation.present` the curve uses, so a card can
never state a value its own curve does not carry.
"""
displayed, suppressed = self.presentation.present(
np.asarray([self._last_values], dtype=float), unit=self._unit
)
self._display_values = tuple(float(value) for value in displayed[0])
self._display_suppressed = tuple(bool(flag) for flag in suppressed[0])
def _clear_curves(self) -> None:
for curve in self.curves:
@@ -1252,7 +1456,13 @@ class ForcePlotsPanel(QFrame):
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled and self.presentation.ready:
elif self.presentation.enabled:
# The policy is *live from the first samples*: there is no
# separate "the estimate is ready" gate here. Such a gate is what
# let a card read FN 1118 raw while its own curve was already flat
# on zero, because the card fell back to the raw measurement and
# the curve did not. Both are ``_display_values`` / ``present()``
# of the same applied estimate.
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
@@ -1273,7 +1483,6 @@ class ForcePlotsPanel(QFrame):
zero_zone = (
selecting is None
and self.presentation.enabled
and self.presentation.ready
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
+1 -2
View File
@@ -14,11 +14,10 @@ from __future__ import annotations
from functools import lru_cache
from orisys_app_shared.theme import color
from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color
#: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest", "settings", "reset_view")
@@ -13,9 +13,15 @@ acquisition, and no path that reports a 3D problem as a recording problem.
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
from typing import TYPE_CHECKING
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
ExpectedGeometry,
)
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import (
QFrame,
@@ -27,13 +33,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
ExpectedGeometry,
SceneAssets,
diagnostic,
)
from orisys_studio.scene3d.asset_source import (
SceneAssetLoader,
SceneSource,
@@ -44,6 +43,7 @@ from orisys_studio.ui import icons
if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here
from orisys_app_shared.acquisition.geometry import DeviceGeometry
from orisys_studio.scene3d.view import Scene3DView
#: Shown when the pane is on but this sensor has no 3D scene at all (box, mini).
@@ -81,17 +81,17 @@ class Mesh3dPanel(QFrame):
enabledChanged = Signal(bool)
def __init__(
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
self, parent: QWidget | None = None, *, theme: str = "dark"
) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = str(theme)
self._view: Optional["Scene3DView"] = None
self._loader: Optional[SceneAssetLoader] = None
self._source: Optional[SceneSource] = None
self._view: Scene3DView | None = None
self._loader: SceneAssetLoader | None = None
self._source: SceneSource | None = None
self._sensor_type = ""
self._expected: Optional[ExpectedGeometry] = None
self._expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None
self._expected: ExpectedGeometry | None = None
self._expected_provider: Callable[[], DeviceGeometry] | None = None
self._source_kind = "live"
self._enabled = False
self._loaded = False
@@ -103,14 +103,41 @@ class Mesh3dPanel(QFrame):
self._keys = None
#: The package whose scene is in the view, so switching sensor away and back
#: does not reload it (and does not pretend to be loading it either).
self._loaded_path: Optional[Path] = None
self._reported: Optional[Diagnostic] = None
self._loaded_path: Path | None = None
self._reported: Diagnostic | None = None
self._pending_settings = None
self._message_kind = _UNAVAILABLE
self._message_args: dict[str, str] = {"reason": ""}
#: Whether the pane explains itself (the window's 「诊断信息」 switch), held here
#: until the view exists (see ``set_diagnostics_visible``).
self._show_diagnostics = False
#: The session's island latch, shared with the 2D canvas (see
#: ``set_domain_latch``). Held until the view exists.
self._domain_latch = None
# Reserve this top-level window's native handle *now*, before the window is
# shown and while no OpenGL widget exists yet.
#
# The view is built on first use, so on a first run — the pane starts on a box,
# which has no 3D scene — the GL widget is created later, while the window is
# already on screen. Creating the first *native* child of a visible window makes
# Qt re-create the window, and Qt does that by hiding it and showing it again:
# measured here as a 250–400 ms disappearance of the whole application, with the
# popup or transient dialog that was open losing its activation and being
# dismissed with it. (``QOpenGLWidget`` sets ``WA_NativeWindow`` on itself.)
# Switching the sensor to ``finger`` in the sensor dialog is exactly that moment,
# which is why it read as "the window closes and reopens and the dialog closes by
# itself".
#
# One zero-sized native child is enough: Qt then already owns a native window
# handle when the 3D widget is added, and no recreation — and so no hide/show —
# happens. It reserves the handle only; it creates no context, no scene and no GL
# import, so the pane stays as lazy as it was. There is no "re-layout" to force
# here: the window was never meant to be hidden at all.
self._native_handle = QWidget(self)
self._native_handle.setObjectName("mesh3dNativeHandle")
self._native_handle.setGeometry(0, 0, 0, 0)
self._native_handle.winId() # creating the handle *is* the point
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
@@ -189,9 +216,26 @@ class Mesh3dPanel(QFrame):
panel.set_diagnostics_visible(self._show_diagnostics)
def set_theme(self, theme: str) -> None:
"""Re-tint the header icons: they are painted from the theme's own colours."""
"""Re-tint the header icons, and colour the picture from the theme's ramp.
The ramp half is not cosmetic: the 2D canvas is coloured from
``arrows.flow_colormap(theme)``, so the 3D surface has to be coloured from the
same string or the two panes show one measurement in two colours.
"""
self._theme = str(theme)
self._apply_icons()
if self._view is not None and hasattr(self._view, "set_theme"):
self._view.set_theme(self._theme)
def set_domain_latch(self, latch) -> None:
"""Share the 2D canvas's island latch with the 3D pane (see ``Scene3DView``).
Held here as well as pushed, because the view is built on first use: a latch
handed over before then must still reach the adapter when the view appears.
"""
self._domain_latch = latch
if self._view is not None and hasattr(self._view, "set_domain_latch"):
self._view.set_domain_latch(latch)
def _apply_icons(self) -> None:
size = QSize(icons.ICON_SIZE, icons.ICON_SIZE)
@@ -205,8 +249,8 @@ class Mesh3dPanel(QFrame):
self,
sensor_type: str,
*,
expected: Optional[ExpectedGeometry] = None,
expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None,
expected: ExpectedGeometry | None = None,
expected_provider: Callable[[], DeviceGeometry] | None = None,
package_override: str | Path | None = None,
) -> None:
"""Tell the pane which sensor it is looking at. Cheap; no IO.
@@ -256,7 +300,7 @@ class Mesh3dPanel(QFrame):
return
self._show_message(_LOADING, name=self._source.path.name)
def attach_view(self, view: "Scene3DView") -> None:
def attach_view(self, view: Scene3DView) -> None:
"""Adopt a view: the real one (built lazily) or a double in a test.
Public because it is the seam tests use — poking ``_view`` from a test would
@@ -267,6 +311,16 @@ class Mesh3dPanel(QFrame):
self._stack.addWidget(view)
view.diagnosticRaised.connect(self._on_diagnostic)
view.enabledChanged.connect(self._on_view_enabled)
# The theme and the session's island latch were decided before the view existed
# (the view is built on first use), so they are pushed again here rather than
# lost — exactly the treatment ``_pending_settings`` and ``_source_kind`` get,
# and for the same reason: the picture must be coloured by the same ramp, and
# bounded by the same island, as the 2D canvas from its first frame. Guarded the
# same way as ``panel`` above, because a test double is a view too.
if hasattr(view, "set_theme"):
view.set_theme(self._theme)
if self._domain_latch is not None and hasattr(view, "set_domain_latch"):
view.set_domain_latch(self._domain_latch)
if self._pending_settings is not None:
view.restore_settings(self._pending_settings)
# The kind was decided before the view existed (the window sets it when the
@@ -278,7 +332,7 @@ class Mesh3dPanel(QFrame):
if not view.available:
self._show_message(_UNAVAILABLE, reason=view.reason)
def ensure_view(self) -> Optional[QWidget]:
def ensure_view(self) -> QWidget | None:
"""Build the 3D view on first use, so a headless machine never imports GL."""
if self._view is not None or self._sensor_type != "finger":
return self._view
@@ -294,7 +348,6 @@ class Mesh3dPanel(QFrame):
if self._loaded or self._source is None or self._view is None:
return
self._loaded = True
view = self._view
if self._loader is None:
self._loader = SceneAssetLoader(self)
self._loader.loaded.connect(self._on_loaded)
@@ -418,11 +471,11 @@ class Mesh3dPanel(QFrame):
# ------------------------------------------------------------------ inputs
@property
def view(self) -> Optional["Scene3DView"]:
def view(self) -> Scene3DView | None:
return self._view
@property
def last_diagnostic(self) -> Optional[Diagnostic]:
def last_diagnostic(self) -> Diagnostic | None:
return self._reported
@property
@@ -441,7 +494,7 @@ class Mesh3dPanel(QFrame):
"""Whether a 3D picture is possible here at all (GL and an asset)."""
return self._view is not None and bool(self._view.available)
def scene_path(self) -> Optional[Path]:
def scene_path(self) -> Path | None:
return None if self._source is None else self._source.path
def set_source_kind(self, kind: str) -> None:
@@ -1,7 +1,8 @@
"""Modal dialog for the force-plot window, Y-axis limits and the filters.
"""Modal dialog for the force-plot window, Y-axis limits, deadband and filters.
Two things live here because they are answered together, at the chart: how much
time and range the curves show, and what the acquisition is allowed to filter.
time and range the curves show (per channel, and the display deadband that holds
a resting channel at zero), and what the acquisition is allowed to filter.
The filter half is a *request*: it is handed to the SDK through
``FilterSettings.apply_to`` at the start of the next session, and what the device
reports as done is what the record will say (see ``force/filters.py``).
@@ -9,6 +10,12 @@ reports as done is what the record will say (see ``force/filters.py``).
from __future__ import annotations
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force.filters import (
FilterSettings,
available_presets,
)
from orisys_app_shared.i18n import t
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
@@ -24,21 +31,24 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force.filters import (
SUGGESTED_LOWPASS_HZ,
FilterSettings,
available_presets,
)
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
#: Lower bounds. FN is allowed below zero: a resting normal force has no reason
#: to sit exactly on the frame, and with the lower bound at 0 the idle curve is
#: drawn along the subplot border that abuts the FX frame (see force_plots).
DEFAULT_PLOT_FN_MIN = 0.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
# stays sub-pixel while a full contact still fits (see prefs store).
DEFAULT_PLOT_FN_RAW_MIN = 0.0
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.0
#: Display deadband floors ("归零阈值") in the unit each applies to. 0 means
#: "auto": the percentile the presentation layer estimates from the quiet
#: startup window. Non-zero forces a floor under that estimate, so an operator
#: can widen the zero zone (or pin one when calibration data is suspect).
DEFAULT_PLOT_DEADBAND_N = 0.0
DEFAULT_PLOT_DEADBAND_RAW = 0.0
class PlotSettingsDialog(QDialog):
@@ -47,10 +57,14 @@ class PlotSettingsDialog(QDialog):
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_min: float = DEFAULT_PLOT_FN_MIN,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
fn_raw_min: float = DEFAULT_PLOT_FN_RAW_MIN,
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
deadband_n: float = DEFAULT_PLOT_DEADBAND_N,
deadband_raw: float = DEFAULT_PLOT_DEADBAND_RAW,
units: str = "newton",
filters: FilterSettings | None = None,
filters_locked: bool = False,
@@ -58,7 +72,7 @@ class PlotSettingsDialog(QDialog):
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(420, 520)
self.resize(460, 640)
self._sample_rate_hz = float(sample_rate_hz)
#: True while the widgets are being filled in from a preset, so the
#: "edited -> custom" handler does not fire on our own writes.
@@ -75,6 +89,15 @@ class PlotSettingsDialog(QDialog):
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
# FN is asymmetric: a lower bound that may be negative, and an upper one.
self.fn_min_label = QLabel()
self.fn_min_spin = QDoubleSpinBox()
self.fn_min_spin.setRange(-1000.0, 1000.0)
self.fn_min_spin.setSingleStep(1.0)
self.fn_min_spin.setDecimals(1)
self.fn_min_spin.setSuffix(" N")
self.fn_min_spin.setValue(float(fn_min))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
@@ -91,6 +114,13 @@ class PlotSettingsDialog(QDialog):
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
self.fn_raw_min_label = QLabel()
self.fn_raw_min_spin = QDoubleSpinBox()
self.fn_raw_min_spin.setRange(-1e9, 1e9)
self.fn_raw_min_spin.setSingleStep(50000.0)
self.fn_raw_min_spin.setDecimals(0)
self.fn_raw_min_spin.setValue(float(fn_raw_min))
# Raw limits: 0 means "auto range on the data".
self.fn_raw_max_label = QLabel()
self.fn_raw_max_spin = QDoubleSpinBox()
@@ -106,22 +136,48 @@ class PlotSettingsDialog(QDialog):
self.shear_raw_max_spin.setDecimals(0)
self.shear_raw_max_spin.setValue(float(shear_raw_max))
# The display deadband: a *display* aid, per unit. It never reaches the
# store or forces.csv (see force/presentation.py).
self.deadband_n_label = QLabel()
self.deadband_n_spin = QDoubleSpinBox()
self.deadband_n_spin.setRange(0.0, 100.0)
self.deadband_n_spin.setSingleStep(0.1)
self.deadband_n_spin.setDecimals(2)
self.deadband_n_spin.setSuffix(" N")
self.deadband_n_spin.setValue(float(deadband_n))
self.deadband_raw_label = QLabel()
self.deadband_raw_spin = QDoubleSpinBox()
self.deadband_raw_spin.setRange(0.0, 1e9)
self.deadband_raw_spin.setSingleStep(5000.0)
self.deadband_raw_spin.setDecimals(0)
self.deadband_raw_spin.setValue(float(deadband_raw))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_min_label, self.fn_min_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
form.addRow(self.fn_raw_min_label, self.fn_raw_min_spin)
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
form.addRow(self.deadband_n_label, self.deadband_n_spin)
form.addRow(self.deadband_raw_label, self.deadband_raw_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
raw_widgets = (
self.fn_raw_min_spin,
self.fn_raw_max_spin,
self.shear_raw_max_spin,
self.deadband_raw_spin,
self.fn_raw_min_label,
self.fn_raw_max_label,
self.shear_raw_max_label,
self.deadband_raw_label,
)
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
self.shear_max_label):
for widget in (self.fn_min_spin, self.fn_max_spin, self.shear_max_spin,
self.deadband_n_spin, self.fn_min_label, self.fn_max_label,
self.shear_max_label, self.deadband_n_label):
widget.setEnabled(newton)
for widget in raw_widgets:
widget.setEnabled(not newton)
@@ -330,6 +386,7 @@ class PlotSettingsDialog(QDialog):
self.filter_note.setText("")
def values(self) -> tuple[float, float, float, float, float]:
"""The original five-values shape, kept for the existing caller."""
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
@@ -338,12 +395,41 @@ class PlotSettingsDialog(QDialog):
float(self.shear_raw_max_spin.value()),
)
def ranges(self) -> dict[str, float]:
"""Every axis limit, keyed the way the preference names it."""
return {
"window_sec": float(self.window_spin.value()),
"fn_min": float(self.fn_min_spin.value()),
"fn_max": float(self.fn_max_spin.value()),
"shear_max": float(self.shear_max_spin.value()),
"fn_raw_min": float(self.fn_raw_min_spin.value()),
"fn_raw_max": float(self.fn_raw_max_spin.value()),
"shear_raw_max": float(self.shear_raw_max_spin.value()),
}
def deadbands(self) -> dict[str, float]:
"""The per-unit display deadband floors. 0 means "auto" (estimated)."""
return {
"deadband_n": float(self.deadband_n_spin.value()),
"deadband_raw": float(self.deadband_raw_spin.value()),
}
def settings(self) -> dict[str, float]:
"""Everything the plot half of the dialog decides, in one mapping."""
return {**self.ranges(), **self.deadbands()}
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_min_spin.setValue(DEFAULT_PLOT_FN_MIN)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
self.fn_raw_min_spin.setValue(DEFAULT_PLOT_FN_RAW_MIN)
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
# "Auto" is the shipped answer for the deadband: the estimate from the
# quiet startup window, with the layer's own floor under it.
self.deadband_n_spin.setValue(DEFAULT_PLOT_DEADBAND_N)
self.deadband_raw_spin.setValue(DEFAULT_PLOT_DEADBAND_RAW)
# "Reset" means the shipped defaults, and those filter nothing — the same
# answer the dialog gives for a preference file that says nothing.
self.flow_ema_spin.setValue(1.0)
@@ -358,14 +444,22 @@ class PlotSettingsDialog(QDialog):
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_min_label.setText(t("dialog.plot_fn_min"))
self.fn_min_spin.setToolTip(t("dialog.plot_fn_min_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.fn_raw_min_label.setText(t("dialog.plot_fn_raw_min"))
self.fn_raw_min_spin.setToolTip(t("dialog.plot_fn_raw_min_tip"))
self.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
self.deadband_n_label.setText(t("dialog.plot_deadband_n"))
self.deadband_n_spin.setToolTip(t("dialog.plot_deadband_n_tip"))
self.deadband_raw_label.setText(t("dialog.plot_deadband_raw"))
self.deadband_raw_spin.setToolTip(t("dialog.plot_deadband_raw_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
self.filters_group.setTitle(t("dialog.plot_filters"))
self.filter_preset_label.setText(t("dialog.filter_preset"))
@@ -14,10 +14,9 @@ timer without touching the writer thread.
from __future__ import annotations
from PySide6.QtWidgets import QLabel
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from PySide6.QtWidgets import QLabel
#: The recording channel is idle, never started in this session.
IDLE = "idle"
@@ -17,6 +17,7 @@ from __future__ import annotations
from datetime import datetime
from pathlib import Path
from orisys_app_shared.i18n import t
from PySide6.QtWidgets import (
QDialog,
QFrame,
@@ -26,8 +27,6 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from orisys_app_shared.i18n import t
from orisys_studio.ui.fit_text import ShrinkableLabel
@@ -9,6 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
from __future__ import annotations
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
@@ -18,9 +20,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
CONTROL_HEIGHT = 30
#: Both combos stay narrow: this row shares one line with the session state.
TYPE_WIDTH = 76
+134 -73
View File
@@ -1,8 +1,15 @@
"""Top of the studio shell: status rail and a collapsible settings area.
The rail is what an operator watches while a session runs — phase, device,
calibration, data validity and the record channel — plus the three actions that
must never be more than one click away (record, run/stop, reset).
calibration, data validity and the record channel — plus the actions that must
never be more than one click away (record, run/pause, stop, reset).
The transport is the *same* control in both views (``btn_run``): a play/pause
toggle whose checked state means "acquiring / playing and not paused". A session
that is stopped and a session that is paused both leave the button unchecked, so
the session phase — not the button — is what tells the two apart (see
``_refresh_run_label``). Stopping is its own button (``btn_stop``), because
releasing the device is a different act from pausing the computation.
Everything that is *configuration* rather than state — source, camera number,
video file, units and the sensor/config pickers — lives in the settings area,
@@ -12,6 +19,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
from __future__ import annotations
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
@@ -26,9 +35,6 @@ from PySide6.QtWidgets import (
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from orisys_studio.ui.fit_text import ShrinkableLabel
from orisys_studio.ui.record_status import RecordStatusChip
@@ -81,7 +87,10 @@ class ToolbarWidget(QWidget):
replay_video_clicked = Signal()
#: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = Signal()
#: The transport: True when a live session should acquire, or a replay play.
run_toggled = Signal(bool)
#: Stop: end the live session (releasing the device), or leave the replay.
stop_clicked = Signal()
reset_clicked = Signal()
units_changed = Signal(str)
record_toggled = Signal(bool)
@@ -109,7 +118,6 @@ class ToolbarWidget(QWidget):
self._video_path = ""
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
self._run_role = "acquire"
self._reset_role = "reset"
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
@@ -242,6 +250,16 @@ class ToolbarWidget(QWidget):
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
# Stop is a text button beside the transport, like Run — the icon-only pause
# it replaces cost the same rail width while saying nothing about what it
# stopped. It carries no minimum width, so the rail can shrink it at 1280
# (``tools/ui_check.py`` checks the rail for elision in both locales).
self.btn_stop = QPushButton()
self.btn_stop.setObjectName("dangerOutline")
self.btn_stop.setMinimumHeight(BUTTON_HEIGHT)
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self.stop_clicked.emit)
self.btn_reset = QPushButton()
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False)
@@ -266,6 +284,7 @@ class ToolbarWidget(QWidget):
rail.addWidget(self.record_chip)
rail.addWidget(_rail(self.btn_record))
rail.addWidget(_rail(self.btn_run))
rail.addWidget(_rail(self.btn_stop))
rail.addWidget(_rail(self.btn_reset))
rail.addWidget(_rail(self.btn_settings))
return rail
@@ -386,7 +405,11 @@ class ToolbarWidget(QWidget):
self._apply_source_mode_ui()
def set_run_role(self, role: str) -> None:
"""What the primary button does in this view: acquire, or play back."""
"""What the primary button does in this view: acquire, or play back.
The same button is the transport of both views, so the *labels* follow the
role; only the words change (运行/暂停/继续 against 播放/暂停/继续).
"""
wanted = "playback" if str(role) == "playback" else "acquire"
if wanted == self._run_role:
return
@@ -397,38 +420,11 @@ class ToolbarWidget(QWidget):
self.btn_run.setChecked(False)
self.btn_run.blockSignals(False)
self._refresh_run_label()
self._refresh_stop_label()
def run_role(self) -> str:
return self._run_role
def set_reset_role(self, role: str) -> None:
"""What the second rail button does: zero the tracker, or stop the replay.
The replay view needs a *stop* the operator can always reach. The transport
button is a play/pause toggle, which is ambiguous precisely when something is
wrong (is it playing? is a recompute pass running?), so the second button —
disabled in that view otherwise — becomes an unconditional stop instead of a
second thing to reason about.
"""
wanted = "stop" if str(role) == "stop" else "reset"
if wanted == self._reset_role:
return
self._reset_role = wanted
if wanted == "reset":
self.btn_reset.setEnabled(False)
self._refresh_reset_label()
def reset_role(self) -> str:
return self._reset_role
def _refresh_reset_label(self) -> None:
if self._reset_role == "stop":
self.btn_reset.setText(t("toolbar.stop_playback"))
self.btn_reset.setToolTip(t("toolbar.stop_playback_tip"))
return
self.btn_reset.setText(t("toolbar.reset"))
self.btn_reset.setToolTip("")
def source_view(self) -> str:
return self._source_view
@@ -467,6 +463,12 @@ class ToolbarWidget(QWidget):
self.refresh_labels()
def set_run_checked(self, checked: bool) -> None:
"""Follow the session's transport state without re-emitting the signal.
The window calls this to *state* what the session is doing (a replay began,
a session ended, a recompute pass stopped). An operator's own click goes
through the button instead, and is routed by ``_on_run_toggled``.
"""
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
@@ -474,6 +476,14 @@ class ToolbarWidget(QWidget):
if not checked:
self.btn_reset.setEnabled(False)
def set_stop_enabled(self, enabled: bool) -> None:
"""Stop is offered for as long as there is something to end.
That includes a device open that has not returned yet: the stop button is
where the cancel-a-hung-connect escape hatch lives now.
"""
self.btn_stop.setEnabled(bool(enabled))
def set_reset_enabled(self, enabled: bool) -> None:
self.btn_reset.setEnabled(bool(enabled))
@@ -517,7 +527,13 @@ class ToolbarWidget(QWidget):
self._apply_run_tooltip()
def set_run_phase(self, phase: str) -> None:
"""Track the session phase so Stop can advertise itself while connecting."""
"""Track the session phase.
The phase is half of what the transport label means: unchecked *with* a
live session is paused, unchecked without one is idle (see
``_refresh_run_label``), and ``connecting`` is what the stop button
advertises itself as cancel for.
"""
self._run_phase = str(phase or "idle")
self.refresh_labels()
@@ -531,6 +547,7 @@ class ToolbarWidget(QWidget):
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
self._refresh_preview_label()
self._refresh_run_label()
self._refresh_stop_label()
# ------------------------------------------------------------- internals
def _populate_units_combo(self) -> None:
@@ -636,9 +653,15 @@ class ToolbarWidget(QWidget):
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
"""The transport: the label follows the button, the *state* does not.
Nothing about a session is decided here — the window routes the click (§
``_on_run_toggled`` there) and states the result back through
``set_run_checked``. Reset is deliberately left alone: unchecking now means
pause as often as it means stop, and pausing must not disable a control the
still-running session owns.
"""
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
@@ -650,28 +673,52 @@ class ToolbarWidget(QWidget):
else:
self.btn_preview.setText(t("toolbar.preview"))
def _session_running(self) -> bool:
"""Whether a *live* session exists in this view (connecting or acquiring).
This is the third transport state the two-state button cannot carry: with
the button unchecked it is what separates "paused" from "not started".
"""
return self._run_role == "acquire" and self._run_phase in ("connecting", "running")
def _refresh_run_label(self) -> None:
if self._run_role == "playback":
# The primary action of the replay view is playing the recording —
# the same button, doing what this view is for.
# the same button, doing what this view is for. Unchecked with nothing
# loaded is idle, and the button is disabled with the reason in its tip.
self.btn_run.setText(
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
)
self._refresh_emphasis()
self._apply_run_tooltip()
return
if self.btn_run.isChecked():
# While connecting, cancel is the action the user actually needs —
# a device open can hang, and this is the way out of it.
if self._run_phase == "connecting":
self.btn_run.setText(t("toolbar.cancel_label"))
else:
self.btn_run.setText(t("toolbar.stop_label"))
elif self.btn_run.isChecked():
# checked = acquiring and not paused, so the action is Pause.
self.btn_run.setText(t("toolbar.pause"))
elif self._session_running():
# unchecked *with* a live session is the paused state: the device is
# open and the computation is stopped, so the action is Resume.
self.btn_run.setText(t("toolbar.resume_label"))
else:
self.btn_run.setText(t("toolbar.run_label"))
self._refresh_emphasis()
self._apply_run_tooltip()
def _refresh_stop_label(self) -> None:
"""Stop is one button for two situations, and its label says which.
A device open can hang, and the way out of it is this button: while the
session is still connecting it advertises itself as cancel, because that
is the act the operator needs. In the replay view it is the way out of the
replay (or a recompute pass), whatever state the transport is in.
"""
if self._run_role == "playback":
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop_playback_tip"))
elif self._run_phase == "connecting":
self.btn_stop.setText(t("toolbar.cancel_label"))
self.btn_stop.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_stop.setText(t("toolbar.stop_label"))
self.btn_stop.setToolTip(t("toolbar.stop"))
def _refresh_emphasis(self) -> None:
"""Exactly one *filled* button at a time.
@@ -681,31 +728,36 @@ class ToolbarWidget(QWidget):
* not recording yet → the record action is the filled primary;
* recording → stopping the recording is the filled danger;
* connecting → cancel is the filled danger (it is the way out of a hang).
* connecting → cancel (on the stop button) is the filled danger, since it
is the way out of a hang;
* a live session acquiring or paused → the transport is an outline: pause,
resume and stop all change the session's state rather than being the one
action the rail is pointing at.
The replay's transport is the exception: it *is* what that view is for, so
it is filled while it plays and an outline while it is idle.
"""
recording = self.btn_record.isChecked()
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
playing = self.btn_run.isChecked()
if self._run_role == "playback":
# The transport is the replay view's own action, not a session stop:
# filled while it plays, an outline when idle, and never red.
self.btn_record.setObjectName(
"dangerButton" if recording else "primaryButton"
)
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
elif recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("dangerOutline")
# Never red: the replay's stop is an outline beside a filled transport.
self.btn_record.setObjectName("dangerButton" if recording else "primaryButton")
self.btn_run.setObjectName("primaryButton" if playing else "ghostButton")
self.btn_stop.setObjectName("dangerOutline")
else:
self.btn_record.setObjectName("primaryButton")
if running and self._run_phase != "connecting":
self.btn_run.setObjectName("dangerOutline")
elif running:
self.btn_run.setObjectName("dangerButton")
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
connecting = self._run_phase == "connecting"
self.btn_stop.setObjectName("dangerButton" if connecting else "dangerOutline")
if recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("ghostButton")
else:
self.btn_run.setObjectName("primaryButton")
for button in (self.btn_record, self.btn_run):
self.btn_record.setObjectName("primaryButton")
self.btn_run.setObjectName(
"ghostButton" if playing or self._session_running() else "primaryButton"
)
for button in (self.btn_record, self.btn_run, self.btn_stop):
button.style().unpolish(button)
button.style().polish(button)
@@ -723,10 +775,12 @@ class ToolbarWidget(QWidget):
self.btn_run.setToolTip(t(key))
return
if self.btn_run.isChecked():
if self._run_phase == "connecting":
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
else:
self.btn_run.setToolTip(t("toolbar.stop"))
# The tip is where the pause semantics are stated: computing stops, the
# picture holds, the device stays open — and a recording that spans the
# pause has a gap.
self.btn_run.setToolTip(t("toolbar.pause_tip"))
elif self._session_running():
self.btn_run.setToolTip(t("toolbar.resume_label_tip"))
elif not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif not self._run_available:
@@ -737,6 +791,13 @@ class ToolbarWidget(QWidget):
tip = f"{tip} — {self._run_tooltip_reason}"
self.btn_run.setToolTip(tip)
def _refresh_reset_label(self) -> None:
self.btn_reset.setText(t("toolbar.reset"))
# The button zeroes the *device* tracker; it no longer clears the curves.
# That is now 视图 → 复位视图, because one button doing both meant an
# operator who wanted a fresh baseline also lost the history on screen.
self.btn_reset.setToolTip(t("toolbar.reset_tip"))
def retranslate(self) -> None:
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_search.setText(t("toolbar.search_cameras"))
+7 -7
View File
@@ -22,7 +22,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Optional, Protocol
from typing import Any, Protocol
from PySide6.QtCore import QObject
@@ -80,7 +80,7 @@ class ViewLayoutController(QObject):
default_splitter_sizes: tuple,
min_panel_widths: dict,
default_3d_pane_width: int,
parent: Optional[QObject] = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self._host = host
@@ -103,7 +103,7 @@ class ViewLayoutController(QObject):
self._view_preset = preset if preset in self._presets else "custom"
self._show_cal = bool(host.prefs.get("show_cal_panel", False))
self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True))
self._actions: Optional[LayoutActions] = None
self._actions: LayoutActions | None = None
# ------------------------------------------------------------------ wiring
def bind_actions(
@@ -244,7 +244,7 @@ class ViewLayoutController(QObject):
self._set_check(self._actions.settings, self._toolbar.settings_expanded())
@staticmethod
def _set_check(action: Any, checked: bool, *, enabled: Optional[bool] = None) -> None:
def _set_check(action: Any, checked: bool, *, enabled: bool | None = None) -> None:
"""Set a checkable entry without letting it drive its own handler."""
if action is None:
return
@@ -279,10 +279,10 @@ class ViewLayoutController(QObject):
self._replay.pause_playback()
self._toolbar.set_source_view("replay" if preset == "replay" else "live")
# The rail's primary button is this view's main action: acquire while
# measuring, play back while replaying; and the second button is that view's
# own reset — or, in the replay view, an unconditional stop.
# measuring, play back while replaying. The button beside it is the same in
# both views — Stop, which ends a session or leaves a replay — and the
# tracker reset is simply disabled wherever there is no device session.
self._toolbar.set_run_role("playback" if preset == "replay" else "acquire")
self._toolbar.set_reset_role("stop" if preset == "replay" else "reset")
self.apply_panel_visibility()
if apply_sizes and preset in self._splitter_sizes:
self._splitter.setSizes(self.splitter_sizes_for(preset))
+1 -1
View File
@@ -62,8 +62,8 @@ def qt_app():
@pytest.fixture
def make_window(qt_app, tmp_path, monkeypatch):
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path."""
from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared import paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
from orisys_studio.app_window import StudioMainWindow
prefs_file = tmp_path / "preference.json"
+79 -4
View File
@@ -12,8 +12,10 @@ from orisys_studio.display_prefs import DisplayPrefsController
PLOT_DEFAULTS = {
"window_sec": 10.0,
"fn_min": 0.0,
"fn_max": 5.0,
"shear_max": 5.0,
"fn_raw_min": 0.0,
"fn_raw_max": 1e5,
"shear_raw_max": 1e5,
}
@@ -46,7 +48,10 @@ class _Charts:
self.fixed_newton: list[bool] = []
self.units: list[str] = []
self.window_sec: list[float] = []
self.limits: list[tuple] = []
self.limits: list[dict] = []
self.deadband_floors: list[dict] = []
self.presentation_enabled: list[bool] = []
self.presentation: list[dict] = []
def unit(self) -> str:
return self._unit
@@ -61,8 +66,17 @@ class _Charts:
def set_window_sec(self, value: float) -> None: # noqa: N802
self.window_sec.append(float(value))
def set_axis_limits(self, *values) -> None: # noqa: N802
self.limits.append(tuple(float(v) for v in values))
def set_axis_limits(self, **values) -> None: # noqa: N802
self.limits.append({key: float(value) for key, value in values.items()})
def set_deadband_floors(self, *, newton: float, raw: float) -> None: # noqa: N802
self.deadband_floors.append({"newton": float(newton), "raw": float(raw)})
def configure_presentation(self, **values) -> None: # noqa: N802
self.presentation.append(dict(values))
def set_presentation_enabled(self, enabled: bool) -> None: # noqa: N802
self.presentation_enabled.append(bool(enabled))
class _ArrowPanel:
@@ -197,27 +211,88 @@ def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
settings = controller.plot_settings()
assert settings["window_sec"] == 3.0
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
assert settings["fn_min"] == 0.0, "FN has no lower bound by default"
assert settings["deadband_n"] == 0.0, "the deadband default is 'auto'"
assert settings["units"] == "newton"
def test_a_stored_zero_is_a_value_and_not_a_missing_preference():
"""0 means "auto" on the raw bounds and the deadband, so it must survive."""
controller, parts = _controller(prefs={"plot_fn_raw_max": 0.0})
settings = controller.plot_settings()
assert settings["fn_raw_max"] == 0.0, "a stored 0 became the default"
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
controller, parts = _controller(prefs={})
controller.apply_plot_settings(
{
"window_sec": 20.0,
"fn_min": -2.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_min": -1000.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
"deadband_n": 0.25,
"deadband_raw": 0.0,
}
)
assert parts["host"].prefs["plot_window_sec"] == 20.0
assert parts["host"].prefs["plot_fn_min"] == -2.0
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
assert parts["host"].prefs["plot_deadband_n"] == 0.25
assert parts["charts"].window_sec == [20.0]
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
assert parts["charts"].limits[-1] == {
"fn_min": -2.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_min": -1000.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
}
# 0 on a deadband floor means "auto", and the charts are told exactly that.
assert parts["charts"].deadband_floors[-1] == {"newton": 0.25, "raw": 0.0}
assert parts["host"].persisted == 1
def test_a_partial_plot_settings_mapping_leaves_the_rest_as_stored():
"""The older five-value shape must not silently reset a range."""
controller, parts = _controller(prefs={"plot_fn_min": -5.0, "plot_deadband_raw": 900.0})
controller.apply_plot_settings(
{"window_sec": 6.0, "fn_max": 12.0, "shear_max": 3.0,
"fn_raw_max": 4e5, "shear_raw_max": 5e5}
)
assert parts["host"].prefs["plot_fn_min"] == -5.0
assert parts["charts"].limits[-1]["fn_min"] == -5.0
assert parts["charts"].deadband_floors[-1]["raw"] == 900.0
def test_the_display_baseline_is_not_a_finger_only_setting():
"""Every sensor type and both units: an idle newton offset is the same problem."""
for sensor, units in (("box", "newton"), ("box", "raw"),
("finger", "newton"), ("finger", "raw")):
controller, parts = _controller(prefs={"raw_auto_zero": True},
sensor_type=sensor, units=units)
assert parts["charts"].presentation_enabled[-1] is True, (sensor, units)
assert parts["charts"].presentation[-1]["unit"] == (
"N" if units == "newton" else "raw"
)
controller, parts = _controller(prefs={"raw_auto_zero": False}, sensor_type="box",
units="newton")
assert parts["charts"].presentation_enabled[-1] is False
def test_the_deadband_floors_reach_the_charts_from_the_preferences():
controller, parts = _controller(prefs={"plot_deadband_n": 0.5, "plot_deadband_raw": 2000.0})
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
controller.set_raw_auto_zero(True) # re-applies the whole policy
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
assert controller.device_sample_rate() == 48.5
@@ -0,0 +1,330 @@
"""The force charts as the operator sees them: baseline, deadband and ranges.
The display policy is a *view* concern: an idle channel must read zero and draw a
flat line, while the session store and ``forces.csv`` keep the measurement. These
tests drive the panel on its own (no acquisition, no window) and read back what
the curves and the value cards were actually given.
"""
from __future__ import annotations
import numpy as np
from orisys_app_shared.force.session import SessionSample, SessionStore
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force
#: The two idle triplets from the customer's screenshots: a raw FN of ~9555 and
#: an FX whose bias sits ~13k counts off zero.
RAW_BIAS = np.array([9555.0, -13368.0, -353.0])
NEWTON_BIAS = np.array([0.4, -0.25, 0.1])
def _panel(unit: str = "raw") -> ForcePlotsPanel:
"""A chart in one unit, with the display baseline switched on."""
panel = ForcePlotsPanel(theme="dark")
panel.set_units(unit)
panel.configure_presentation(sensor_type="box", unit=unit)
panel.set_presentation_enabled(True)
panel.set_fixed_newton(True)
return panel
def _rest(
panel: ForcePlotsPanel,
bias: np.ndarray,
*,
unit: str = "raw",
samples: int = 80,
spread: float = 0.0,
sigma: float = 0.0,
seed: int = 0,
) -> None:
"""Feed a quiet startup window, the way a live session would.
``spread`` is a bounded wander (the device really does sit still) and
``sigma`` is the random jitter of a `noisier` product.
"""
rng = np.random.default_rng(seed)
for index in range(samples):
sample = bias + ((index % 5) - 2) * spread
if sigma:
sample = sample + rng.normal(0.0, sigma, size=3)
panel.push(index / 30.0, *sample, unit=unit)
def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]:
"""The Y tick values actually installed on one axis."""
levels = panel.plots[channel].getAxis("left")._tickLevels
assert levels, "the axis has no explicit ticks"
return [float(value) for value, _text in levels[0]]
def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray:
_times, values = panel.curves[channel].getData()
return np.asarray(values, dtype=float)
# ------------------------------------------------------------ resting channel
def test_the_cards_read_zero_from_the_first_samples_of_a_session(qt_app):
"""The reported defect: FN 1118 raw, FX 34.16, FY -186.1 on the cards.
The screenshot was taken as the session started, so the aid has to be
effective from the first samples — not once the whole quiet window closed.
"""
panel = _panel("raw")
for index in range(4):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
if index < 2:
# Nothing has been estimated from fewer than three samples yet; those
# first frames honestly show the measurement.
continue
assert [label.text() for label in panel.value_labels] == ["0"] * 3, index
assert not panel.presentation.ready, "the whole three second window was needed"
assert panel.presentation.display_quality == "provisional"
for channel in (0, 1, 2):
assert set(np.unique(_curve(panel, channel))) == {0.0}, channel
def test_the_newton_cards_read_zero_from_the_first_samples_too(qt_app):
"""Both units: a newton offset at rest is the same display problem."""
panel = _panel("N")
for index in range(4):
panel.push(index / 30.0, *NEWTON_BIAS, unit="N")
if index < 2:
continue
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3, index
assert not panel.presentation.ready
def test_a_card_and_its_own_curve_are_the_same_value(qt_app):
"""A card may never read a number its curve does not carry.
The extra "the estimate is ready" gate in ``_render_value_cards`` is what
used to let the card show the raw measurement while the curve was already
drawn through the applied estimate.
"""
panel = _panel("raw")
for index in range(4):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
# A real contact, flagged as one so it does not enter the idle estimate.
panel.push(1.0, *(RAW_BIAS + [4000.0, 0.0, 0.0]), unit="raw", contact_found=True)
for channel in range(3):
drawn = float(_curve(panel, channel)[-1])
assert panel.value_labels[channel].text() == format_force(
drawn, "raw", presentation=panel.presentation
), channel
assert panel.value_labels[0].text() not in ("0", "1118"), "the contact was lost"
def test_a_resting_raw_channel_reads_zero_and_draws_a_flat_curve(qt_app):
"""FN 9555 / FX -13368 at rest must read 0 and draw a line on zero."""
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
assert panel.presentation.ready, "the quiet window never closed"
for index, label in enumerate(panel.value_labels):
assert label.text() == "0", (index, label.text())
for channel in (0, 1, 2):
values = _curve(panel, channel)
assert values.size, channel
assert set(np.unique(values)) == {0.0}, (channel, np.unique(values))
def test_a_resting_newton_channel_is_baselined_too(qt_app):
"""The same aid in newtons: it is a display problem in either unit."""
panel = _panel("N")
_rest(panel, NEWTON_BIAS, unit="N")
assert panel.presentation.ready
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3
assert set(np.unique(_curve(panel, 0))) == {0.0}
def test_noise_outside_the_estimated_band_is_still_rare(qt_app):
"""A p99 band, so a resting *noisy* channel is flat but not rigid.
The offset removal is what fixes a 13k-count bias; the percentile band holds
the remaining jitter at zero. A few idle samples may sit just outside it —
that is what the operator's deadband floor in the dialog is for.
"""
panel = _panel("raw")
_rest(panel, RAW_BIAS, sigma=30.0)
assert panel.presentation.ready
values = _curve(panel, 1)
zeroed = float(np.mean(values == 0.0))
assert zeroed >= 0.95, f"only {zeroed:.2%} of a resting channel was zeroed"
assert abs(float(np.median(values))) < 1.0
def test_the_record_keeps_the_measurement_while_the_display_is_zeroed(qt_app):
"""The deadband is a display copy: the buffer and the store see the truth."""
panel = _panel("raw")
store = SessionStore()
panel.set_store(store)
for index in range(6):
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
store.append(
SessionSample(
index,
index / 30.0,
fn_raw=RAW_BIAS[0],
fx_raw=RAW_BIAS[1],
fy_raw=RAW_BIAS[2],
fn_n=NEWTON_BIAS[0],
fx_n=NEWTON_BIAS[1],
fy_n=NEWTON_BIAS[2],
valid=True,
)
)
# The very values the customer complained about are still measured, from the
# first sample on — in the live buffer...
state = panel.history.state()
assert state is not None
assert abs(float(np.median(state.values[:, 0])) - RAW_BIAS[0]) < 100.0
assert abs(float(np.median(state.values[:, 1])) - RAW_BIAS[1]) < 100.0
# ...and in the session store (which is what forces.csv is written from).
stored = store.data().raw
assert stored.shape[0] == 6
assert float(np.median(stored[:, 0])) == RAW_BIAS[0]
assert float(np.median(stored[:, 1])) == RAW_BIAS[1]
assert float(np.median(stored[:, 2])) == RAW_BIAS[2]
# ...while the cards and the curve drawn from it are already flat on zero.
assert [label.text() for label in panel.value_labels] == ["0"] * 3
assert set(np.unique(_curve(panel, 1))) == {0.0}
# ------------------------------------------------------------------ deadband
def test_a_small_excursion_is_suppressed_and_a_larger_one_is_not(qt_app):
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
estimate = panel.presentation.last_estimate
assert estimate is not None and estimate.quality == "ok"
deadband = float(estimate.deadbands[0])
assert deadband > 0.0
panel.push(3.0, *(RAW_BIAS + [0.5 * deadband, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() == "0", "half the deadband was drawn"
panel.push(3.1, *(RAW_BIAS + [3.0 * deadband, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() != "0", (
"an excursion well outside the deadband was swallowed"
)
assert abs(_curve(panel, 0)[-1]) > 2.0 * deadband
# ...and the other axes, which stayed quiet, are still zero.
assert panel.value_labels[1].text() == "0"
def test_the_operator_floor_widens_the_deadband_at_once(qt_app):
panel = _panel("raw")
_rest(panel, RAW_BIAS, spread=20.0)
panel.set_deadband_floors(raw=250000.0)
panel.push(3.0, *(RAW_BIAS + [50000.0, 0.0, 0.0]), unit="raw")
assert panel.value_labels[0].text() == "0", (
"an excursion inside the operator's floor was drawn"
)
panel.set_deadband_floors(raw=0.0) # "auto": the layer's own floor returns
assert panel.presentation.min_deadband_for("raw") == 50.0
# -------------------------------------------------------------------- ranges
def test_the_configured_ranges_reach_every_axis(qt_app):
"""FN is asymmetric and may go below zero; FX/FY are symmetric."""
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=-5.0,
fn_max=15.0,
shear_max=3.0,
fn_raw_min=-100000.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
panel.set_fixed_newton(True)
# The panel starts in raw, so the raw limits are the ones on the axes.
assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0]
panel.set_units("N")
assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert panel._fixed_y_range(0) == (-5.0, 15.0)
assert panel._fixed_y_range(1) == (-3.0, 3.0)
# A range that starts below zero carries an explicit 0 tick: that is the
# "missing origin" the customer could not find.
assert 0.0 in _ticks(panel, 0)
panel.set_units("raw")
assert panel._fixed_y_range(0) == (-100000.0, 1000000.0)
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
def test_the_zero_line_is_only_drawn_where_zero_is_inside_the_range(qt_app):
"""pyqtgraph does not clip a layout item to its own view box."""
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=0.0,
fn_max=20.0,
shear_max=3.0,
fn_raw_min=100.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
view_box = panel.plots[0].getViewBox()
assert not panel.zero_lines[0].isVisibleTo(view_box), (
"a zero line was painted on an axis that does not reach zero"
)
assert panel.zero_lines[1].isVisibleTo(panel.plots[1].getViewBox())
assert 0.0 not in _ticks(panel, 0)
# Give FN a negative lower bound and the origin appears.
panel.set_axis_limits(
fn_min=0.0,
fn_max=20.0,
shear_max=3.0,
fn_raw_min=-100000.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
assert panel.zero_lines[0].isVisibleTo(view_box)
assert 0.0 in _ticks(panel, 0)
def test_an_inverted_range_is_widened_rather_than_collapsing_the_axis(qt_app):
panel = ForcePlotsPanel(theme="dark")
panel.set_axis_limits(
fn_min=5.0,
fn_max=2.0,
shear_max=3.0,
fn_raw_min=0.0,
fn_raw_max=1000000.0,
shear_raw_max=500000.0,
)
ymin, ymax = panel._fixed_y_range(0)
assert ymax > ymin
def test_the_default_ranges_are_the_ones_they_always_were(qt_app):
"""Defaults preserve today's behaviour: FN 0…20 N, shear ±20 N (and raw)."""
panel = ForcePlotsPanel(theme="dark")
assert panel._fixed_y_range(0) == (0.0, 1000000.0)
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
panel.set_units("N")
panel.set_fixed_newton(True)
assert panel._fixed_y_range(0) == (0.0, 20.0)
assert panel._fixed_y_range(1) == (-20.0, 20.0)
# With the lower bound at 0 nothing changes about the tick list either.
assert _nice_ticks(0.0, 20.0, 5) == [
(0.0, "0"),
(5.0, "5"),
(10.0, "10"),
(15.0, "15"),
(20.0, "20"),
]
+138
View File
@@ -0,0 +1,138 @@
"""The live Pause control: what it stops, and what it deliberately leaves alone.
Pause means "stop computing and stop the picture, keep the camera". It must never be
confused with 停止 (which releases the device) or with the chart's own browse pause, and
it must not survive a session.
It lives on the rail's transport button — the same control the replay view uses to play
and pause a recording. ``checked`` means "acquiring and not paused", so unchecking the
button *while a session exists* is the pause, and checking it again is the resume. That
is how one two-state toggle carries a three-state transport: the session state, not the
button, is what tells stopped from paused.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from tests.conftest import finish_run, running_window, show_window
def test_the_transport_is_idle_with_no_session(make_window):
"""Nothing to pause: the button says it runs, and there is no pause to reach."""
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
assert not window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
assert not window.act_pause.isEnabled()
window.close()
def test_unchecking_the_transport_pauses_and_never_stops_the_device(make_window):
window, stuck = running_window(make_window)
try:
assert window.toolbar.btn_run.isChecked(), "the transport does not show acquiring"
assert window.toolbar.btn_run.text() == t("toolbar.pause")
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True
assert stuck.is_paused is True, "the worker was not asked to pause"
assert stuck.pause_requests == 1
assert window.act_pause.isChecked(), "the menu action did not follow the transport"
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
# The device is still open and the session is still the session.
assert window._running and window._session_phase == "running", (
"pausing ended the session instead of holding it"
)
assert "暂停" in window.statusBar().currentMessage() or (
"Paus" in window.statusBar().currentMessage()
)
window.toolbar.btn_run.setChecked(True)
assert window._display_paused is False
assert stuck.is_paused is False, "the worker was not asked to resume"
assert stuck.pause_requests == 2
assert not window.act_pause.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.pause")
finally:
finish_run(window, stuck)
def test_unchecking_the_transport_with_no_session_does_nothing(make_window):
"""The unchecked state only means paused *while a session exists*."""
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
window._on_run_toggled(False)
assert window._display_paused is False
assert not window._running
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
window.close()
def test_the_stop_button_is_what_ends_a_paused_session(make_window):
"""Pause holds the session; only Stop releases the device."""
window, stuck = running_window(make_window)
try:
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True and window._running
assert window.toolbar.btn_stop.isEnabled(), "no way out of the paused session"
window.toolbar.btn_stop.click()
assert not window._running, "Stop did not end the session"
assert window._session_phase == "idle"
finally:
stuck.release()
def test_the_shortcut_drives_the_same_state_as_the_transport(make_window):
"""One authoritative control, so the two can never disagree."""
window, stuck = running_window(make_window)
try:
window.act_pause.setChecked(True)
assert window._display_paused is True
assert stuck.is_paused is True
assert not window.toolbar.btn_run.isChecked(), (
"the transport did not follow the action"
)
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
window.act_pause.setChecked(False)
assert window._display_paused is False
assert stuck.is_paused is False
assert window.toolbar.btn_run.isChecked()
assert window.toolbar.btn_run.text() == t("toolbar.pause")
finally:
finish_run(window, stuck)
def test_the_transport_says_which_way_it_goes(make_window):
window, stuck = running_window(make_window)
try:
acquiring = window.toolbar.btn_run.text()
acquiring_tip = window.toolbar.btn_run.toolTip()
window.toolbar.btn_run.setChecked(False)
paused = window.toolbar.btn_run.text()
paused_tip = window.toolbar.btn_run.toolTip()
assert acquiring and paused and acquiring != paused, (acquiring, paused)
assert acquiring == t("toolbar.pause")
assert paused == t("toolbar.resume_label")
# The tooltips explain what each state does (the label cannot) — the pause
# one including the gap a pause leaves in a recording.
assert acquiring_tip == t("toolbar.pause_tip")
assert paused_tip == t("toolbar.resume_label_tip")
gap_tip = t("toolbar.pause_tip")
assert "gap" in gap_tip or "空档" in gap_tip, gap_tip
finally:
finish_run(window, stuck)
def test_stopping_a_paused_session_clears_the_pause(make_window):
"""The next run must compute: a latched pause would look like a dead device."""
window, stuck = running_window(make_window)
window.toolbar.btn_run.setChecked(False)
assert window._display_paused is True
window._stop_run(reason="user_stop")
assert window._display_paused is False
assert not window.act_pause.isChecked()
assert not window.toolbar.btn_run.isChecked()
# The session is over, so the transport is idle again — not paused.
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
stuck.release()
@@ -13,7 +13,6 @@ window-level tests): these are the *rules*, not the decoder.
from __future__ import annotations
import pytest
from orisys_app_shared.i18n import t
from orisys_studio import replay_controller
from orisys_studio.replay_controller import ReplayController

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