From fa2703968e939ba2f2a377d5c9aff7f77e2d8458 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 27 Sep 2026 19:20:43 +0800 Subject: [PATCH] added ruff test --- apps/autocal/README.md | 11 + apps/autocal/config/locales/en_US.json | 30 ++ apps/autocal/config/locales/zh_CN.json | 30 ++ apps/autocal/docs/finger-preview.md | 24 ++ apps/autocal/docs/workflow.md | 11 + apps/autocal/src/orisys_autocal/main.py | 6 +- .../src/orisys_autocal/motion/__init__.py | 25 +- .../src/orisys_autocal/motion/kinematics.py | 8 +- .../src/orisys_autocal/motion/models.py | 6 +- .../src/orisys_autocal/motion/poller.py | 4 +- .../src/orisys_autocal/motion/pose_move.py | 315 ++++++++++++++++ .../src/orisys_autocal/motion/service.py | 221 +++++++++++- .../autocal/src/orisys_autocal/preferences.py | 88 ++++- .../src/orisys_autocal/sensor/__init__.py | 5 +- .../autocal/src/orisys_autocal/sequence_io.py | 3 +- .../src/orisys_autocal/ui/axis_panel.py | 7 +- .../src/orisys_autocal/ui/axis_readout.py | 47 ++- .../src/orisys_autocal/ui/brand_bar.py | 7 +- .../src/orisys_autocal/ui/bus_data_panel.py | 4 +- .../src/orisys_autocal/ui/bus_panel.py | 5 +- .../src/orisys_autocal/ui/connection_panel.py | 3 +- .../src/orisys_autocal/ui/device_setup.py | 150 +++++++- .../src/orisys_autocal/ui/pose_preview.py | 2 +- .../src/orisys_autocal/ui/sensor_panel.py | 94 ++++- .../src/orisys_autocal/ui/sequence_panel.py | 6 +- .../src/orisys_autocal/ui/status_strip.py | 7 +- apps/autocal/src/orisys_autocal/ui/window.py | 188 +++++++++- .../src/orisys_autocal/ui/workspace.py | 3 +- apps/autocal/tests/conftest.py | 2 - .../autocal/tests/test_controller_sequence.py | 1 - apps/autocal/tests/test_device_setup.py | 40 ++- apps/autocal/tests/test_errors.py | 1 - apps/autocal/tests/test_finger_panel.py | 7 +- apps/autocal/tests/test_ik_analytical.py | 3 +- apps/autocal/tests/test_kinematics.py | 2 - apps/autocal/tests/test_locales_autocal.py | 37 +- apps/autocal/tests/test_move_buttons.py | 192 ++++++++++ apps/autocal/tests/test_poller.py | 8 +- apps/autocal/tests/test_pose_move.py | 337 ++++++++++++++++++ apps/autocal/tests/test_sensor_bundle.py | 1 - apps/autocal/tests/test_sequence_io.py | 1 - apps/autocal/tests/test_units.py | 1 - apps/autocal/tests/test_uv_texture.py | 1 - apps/autocal/tools/layout_mockup.py | 83 +++-- apps/autocal/tools/panel_check.py | 226 +++++++++++- apps/autocal/tools/panel_snapshot.py | 6 +- apps/autocal/tools/preview_snapshot.py | 5 +- apps/studio/src/orisys_studio/app_window.py | 51 ++- .../src/orisys_studio/calibration_service.py | 8 +- .../studio/src/orisys_studio/display_prefs.py | 10 +- apps/studio/src/orisys_studio/main.py | 9 +- apps/studio/src/orisys_studio/recording.py | 15 +- apps/studio/src/orisys_studio/replay.py | 8 +- .../src/orisys_studio/replay_controller.py | 13 +- .../src/orisys_studio/scene3d/adapters.py | 36 +- .../src/orisys_studio/scene3d/asset_source.py | 37 +- .../src/orisys_studio/scene3d/camera.py | 6 +- .../src/orisys_studio/scene3d/coordinator.py | 27 +- .../src/orisys_studio/scene3d/gl_backend.py | 63 ++-- apps/studio/src/orisys_studio/scene3d/keys.py | 11 +- .../studio/src/orisys_studio/scene3d/panel.py | 49 ++- .../studio/src/orisys_studio/scene3d/prefs.py | 14 +- .../orisys_studio/scene3d/settings_dialog.py | 7 +- apps/studio/src/orisys_studio/scene3d/view.py | 63 ++-- .../src/orisys_studio/scene3d/widget.py | 35 +- .../src/orisys_studio/session_controller.py | 19 +- .../src/orisys_studio/session_metadata.py | 6 +- .../src/orisys_studio/ui/about_dialog.py | 5 +- .../src/orisys_studio/ui/arrow_panel.py | 22 +- apps/studio/src/orisys_studio/ui/brand_bar.py | 7 +- apps/studio/src/orisys_studio/ui/cal_panel.py | 19 +- .../src/orisys_studio/ui/cal_point_dialog.py | 7 +- .../src/orisys_studio/ui/force_plots.py | 25 +- apps/studio/src/orisys_studio/ui/icons.py | 3 +- .../src/orisys_studio/ui/mesh3d_panel.py | 46 +-- .../orisys_studio/ui/plot_settings_dialog.py | 14 +- .../src/orisys_studio/ui/record_status.py | 3 +- .../src/orisys_studio/ui/sensor_dialog.py | 3 +- .../src/orisys_studio/ui/sensor_picker.py | 5 +- apps/studio/src/orisys_studio/ui/toolbar.py | 5 +- apps/studio/src/orisys_studio/view_layout.py | 8 +- apps/studio/tests/conftest.py | 2 +- apps/studio/tests/test_force_plots_display.py | 1 - apps/studio/tests/test_pause_controls.py | 1 - apps/studio/tests/test_replay_controller.py | 1 - apps/studio/tests/test_scene3d_adapters.py | 19 +- apps/studio/tests/test_scene3d_coordinator.py | 1 - apps/studio/tests/test_scene3d_gl_smoke.py | 7 +- apps/studio/tests/test_scene3d_overlays.py | 1 - apps/studio/tests/test_scene3d_pane_ui.py | 11 +- .../tests/test_scene3d_protected_package.py | 1 - .../tests/test_scene3d_region_agreement.py | 3 +- apps/studio/tests/test_scene3d_skeleton.py | 5 +- apps/studio/tests/test_scene3d_stage1.py | 1 - apps/studio/tests/test_scene3d_view.py | 20 +- apps/studio/tests/test_sensor_types.py | 2 - apps/studio/tests/test_session_controller.py | 1 - apps/studio/tests/test_session_metadata.py | 3 +- apps/studio/tests/test_transport_review.py | 1 - apps/studio/tests/test_ui_smoke.py | 66 ++-- apps/studio/tests/test_view_layout.py | 5 +- apps/studio/tools/ui_check.py | 28 +- apps/studio/tools/ui_snapshot.py | 9 +- docs/architecture/studio-3d-contracts.pyi | 12 +- docs/code-review.md | 74 ++++ ruff.toml | 56 +++ .../orisys_app_shared/acquisition/arrows.py | 24 +- .../acquisition/finger_worker.py | 52 +-- .../orisys_app_shared/acquisition/geometry.py | 6 +- .../acquisition/image_backend.py | 38 +- .../orisys_app_shared/acquisition/models.py | 25 +- .../acquisition/sdk_worker.py | 71 ++-- .../orisys_app_shared/acquisition/sinks.py | 3 +- .../src/orisys_app_shared/camera/__init__.py | 6 +- .../src/orisys_app_shared/camera/discovery.py | 2 +- shared/src/orisys_app_shared/compat.py | 3 +- .../src/orisys_app_shared/force/__init__.py | 10 +- .../orisys_app_shared/force/calibration_io.py | 34 +- .../force/calibration_recorder.py | 22 +- .../force/calibration_session.py | 27 +- shared/src/orisys_app_shared/force/filters.py | 23 +- shared/src/orisys_app_shared/force/history.py | 7 +- .../orisys_app_shared/force/presentation.py | 42 +-- shared/src/orisys_app_shared/force/session.py | 10 +- shared/src/orisys_app_shared/force/units.py | 3 +- .../orisys_app_shared/logging/qt_handler.py | 2 +- .../src/orisys_app_shared/record/__init__.py | 6 + .../orisys_app_shared/record/experiment.py | 40 +-- shared/src/orisys_app_shared/record/index.py | 24 +- .../src/orisys_app_shared/record/playback.py | 22 +- shared/src/orisys_app_shared/record/sample.py | 8 +- .../orisys_app_shared/record/video_writer.py | 5 +- .../record/visualization3d.py | 15 +- .../src/orisys_app_shared/scene3d/__init__.py | 17 +- .../src/orisys_app_shared/scene3d/assets.py | 14 +- .../src/orisys_app_shared/scene3d/composer.py | 4 +- .../orisys_app_shared/scene3d/contracts.py | 79 ++-- .../src/orisys_app_shared/scene3d/digest.py | 5 +- shared/src/orisys_app_shared/scene3d/glb.py | 7 +- .../src/orisys_app_shared/scene3d/legacy.py | 31 +- .../src/orisys_app_shared/scene3d/overlays.py | 6 +- .../src/orisys_app_shared/scene3d/package.py | 25 +- .../src/orisys_app_shared/scene3d/protect.py | 15 +- .../src/orisys_app_shared/scene3d/sampling.py | 4 +- .../scene3d/visual_policy.py | 25 +- .../src/orisys_app_shared/sensors/catalog.py | 2 - .../src/orisys_app_shared/zmotion/client.py | 6 +- shared/tests/test_calibration_identity.py | 1 - shared/tests/test_calibration_model.py | 1 - shared/tests/test_calibration_record.py | 1 - shared/tests/test_display_units.py | 1 - shared/tests/test_experiment_recorder.py | 1 - shared/tests/test_force_filters.py | 1 - shared/tests/test_force_history.py | 1 - shared/tests/test_force_presentation.py | 1 - shared/tests/test_history_source.py | 1 - shared/tests/test_image_backend.py | 1 - shared/tests/test_module_boundaries.py | 3 +- shared/tests/test_overlays.py | 1 - shared/tests/test_playback.py | 4 - shared/tests/test_record_channel.py | 3 +- shared/tests/test_record_lifecycle.py | 1 - shared/tests/test_record_semantics.py | 1 - shared/tests/test_scene3d_assets.py | 5 +- shared/tests/test_scene3d_contracts.py | 2 - shared/tests/test_scene3d_device_geometry.py | 1 - shared/tests/test_scene3d_live.py | 9 - shared/tests/test_scene3d_package.py | 5 +- shared/tests/test_scene3d_protect.py | 1 - shared/tests/test_visual_policy.py | 1 - shared/tests/test_worker_cancellation.py | 2 +- tools/lint_gate.py | 193 ++++++++++ tools/scene_assets/bench_scene.py | 9 +- tools/scene_assets/compile_scene.py | 3 +- tools/scene_assets/fit_design_frame.py | 12 +- tools/scene_assets/probe_gl.py | 8 +- tools/scene_assets/soak_scene.py | 6 +- tools/scene_assets/validate_scene.py | 7 +- 178 files changed, 3277 insertions(+), 1034 deletions(-) create mode 100644 apps/autocal/src/orisys_autocal/motion/pose_move.py create mode 100644 apps/autocal/tests/test_move_buttons.py create mode 100644 apps/autocal/tests/test_pose_move.py create mode 100644 docs/code-review.md create mode 100644 ruff.toml create mode 100644 tools/lint_gate.py diff --git a/apps/autocal/README.md b/apps/autocal/README.md index 0063daa..11f48dd 100644 --- a/apps/autocal/README.md +++ b/apps/autocal/README.md @@ -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 diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index 27e0bfc..9356f18 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -34,9 +34,39 @@ "hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result, and this window can stay open while you work.", "tab_connection": "Connection", "tab_bus": "Bus initialisation", + "tab_axes": "Axis mapping", + "axes_hint": "The bus reports axis numbers, not what each one moves. The mapping below comes from the bench wiring, and it must be complete and free of duplicates before anything moves.", + "role_x": "X (table)", + "role_y": "Y (table)", + "role_z": "Z (press)", + "role_yaw": "yaw (rotation)", + "role_pitch": "pitch (tilt)", + "role_axis": "Drive", + "axes_duplicate": "One drive is claimed by more than one direction: {detail}", "close": "Close", "chip_hint": "Open device setup — {name}" }, + "move": { + "execute": "Execute", + "execute_hint": "Send the selected point's solved pose to the platform: yaw, then pitch, then table X/Y. The press axis Z is not moved.", + "stop": "Stop", + "stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).", + "return_initial": "Return to zero", + "return_hint": "Every axis back to the initial state, press axis Z first.", + "confirm_title": "Confirm motion", + "confirm_execute": "Will move, in this order:\n\n{steps}\n\nCheck this against the machine before continuing.", + "confirm_return": "Will move every axis back to zero (press axis Z first):\n\n{steps}", + "hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)", + "cancelled": "Cancelled; nothing was commanded", + "confirm_check": "Confirm before moving", + "confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.", + "no_point": "No calibration point is selected", + "busy": "A move is already running", + "refused": "Motion refused", + "done": "Move finished", + "failed": "Move failed", + "stopped": "Stopped; the remaining steps were cancelled" + }, "connection": { "title": "Controller connection", "mode": "Connection", diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 5df3e6b..d7fadeb 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -34,9 +34,39 @@ "hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。", "tab_connection": "连接", "tab_bus": "总线初始化", + "tab_axes": "轴映射", + "axes_hint": "总线只报告轴号,不报告它推的是哪个方向。这里的对应关系来自实际接线,运动前必须完整且不重复。", + "role_x": "X(工作台)", + "role_y": "Y(工作台)", + "role_z": "Z(压轴)", + "role_yaw": "yaw(回转)", + "role_pitch": "pitch(俯仰)", + "role_axis": "驱动器", + "axes_duplicate": "同一驱动器被分配给多个方向:{detail}", "close": "关闭", "chip_hint": "点击打开设备设置 — {name}" }, + "move": { + "execute": "执行动作", + "execute_hint": "把选中点解算出的位姿送到平台:先回转、再俯仰,最后工作台 X/Y。压轴 Z 不动作。", + "stop": "停止", + "stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。", + "return_initial": "回初始位", + "return_hint": "五个轴全部回到零点,先退压轴 Z。", + "confirm_title": "确认运动", + "confirm_execute": "将按以下顺序移动:\n\n{steps}\n\n请确认仿真与机床一致。", + "confirm_return": "将把五个轴全部移回零点(先退压轴 Z):\n\n{steps}", + "hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)", + "cancelled": "已取消,未发出任何运动", + "confirm_check": "移动前确认", + "confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。", + "no_point": "未选中标定点,无法执行", + "busy": "已有运动在执行", + "refused": "运动被拒绝", + "done": "运动完成", + "failed": "运动失败", + "stopped": "已停止,后续动作已取消" + }, "connection": { "title": "连接控制器", "mode": "连接方式", diff --git a/apps/autocal/docs/finger-preview.md b/apps/autocal/docs/finger-preview.md index c6c2d1b..6e56f7d 100644 --- a/apps/autocal/docs/finger-preview.md +++ b/apps/autocal/docs/finger-preview.md @@ -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 diff --git a/apps/autocal/docs/workflow.md b/apps/autocal/docs/workflow.md index ef5bd0c..5c5c503 100644 --- a/apps/autocal/docs/workflow.md +++ b/apps/autocal/docs/workflow.md @@ -236,6 +236,17 @@ measured time per point. **Gate:** the operator has seen the path and a dry run has completed without a guard violation. +**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the +selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and +does **not** command the press axis, so it cannot reach the sensor. The order is the +model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator +watched in the preview. Before it moves it checks the mapping, the target against the +P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration — +all in `motion/service.py`, so a later caller cannot walk around them — and the drives +are commanded one at a time, each waiting for the previous to arrive, with a stall or +an alarm aborting the rest. 停止 next to it is the same command as the strip's +全部停止; 回初始位 in the rail sends every axis to zero with the press axis first. + --- ## P7 — Run + acquire diff --git a/apps/autocal/src/orisys_autocal/main.py b/apps/autocal/src/orisys_autocal/main.py index 7b22dce..239c1b6 100644 --- a/apps/autocal/src/orisys_autocal/main.py +++ b/apps/autocal/src/orisys_autocal/main.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/motion/__init__.py b/apps/autocal/src/orisys_autocal/motion/__init__.py index 1af5a18..c7c889e 100644 --- a/apps/autocal/src/orisys_autocal/motion/__init__.py +++ b/apps/autocal/src/orisys_autocal/motion/__init__.py @@ -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", diff --git a/apps/autocal/src/orisys_autocal/motion/kinematics.py b/apps/autocal/src/orisys_autocal/motion/kinematics.py index 4002921..1f9fd33 100644 --- a/apps/autocal/src/orisys_autocal/motion/kinematics.py +++ b/apps/autocal/src/orisys_autocal/motion/kinematics.py @@ -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. diff --git a/apps/autocal/src/orisys_autocal/motion/models.py b/apps/autocal/src/orisys_autocal/motion/models.py index 5a33c5a..0c9a79a 100644 --- a/apps/autocal/src/orisys_autocal/motion/models.py +++ b/apps/autocal/src/orisys_autocal/motion/models.py @@ -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) diff --git a/apps/autocal/src/orisys_autocal/motion/poller.py b/apps/autocal/src/orisys_autocal/motion/poller.py index 1791a55..f6395fe 100644 --- a/apps/autocal/src/orisys_autocal/motion/poller.py +++ b/apps/autocal/src/orisys_autocal/motion/poller.py @@ -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) diff --git a/apps/autocal/src/orisys_autocal/motion/pose_move.py b/apps/autocal/src/orisys_autocal/motion/pose_move.py new file mode 100644 index 0000000..619a1b5 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/motion/pose_move.py @@ -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}" + ) diff --git a/apps/autocal/src/orisys_autocal/motion/service.py b/apps/autocal/src/orisys_autocal/motion/service.py index f1fb879..95c1739 100644 --- a/apps/autocal/src/orisys_autocal/motion/service.py +++ b/apps/autocal/src/orisys_autocal/motion/service.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/preferences.py b/apps/autocal/src/orisys_autocal/preferences.py index c6a928d..3c81db4 100644 --- a/apps/autocal/src/orisys_autocal/preferences.py +++ b/apps/autocal/src/orisys_autocal/preferences.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/sensor/__init__.py b/apps/autocal/src/orisys_autocal/sensor/__init__.py index 3b97473..fa89e72 100644 --- a/apps/autocal/src/orisys_autocal/sensor/__init__.py +++ b/apps/autocal/src/orisys_autocal/sensor/__init__.py @@ -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", diff --git a/apps/autocal/src/orisys_autocal/sequence_io.py b/apps/autocal/src/orisys_autocal/sequence_io.py index 4a00118..b89c736 100644 --- a/apps/autocal/src/orisys_autocal/sequence_io.py +++ b/apps/autocal/src/orisys_autocal/sequence_io.py @@ -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" diff --git a/apps/autocal/src/orisys_autocal/ui/axis_panel.py b/apps/autocal/src/orisys_autocal/ui/axis_panel.py index ff3a38e..b6b0b85 100644 --- a/apps/autocal/src/orisys_autocal/ui/axis_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/axis_panel.py @@ -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}" diff --git a/apps/autocal/src/orisys_autocal/ui/axis_readout.py b/apps/autocal/src/orisys_autocal/ui/axis_readout.py index 5da18ca..d6c7744 100644 --- a/apps/autocal/src/orisys_autocal/ui/axis_readout.py +++ b/apps/autocal/src/orisys_autocal/ui/axis_readout.py @@ -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) diff --git a/apps/autocal/src/orisys_autocal/ui/brand_bar.py b/apps/autocal/src/orisys_autocal/ui/brand_bar.py index ede14df..8c9e02d 100644 --- a/apps/autocal/src/orisys_autocal/ui/brand_bar.py +++ b/apps/autocal/src/orisys_autocal/ui/brand_bar.py @@ -7,13 +7,12 @@ controller is reachable. from __future__ import annotations -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget - from orisys_app_shared.i18n import t from orisys_app_shared.paths import logo_topbar_path from orisys_app_shared.theme import load_colors +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget #: Chip tone -> colour token name. _TONE_TOKENS = { diff --git a/apps/autocal/src/orisys_autocal/ui/bus_data_panel.py b/apps/autocal/src/orisys_autocal/ui/bus_data_panel.py index fe6a05e..2d73004 100644 --- a/apps/autocal/src/orisys_autocal/ui/bus_data_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/bus_data_panel.py @@ -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. diff --git a/apps/autocal/src/orisys_autocal/ui/bus_panel.py b/apps/autocal/src/orisys_autocal/ui/bus_panel.py index 48ac018..353cc70 100644 --- a/apps/autocal/src/orisys_autocal/ui/bus_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/bus_panel.py @@ -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 = { diff --git a/apps/autocal/src/orisys_autocal/ui/connection_panel.py b/apps/autocal/src/orisys_autocal/ui/connection_panel.py index a76ec05..356c1e3 100644 --- a/apps/autocal/src/orisys_autocal/ui/connection_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/connection_panel.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/ui/device_setup.py b/apps/autocal/src/orisys_autocal/ui/device_setup.py index 6a81cbc..7830a21 100644 --- a/apps/autocal/src/orisys_autocal/ui/device_setup.py +++ b/apps/autocal/src/orisys_autocal/ui/device_setup.py @@ -1,9 +1,11 @@ -"""The device setup: connection and bus initialisation, behind one button. +"""The device setup: connection, bus initialisation and the axis mapping. -Both groups are touched once per machine start-up — reach the controller, download -the bus program, watch the bus come up — and then not again for the rest of the -session. In the left rail they were spending ~460 px of its height on that, above -the axis readout that is read all day, which is what made the rail scroll. +These are the facts about the machine itself, as opposed to the job: reach the +controller, download the bus program, watch the bus come up, and say which drive moves +which platform direction. All of them are touched once per machine start-up — or once +per re-wire — and then not again for the rest of the session. In the left rail the +first two were spending ~460 px of its height, above the axis readout that is read all +day, which is what made the rail scroll. They live in a **modeless dialog** rather than a modal one on purpose: connecting and downloading are asynchronous (the poller runs the state machine, the status strip @@ -12,35 +14,156 @@ the axis readout while it happens. Modeless also means the dialog can be opened several places — the strip's button, its state chips, the File menu — without any of them having to block. -The dialog owns the two panels; the window keeps a reference to each +The dialog owns the connection and bus panels; the window keeps a reference to each (``window.connection_panel`` / ``window.bus_panel``) because it is the window that wires them to the controller, and because the tools and checks reach for them there. +The axis mapping is read back by the window on every change, because the motion guards +have to see the same numbers the page shows. """ from __future__ import annotations +from orisys_app_shared.i18n import t +from PySide6.QtCore import Signal from PySide6.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, + QFormLayout, + QGroupBox, QLabel, QTabWidget, QVBoxLayout, QWidget, ) -from orisys_app_shared.i18n import t - from ..motion import ConnectionSpec +from ..motion.service import AXIS_ROLES, DEFAULT_AXIS_ROLES, PLATFORM_AXES from .bus_panel import BusPanel from .connection_panel import ConnectionPanel #: Tab identity -> locale key, in the order they are shown. The order is the work: -#: reach the machine, then bring its bus up. +#: reach the machine, bring its bus up, then say what each drive moves. TABS: tuple[tuple[str, str], ...] = ( ("connection", "setup.tab_connection"), ("bus", "setup.tab_bus"), + ("axes", "setup.tab_axes"), ) +#: Role -> locale key for the mapping rows. +_ROLE_KEYS = { + "x": "setup.role_x", + "y": "setup.role_y", + "z": "setup.role_z", + "yaw": "setup.role_yaw", + "pitch": "setup.role_pitch", +} + + +class AxisRolesPage(QWidget): + """Which drive moves which platform direction. + + A page of its own because it is a separate fact: connection and bus are about + *reaching* the machine, this is about what its drives do. Getting it wrong moves + the wrong motor, so a duplicate is reported rather than quietly accepted — and the + motion guards read the same mapping, so a page left in a contradictory state + refuses motion instead of guessing which claim was meant. + """ + + rolesChanged = Signal() + + def __init__( + self, roles: dict[str, int] | None = None, parent: QWidget | None = None + ) -> None: + super().__init__(parent) + column = QVBoxLayout(self) + column.setContentsMargins(8, 8, 8, 8) + column.setSpacing(8) + + self.hint_label = QLabel(self) + self.hint_label.setObjectName("hintLabel") + self.hint_label.setWordWrap(True) + column.addWidget(self.hint_label) + + self.box = QGroupBox(self) + self.box.setObjectName("groupBox") + form = QFormLayout(self.box) + form.setContentsMargins(10, 8, 10, 10) + form.setSpacing(6) + + self.combos: dict[str, QComboBox] = {} + self.labels: dict[str, QLabel] = {} + for role in AXIS_ROLES: + label = QLabel(self.box) + combo = QComboBox(self.box) + for axis in range(PLATFORM_AXES): + combo.addItem(str(axis), axis) + combo.currentIndexChanged.connect(self._on_changed) + self.labels[role] = label + self.combos[role] = combo + form.addRow(label, combo) + column.addWidget(self.box) + + self.problem_label = QLabel(self) + self.problem_label.setObjectName("statusValue") + self.problem_label.setWordWrap(True) + column.addWidget(self.problem_label) + column.addStretch(1) + + self.set_roles(roles or {}) + self.retranslate() + + # ------------------------------------------------------------------- state + + def set_roles(self, roles: dict[str, int]) -> None: + """Show a mapping without announcing a change the operator did not make. + + An empty mapping falls back to the bench default rather than to "every role + on drive 0", which would open the page in a contradictory state and — because + the guards read the same mapping — refuse motion for a reason nobody typed. + """ + source = dict(roles) or dict(DEFAULT_AXIS_ROLES) + for role, combo in self.combos.items(): + value = source.get(role) + index = combo.findData(int(value)) if value is not None else -1 + combo.blockSignals(True) + try: + combo.setCurrentIndex(index if index >= 0 else 0) + finally: + combo.blockSignals(False) + self._refresh_problem(announce=False) + + def roles(self) -> dict[str, int]: + return {role: int(combo.currentData()) for role, combo in self.combos.items()} + + def problem(self) -> str: + """The duplicate mapping, described, or ``""`` when it is sound.""" + seen: dict[int, str] = {} + for role, combo in self.combos.items(): + axis = int(combo.currentData()) + if axis in seen: + return t("setup.axes_duplicate", detail=f"{seen[axis]} ↔ {role}") + seen[axis] = role + return "" + + def _on_changed(self, _index: int) -> None: + self._refresh_problem() + + def _refresh_problem(self, *, announce: bool = True) -> None: + problem = self.problem() + self.problem_label.setText(f"⚠ {problem}" if problem else "") + if announce: + self.rolesChanged.emit() + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.hint_label.setText(t("setup.axes_hint")) + self.box.setTitle(t("setup.tab_axes")) + for role, label in self.labels.items(): + label.setText(t(_ROLE_KEYS[role])) + self._refresh_problem(announce=False) + class DeviceSetupDialog(QDialog): """Connection and bus initialisation, the once-per-start controls.""" @@ -49,6 +172,7 @@ class DeviceSetupDialog(QDialog): self, spec: ConnectionSpec, bas_path: str = "", + roles: dict[str, int] | None = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) @@ -70,6 +194,7 @@ class DeviceSetupDialog(QDialog): self.tabs.setObjectName("setupTabs") self.connection_panel = ConnectionPanel(spec, self.tabs) self.bus_panel = BusPanel(bas_path, self.tabs) + self.roles_page = AxisRolesPage(roles, self.tabs) for key, locale_key in TABS: self.tabs.addTab(self.panel(key), "") column.addWidget(self.tabs, 1) @@ -90,8 +215,10 @@ class DeviceSetupDialog(QDialog): # ----------------------------------------------------------------- content def panel(self, key: str) -> QWidget: - """One of the two panels, by tab key.""" - return self.connection_panel if key == "connection" else self.bus_panel + """One of the three pages, by tab key.""" + if key == "connection": + return self.connection_panel + return self.bus_panel if key == "bus" else self.roles_page def open_for(self, tab: str | None = None) -> None: """Show the dialog, raising it if it is already up, and pick a tab. @@ -155,3 +282,4 @@ class DeviceSetupDialog(QDialog): self.close_button.setText(t("setup.close")) self.connection_panel.retranslate() self.bus_panel.retranslate() + self.roles_page.retranslate() diff --git a/apps/autocal/src/orisys_autocal/ui/pose_preview.py b/apps/autocal/src/orisys_autocal/ui/pose_preview.py index b6523aa..63f8992 100644 --- a/apps/autocal/src/orisys_autocal/ui/pose_preview.py +++ b/apps/autocal/src/orisys_autocal/ui/pose_preview.py @@ -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 diff --git a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py index 1664f7c..8d890bc 100644 --- a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py @@ -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] ) diff --git a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py index cf5e092..e42761f 100644 --- a/apps/autocal/src/orisys_autocal/ui/sequence_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sequence_panel.py @@ -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: diff --git a/apps/autocal/src/orisys_autocal/ui/status_strip.py b/apps/autocal/src/orisys_autocal/ui/status_strip.py index 7ee8559..12568db 100644 --- a/apps/autocal/src/orisys_autocal/ui/status_strip.py +++ b/apps/autocal/src/orisys_autocal/ui/status_strip.py @@ -20,19 +20,18 @@ from __future__ import annotations import time +from orisys_app_shared.i18n import t +from orisys_app_shared.theme import load_colors from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, QHBoxLayout, QLabel, - QPushButton, QProgressBar, + QPushButton, QWidget, ) -from orisys_app_shared.i18n import t -from orisys_app_shared.theme import load_colors - from ..motion import Snapshot #: Tone -> colour token. Matches the brand bar's chip vocabulary. diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index a5bfd50..7d17520 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -15,12 +15,11 @@ import sys from pathlib import Path import numpy as np - -from orisys_app_shared import API_VERSION, __version__ as shared_version +from orisys_app_shared import API_VERSION +from orisys_app_shared import __version__ as shared_version from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.paths import outputs_dir from orisys_app_shared.theme import build_qss, load_colors - from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QAction, QCloseEvent from PySide6.QtWidgets import ( @@ -40,9 +39,17 @@ from PySide6.QtWidgets import ( from .. import __version__ as app_version from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot from ..motion.kinematics import PlatformStack +from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial from ..motion.service import clamp_bas_task +from ..motion.units import DEFAULT_UNITS from ..preferences import load_preferences, save_preferences -from ..sensor import BundleError, load_bundle, load_uv_texture, rasterise, texture_path_for +from ..sensor import ( + BundleError, + load_bundle, + load_uv_texture, + rasterise, + texture_path_for, +) from ..sensor.uv_flat import DEFAULT_FLAT_SIZE from ..sequence_io import ( FILE_SUFFIX, @@ -145,6 +152,7 @@ class AutoCalMainWindow(QMainWindow): # Its size and last tab, from the previous session. Applied here rather than # in ``_build_ui`` because it is stored state, like the window geometry. self.device_setup.apply_settings(self.prefs.get("device_setup")) + self.finger_panel.set_confirm(self._confirm_moves) self.retranslate() # Load the remembered bundle, so the finger debugger comes up working. @@ -206,10 +214,16 @@ class AutoCalMainWindow(QMainWindow): # so the two panels always exist — the window wires them to the controller, # and the checks and tools reach for them here. self.device_setup = DeviceSetupDialog( - self.spec, str(self.prefs.get("bas_path") or ""), self + self.spec, + str(self.prefs.get("bas_path") or ""), + self.prefs.get("axis_roles"), + self, ) self.connection_panel = self.device_setup.connection_panel self.bus_panel = self.device_setup.bus_panel + # The mapping is live: the guards read it from the service, so an edit on the + # page has to reach the service rather than only the preference file. + self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed) self.axis_readout = AxisReadoutPanel( MotionService().axis_count, self._theme, self.rail_content @@ -331,6 +345,10 @@ class AutoCalMainWindow(QMainWindow): self.axis_readout.stopAllRequested.connect(self._on_stop_all) self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms) self.axis_readout.enableAllRequested.connect(self._on_enable_all) + self.axis_readout.returnRequested.connect(self._on_return_to_initial) + self.finger_panel.executeRequested.connect(self._on_execute_pose) + self.finger_panel.stopRequested.connect(self._on_stop_all) + self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed) self.run_bar.runRequested.connect(self._on_run_requested) self.run_bar.pauseRequested.connect(self._on_run_pause) self.run_bar.abortRequested.connect(self._on_run_abort) @@ -350,6 +368,20 @@ class AutoCalMainWindow(QMainWindow): """The brand bar's chip sends no argument; open on the connection tab.""" self.device_setup.open_for("connection") + def _on_axis_roles_changed(self) -> None: + """Apply a mapping edit to the running service and remember it. + + Applied immediately rather than on a save button, because the guard and the + page must never disagree: a mapping shown here but not held by the service is + a display that promises one motor and a move that drives another. + """ + roles = self.device_setup.roles_page.roles() + self.prefs["axis_roles"] = dict(roles) + self.poller.service.set_axis_roles(roles) + problem = self.poller.service.roles_problem() + if problem: + self.finger_panel.set_move_status(f"✗ {problem}") + # ---------------------------------------------------------- finger debug def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE): @@ -724,6 +756,12 @@ class AutoCalMainWindow(QMainWindow): MotionService( language=self._language, bas_task=clamp_bas_task(self.prefs.get("bas_task")), + units_per_mm=float(self.prefs.get("units_per_mm") or DEFAULT_UNITS), + # The mapping and the limits travel with the service: it is where a + # move is allowed or refused, so it has to hold them rather than ask + # the UI to have checked first. + axis_roles=self.prefs.get("axis_roles"), + limits=self.prefs.get("safety_limits"), ), axes=range(self.axis_panel.axis_count), ) @@ -733,6 +771,14 @@ class AutoCalMainWindow(QMainWindow): self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100)) self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100)) + # The pose mover: one axis at a time, advanced by the snapshots this poller + # already produces. Signal-driven rather than a loop, because a loop would + # hold the worker thread and a stop would have to wait for the move it is + # meant to interrupt. + self.mover = PoseMover(self.poller.service, self.poller.submit, self) + self.mover.failed.connect(self._on_move_failed) + self.mover.finished.connect(self._on_move_finished) + # Ages the poll-health chip even when no snapshot arrives. Runs on the # GUI thread and only reads a timestamp — the controller is untouched — # so a stopped poll becomes visible instead of leaving stale chips @@ -783,6 +829,17 @@ class AutoCalMainWindow(QMainWindow): self.bus_data_panel.set_bus_type(snapshot.bus.bus_type) self.axis_panel.update_snapshot(snapshot) self.axis_readout.update_snapshot(snapshot) + # Progress the pose move. It waits on the axis readings in this same + # snapshot, so it costs the poll nothing extra and cannot run ahead of the + # state it is deciding from. + if self.mover.running: + self.mover.on_snapshot(snapshot) + self.finger_panel.set_move_status(self.mover.progress_text()) + # What the execute button is allowed to do depends on the same state, so it + # is refreshed on every poll rather than guessed at click time. + self.finger_panel.set_move_available( + self._move_available(), moving=self.mover.running + ) # The strip is outside the tabs, so it is fed on every poll regardless # of which tab is showing. self.status_strip.set_snapshot(snapshot) @@ -796,6 +853,22 @@ class AutoCalMainWindow(QMainWindow): if self.diagnostics_box.isVisible(): self._refresh_diagnostics() + def _move_available(self) -> bool: + """Whether the platform may be commanded to a pose right now. + + The same conditions the service refuses on, so the button is disabled for the + reasons the operator would otherwise only discover by pressing it. The + service still checks — this is the UI telling the truth, not the guard. + """ + snapshot = self._last_snapshot + if not snapshot.connected or not snapshot.bus.initialized: + return False + if self.poller.service.roles_problem(): + return False + # One move at a time: a second plan started mid-sequence would command an + # axis the first one is already moving. + return not self.mover.running + def _refresh_status_bar(self) -> None: snapshot = self._last_snapshot if not snapshot.connected: @@ -947,8 +1020,113 @@ class AutoCalMainWindow(QMainWindow): def _stop_all(service: MotionService) -> object: return service.stop_all() + # Abandon the queue *before* the stop is submitted: no further axis may be + # commanded after it, and the mover deciding between ticks is exactly the + # race this closes. Stopping the axis that is moving is the controller's job. + if self.mover.running: + self.mover.cancel(t("move.stopped")) + self.finger_panel.set_move_status(t("move.stopped")) self.poller.submit(_stop_all) + # ------------------------------------------------------------ pose moves + + @property + def _confirm_moves(self) -> bool: + """Whether a pose move asks first. The operator's choice, remembered.""" + return bool(self.prefs.get("move_confirm", True)) + + def _on_confirm_moves_changed(self, confirm: bool) -> None: + self.prefs["move_confirm"] = bool(confirm) + + def _on_execute_pose(self) -> None: + """Move the platform to the solved pose of the finger screen's selection. + + Stages and table only: the press axis is deliberately not commanded, so this + is workflow P6's dry run for one point — it cannot reach the sensor. The + numbers shown in the confirmation are the ones the preview drew, so the + operator checks the simulation against the machine, not a summary of it. + """ + solve = self.finger_panel.solved_pose() + if solve is None: + self._refuse_move(t("move.no_point")) + return + targets, held = solve + plan = plan_pose_move( + self.poller.service, + targets, + title=t("move.execute"), + hold=held, + ) + self._start_move(plan, t("move.execute")) + + def _on_return_to_initial(self) -> None: + """Every drive back to zero, press axis first.""" + plan = plan_return_to_initial(self.poller.service) + self._start_move(plan, t("move.return_initial")) + + def _start_move(self, plan, title: str) -> None: + service = self.poller.service + refusal = service.check_axes_move( + {step.role: step.target for step in plan.steps} + ) + if refusal is not None: + self._refuse_move(refusal.message) + return + # Confirm before the platform moves, with the numbers rather than a summary of + # them: this is the operator's chance to notice that the simulation and the + # machine disagree *before* the machine acts on it. Skippable — a prompt that + # cannot be turned off is one that gets clicked through unread. + if self._confirm_moves: + steps = "\n".join( + f" {index + 1}. {step.role} → {step.target:+.4f} {step.unit}" + f" (驱动器 {step.axis})" + for index, step in enumerate(plan.steps) + ) + if plan.held: + steps += "\n\n " + t("move.hold_note", roles=", ".join(plan.held)) + body_key = ( + "move.confirm_return" + if title == t("move.return_initial") + else "move.confirm_execute" + ) + answer = QMessageBox.question( + self, + t("move.confirm_title"), + t(body_key, steps=steps), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + self.finger_panel.set_move_status(t("move.cancelled")) + return + if not self.mover.start(plan).ok: + self._refuse_move(self.mover.state.failure or t("move.busy")) + return + self.finger_panel.set_move_status(plan.describe()) + self.status_strip.set_action_result(f"{title}: {plan.describe()}", True) + + def _refuse_move(self, reason: str) -> None: + """A refused move is reported where the operator asked for it.""" + self._last_error = reason + self.finger_panel.set_move_status(f"✗ {reason}") + text = f"{t('move.refused')}: {reason}" + self.statusBar().showMessage(text) + self.status_strip.set_action_result(text, False) + logger.warning("Move refused: %s", reason) + + def _on_move_finished(self, plan) -> None: + if plan is not None: + self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}") + self.status_strip.set_action_result(t("move.done"), True) + + def _on_move_failed(self, message: str) -> None: + # A mover that gave up leaves an axis wherever it stopped: stop the rest + # rather than let the next step continue a sequence nobody has verified. + self.finger_panel.set_move_status(f"✗ {message}") + self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False) + self.statusBar().showMessage(f"{t('move.failed')}: {message}") + self.poller.submit(lambda service: service.stop_all()) + def _on_move_abs(self, axis: int, target: float) -> None: self.poller.submit(lambda service: service.move_abs(axis, target)) diff --git a/apps/autocal/src/orisys_autocal/ui/workspace.py b/apps/autocal/src/orisys_autocal/ui/workspace.py index eddf3b1..1a027b0 100644 --- a/apps/autocal/src/orisys_autocal/ui/workspace.py +++ b/apps/autocal/src/orisys_autocal/ui/workspace.py @@ -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 diff --git a/apps/autocal/tests/conftest.py b/apps/autocal/tests/conftest.py index 1d82737..520896e 100644 --- a/apps/autocal/tests/conftest.py +++ b/apps/autocal/tests/conftest.py @@ -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 diff --git a/apps/autocal/tests/test_controller_sequence.py b/apps/autocal/tests/test_controller_sequence.py index 296a134..7f41007 100644 --- a/apps/autocal/tests/test_controller_sequence.py +++ b/apps/autocal/tests/test_controller_sequence.py @@ -21,7 +21,6 @@ import time from pathlib import Path import pytest - from orisys_autocal.motion import ( DEFAULT_BAS_TASK, INIT_OK, diff --git a/apps/autocal/tests/test_device_setup.py b/apps/autocal/tests/test_device_setup.py index 2af69c4..6636539 100644 --- a/apps/autocal/tests/test_device_setup.py +++ b/apps/autocal/tests/test_device_setup.py @@ -16,9 +16,7 @@ What these tests protect: from __future__ import annotations import pytest - from orisys_app_shared.i18n import set_locale, t - from orisys_autocal.motion import ConnectionSpec from orisys_autocal.ui.device_setup import DeviceSetupDialog @@ -175,6 +173,44 @@ def test_the_dialog_remembers_its_size_and_tab(make_window): window.close() +def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_window): + """The page and the service must never disagree about which drive moves what.""" + from orisys_autocal.motion import DEFAULT_AXIS_ROLES + + window = make_window() + try: + dialog = window.device_setup + assert dialog.tabs.count() == 3 + page = dialog.roles_page + assert page.roles() == DEFAULT_AXIS_ROLES + assert page.problem() == "" + assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES + + # Two roles on one drive: reported on the page *and* by the guard. + page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3)) + assert page.problem() != "" + assert window.poller.service.roles_problem() != "" + assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None + + # Correcting it clears both. + page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2)) + assert page.problem() == "" + assert window.poller.service.roles_problem() == "" + finally: + window.close() + + +def test_the_mapping_page_opens_on_the_stored_mapping(make_window): + window = make_window( + axis_roles={"x": 1, "y": 0, "pitch": 2, "yaw": 3, "z": 4} + ) + try: + assert window.device_setup.roles_page.roles()["x"] == 1 + assert window.poller.service.axis_roles["x"] == 1 + finally: + window.close() + + def test_opening_the_setup_does_not_touch_the_connection(make_window): """Opening a dialog is not a command: it must not connect or disconnect.""" window = make_window() diff --git a/apps/autocal/tests/test_errors.py b/apps/autocal/tests/test_errors.py index 0a3a005..b0f078e 100644 --- a/apps/autocal/tests/test_errors.py +++ b/apps/autocal/tests/test_errors.py @@ -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) diff --git a/apps/autocal/tests/test_finger_panel.py b/apps/autocal/tests/test_finger_panel.py index 895cfb0..ff26b0f 100644 --- a/apps/autocal/tests/test_finger_panel.py +++ b/apps/autocal/tests/test_finger_panel.py @@ -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() diff --git a/apps/autocal/tests/test_ik_analytical.py b/apps/autocal/tests/test_ik_analytical.py index 2f00576..516a3c7 100644 --- a/apps/autocal/tests/test_ik_analytical.py +++ b/apps/autocal/tests/test_ik_analytical.py @@ -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 diff --git a/apps/autocal/tests/test_kinematics.py b/apps/autocal/tests/test_kinematics.py index 77e2a34..06820c0 100644 --- a/apps/autocal/tests/test_kinematics.py +++ b/apps/autocal/tests/test_kinematics.py @@ -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, diff --git a/apps/autocal/tests/test_locales_autocal.py b/apps/autocal/tests/test_locales_autocal.py index 21c9449..11d6b8e 100644 --- a/apps/autocal/tests/test_locales_autocal.py +++ b/apps/autocal/tests/test_locales_autocal.py @@ -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}" diff --git a/apps/autocal/tests/test_move_buttons.py b/apps/autocal/tests/test_move_buttons.py new file mode 100644 index 0000000..aa172ad --- /dev/null +++ b/apps/autocal/tests/test_move_buttons.py @@ -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() diff --git a/apps/autocal/tests/test_poller.py b/apps/autocal/tests/test_poller.py index 73fe5c5..5b71c50 100644 --- a/apps/autocal/tests/test_poller.py +++ b/apps/autocal/tests/test_poller.py @@ -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): diff --git a/apps/autocal/tests/test_pose_move.py b/apps/autocal/tests/test_pose_move.py new file mode 100644 index 0000000..a4ccd7b --- /dev/null +++ b/apps/autocal/tests/test_pose_move.py @@ -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) diff --git a/apps/autocal/tests/test_sensor_bundle.py b/apps/autocal/tests/test_sensor_bundle.py index 5660fd6..d765784 100644 --- a/apps/autocal/tests/test_sensor_bundle.py +++ b/apps/autocal/tests/test_sensor_bundle.py @@ -15,7 +15,6 @@ from __future__ import annotations import numpy as np import pytest - from orisys_autocal.sensor.export_bundle import ( BundleError, load_bundle, diff --git a/apps/autocal/tests/test_sequence_io.py b/apps/autocal/tests/test_sequence_io.py index 519053d..717ceb0 100644 --- a/apps/autocal/tests/test_sequence_io.py +++ b/apps/autocal/tests/test_sequence_io.py @@ -12,7 +12,6 @@ import shutil from pathlib import Path import pytest - from orisys_autocal.sequence_io import ( DOCUMENT_KIND, FORMAT_VERSION, diff --git a/apps/autocal/tests/test_units.py b/apps/autocal/tests/test_units.py index 1352903..e916849 100644 --- a/apps/autocal/tests/test_units.py +++ b/apps/autocal/tests/test_units.py @@ -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, diff --git a/apps/autocal/tests/test_uv_texture.py b/apps/autocal/tests/test_uv_texture.py index 1071e1e..3e59341 100644 --- a/apps/autocal/tests/test_uv_texture.py +++ b/apps/autocal/tests/test_uv_texture.py @@ -12,7 +12,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_autocal.sensor import ( DEFAULT_FLAT_SIZE, load_bundle, diff --git a/apps/autocal/tools/layout_mockup.py b/apps/autocal/tools/layout_mockup.py index db856a8..3ab0b3d 100644 --- a/apps/autocal/tools/layout_mockup.py +++ b/apps/autocal/tools/layout_mockup.py @@ -25,7 +25,6 @@ from __future__ import annotations import argparse import math -import math import os import sys from pathlib import Path @@ -116,16 +115,13 @@ STRINGS = { "setup_open": "设备设置", "setup_title": "设备设置", "setup_hint": "连接与总线初始化每次开机只需确认一次,因此收进这里;顶栏状态条持续显示结果,设置窗口可以一直开着。", - "setup_tabs": ["连接", "总线初始化"], + "setup_tabs": ["连接", "总线初始化", "轴映射"], "setup_rows": [ - ["连接方式", "以太网 ▾"], - ["IP", "192.168.0.11"], - ["状态", "已连接"], - ["BAS 文件", "…\\bas\\ECAT.bas"], - ["初始化状态", "初始化成功"], - ["总线类型", "EtherCAT"], - ["节点数量", "5"], - ["轴数量", "5"], + ["X(工作台)", "驱动器 0"], + ["Y(工作台)", "驱动器 1"], + ["Z(压轴)", "驱动器 4"], + ["yaw(回转)", "驱动器 3"], + ["pitch(俯仰)", "驱动器 2"], ], "setup_connect": "连接", "setup_disconnect": "断开连接", @@ -208,6 +204,11 @@ STRINGS = { "finger_preview_title": "三维预览", "finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"], "finger_play": "播放动作", + "move_execute": "执行动作", + "move_stop": "停止", + "move_return": "回初始位", + "move_confirm": "移动前确认", + "move_status": "1/4 yaw → +36.8160 °(回初始位:先退压轴 Z)", "finger_preview_notes": [ "颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。", "黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。", @@ -270,16 +271,13 @@ STRINGS = { "setup_open": "Device setup", "setup_title": "Device setup", "setup_hint": "Connecting and bringing the bus up are checked once per machine start-up, so they live here. The status strip keeps reporting the result.", - "setup_tabs": ["Connection", "Bus initialisation"], + "setup_tabs": ["Connection", "Bus initialisation", "Axis mapping"], "setup_rows": [ - ["Connection", "Ethernet ▾"], - ["IP", "192.168.0.11"], - ["State", "Connected"], - ["BAS file", "…\\bas\\ECAT.bas"], - ["Init state", "Initialised"], - ["Bus type", "EtherCAT"], - ["Nodes", "5"], - ["Axes", "5"], + ["X (table)", "Drive 0"], + ["Y (table)", "Drive 1"], + ["Z (press)", "Drive 4"], + ["yaw (rotation)", "Drive 3"], + ["pitch (tilt)", "Drive 2"], ], "setup_connect": "Connect", "setup_disconnect": "Disconnect", @@ -362,6 +360,11 @@ STRINGS = { "finger_preview_title": "3D preview", "finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"], "finger_play": "Play motion", + "move_execute": "Execute", + "move_stop": "Stop", + "move_return": "Return to zero", + "move_confirm": "Confirm before moving", + "move_status": "1/4 yaw → +36.8160° (return to zero retracts Z first)", "finger_preview_notes": [ "What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.", "Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).", @@ -629,7 +632,10 @@ def draw_left_rail(painter: QPainter, palette: Palette, s: dict, rect: QRect) -> y += 24 _button(painter, QRect(inner.x(), y + 4, inner.width(), 28), s["enable_all"], palette) _button(painter, QRect(inner.x(), y + 40, inner.width(), 28), s["stop_all"], palette, danger=True) - _button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["clear_alarms"], palette) + # Returning every drive to zero is a machine-level action: here, in the rail that + # is on screen in every tab, rather than repeated inside a tab. + _button(painter, QRect(inner.x(), y + 76, inner.width(), 28), s["move_return"], palette) + _button(painter, QRect(inner.x(), y + 112, inner.width(), 28), s["clear_alarms"], palette) group = QRect(rect.x(), rect.y() + 280, rect.width(), 32) _panel(painter, group.adjusted(0, 0, 0, -8), palette) @@ -655,10 +661,11 @@ def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect) ) tabs = QRect(inner.x(), inner.y() + 38, inner.width(), inner.height() - 74) - tab_w = 130 + tab_w = 120 for index, label in enumerate(s["setup_tabs"]): tab = QRect(tabs.x() + index * tab_w, tabs.y(), tab_w, 26) - active = index == 0 + # The axis mapping is the newest page, and the one worth reviewing. + active = label == s["setup_tabs"][-1] painter.fillRect(tab, palette.window if active else palette.panel) painter.setPen(QPen(palette.border, 1)) painter.drawRect(tab.adjusted(0, 0, -1, -1)) @@ -667,16 +674,14 @@ def draw_setup_dialog(painter: QPainter, palette: Palette, s: dict, rect: QRect) painter.drawLine(tab.bottomLeft(), tab.bottomRight()) _text(painter, tab, label, palette, size=10, bold=active, align=Qt.AlignmentFlag.AlignCenter) - # The rows are both tabs' contents stacked, in order: the connection fields and - # the bus readouts, which is what the two tabs hold between them. + # The mapping page: one row per platform direction, each showing the drive that + # moves it. The values are the bench's own wiring. body = _panel(painter, tabs.adjusted(0, 26, 0, 0), palette) y = body.y() + 6 for label, value in s["setup_rows"]: - _text(painter, QRect(body.x(), y, 96, 22), label, palette, size=10, muted=True) - _text(painter, QRect(body.x() + 104, y, body.width() - 104, 22), value, palette, size=10) - y += 24 - _button(painter, QRect(body.x(), y + 6, 110, 26), s["setup_connect"], palette, primary=True) - _button(painter, QRect(body.x() + 118, y + 6, 118, 26), s["setup_disconnect"], palette) + _text(painter, QRect(body.x(), y, 150, 22), label, palette, size=10, muted=True) + _text(painter, QRect(body.x() + 156, y, body.width() - 156, 22), value, palette, size=10) + y += 26 _button(painter, QRect(dialog.right() - 110, dialog.bottom() - 34, 96, 26), s["setup_close"], palette) @@ -1237,9 +1242,25 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) - painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5) _text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True) - play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4) + row_y = view.bottom() + checks_h + 2 + play = QRect(inner_preview.x(), row_y, 110, play_h - 4) _button(painter, play, s["finger_play"], palette, primary=True) - notes_y = play.bottom() + 4 + # The pose move: execute the selected point's solved pose, stop, and the policy + # that decides whether either asks first. + execute = QRect(play.right() + 8, row_y, 100, play_h - 4) + _button(painter, execute, s["move_execute"], palette) + stop = QRect(execute.right() + 8, row_y, 84, play_h - 4) + _button(painter, stop, s["move_stop"], palette, danger=True) + confirm = QRect(stop.right() + 10, row_y, inner_preview.width() - stop.right() - 10, play_h - 4) + _text(painter, confirm, "☑ " + s["move_confirm"], palette, size=9, muted=True) + _text( + painter, + QRect(inner_preview.x(), row_y + play_h, inner_preview.width(), 16), + s["move_status"], + palette, + size=9, + ) + notes_y = row_y + play_h + 18 for text in s["finger_preview_notes"]: _text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True) notes_y += 14 diff --git a/apps/autocal/tools/panel_check.py b/apps/autocal/tools/panel_check.py index 9360d95..9fb0164 100644 --- a/apps/autocal/tools/panel_check.py +++ b/apps/autocal/tools/panel_check.py @@ -76,16 +76,14 @@ def _visible_descendants(widget): def main() -> int: - from PySide6 import __version__ as pyside_version - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.theme import build_qss, load_colors - from orisys_autocal.motion import ConnectionSpec from orisys_autocal.ui.axis_panel import COLUMNS from orisys_autocal.ui.bus_panel import default_bas_dir from orisys_autocal.ui.window import AutoCalMainWindow + from PySide6 import __version__ as pyside_version + from PySide6.QtWidgets import QApplication checker = Checker() app = QApplication.instance() or QApplication([]) @@ -115,7 +113,14 @@ def main() -> int: set_locale("zh_CN") # ---- window ---------------------------------------------------------- - from orisys_autocal.motion import DemoTransport + import math + + from orisys_autocal.motion import ( + DemoTransport, + plan_pose_move, + plan_return_to_initial, + user_units_to_deg, + ) for theme in ("dark", "light"): window = AutoCalMainWindow(prefs={**prefs, "theme": theme}) @@ -267,6 +272,50 @@ def main() -> int: not window.device_setup.isVisible(), "the device setup is open before anyone asked for it", ) + # Where the three move controls live, and why. Execute is about the selected + # point, so it belongs to the finger screen; the stop is repeated next to it + # *and* stays in the strip, because an emergency stop has to be where the + # operator's eyes are; returning to zero is a machine action wanted after any + # tab's work, so it lives in the always-visible rail and is not duplicated. + checker.that( + window.finger_panel.execute_button.parent() is not None + and window.finger_panel.stop_button.parent() is not None, + "the finger screen is missing an execute or stop button", + ) + checker.that( + window.status_strip.stop_button.isEnabled() is not None, + "the strip has no stop button", + ) + checker.that( + window.axis_readout.return_button.text() == t("move.return_initial"), + f"the rail return button says " + f"{window.axis_readout.return_button.text()!r}", + ) + checker.that( + window.axis_readout.return_button.parent() is window.axis_readout, + "the return button is not in the always-visible rail", + ) + # The mapping page is the third tab of the device setup, and the service + # already holds the mapping it shows. + checker.that( + window.device_setup.tabs.count() == 3, + f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3", + ) + checker.that( + window.device_setup.tabs.tabText(2) == t("setup.tab_axes"), + f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}", + ) + # With no controller, a move must be refused with a reason and the execute + # button must say so rather than appearing to work. + disconnected = window.poller.service.check_axes_move({"x": 1.0}) + checker.that( + disconnected is not None, + "a move was allowed with no controller connected", + ) + checker.that( + not window.finger_panel.execute_button.isEnabled(), + "execute is enabled with no controller connected", + ) checker.that( window.device_setup.connection_panel is window.connection_panel and window.device_setup.bus_panel is window.bus_panel, @@ -401,14 +450,14 @@ def main() -> int: # A full simulated session: connect, initialise from the shipped BAS. window.connect_controller(ConnectionSpec(mode="demo"), label="connect") - _wait_for(app, lambda: window._last_snapshot.connected, 6.0) + _wait_for(app, lambda w=window: w._last_snapshot.connected, 6.0) checker.that( window._last_snapshot.connected, "the simulated controller did not connect" ) window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas")) window._on_download_bas() - ok = _wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0) + ok = _wait_for(app, lambda w=window: w._last_snapshot.bus.init_status == 1, 8.0) checker.that(ok, "the simulated bus did not report initialised") if ok: checker.that( @@ -453,12 +502,167 @@ def main() -> int: or "5" in strip.chip_axes.text(), f"strip axis chip says {strip.chip_axes.text()!r}", ) - checker.that( - window.axis_readout._rows[0]["position"].text().startswith("0.000"), - f"readout row 0 shows {window.axis_readout._rows[0]['position'].text()!r}", - ) window.workspace.set_current_key("axes") + # ---- the pose move ------------------------------------------------ + # The whole path end to end, on the simulated controller: solve a pose + # from a picked point, plan it, run it, and check the drives arrived. + # Nothing else exercises the order, the units and the guards together. + window.workspace.set_current_key("finger") + app.processEvents() + finger = window.finger_panel + checker.that( + finger.execute_button.isEnabled(), + "execute is disabled on a connected, initialised platform", + ) + checker.that( + finger.stop_button.isEnabled(), + "the local stop is disabled while the platform may move", + ) + + # A point to solve: pick the centre of the flat image, which is the + # operator's own gesture, so the pose the check plans is a real one. + if not finger.points(): + view = finger.view + window._on_pick_point(view.width() // 2, view.height() // 2) # noqa: SLF001 + app.processEvents() + checker.that( + bool(finger.points()), + "picking the middle of the patch produced no point", + ) + + solve = finger.solved_pose() + checker.that( + solve is not None, + "the finger screen solved no pose from its point list", + ) + if solve is not None: + targets, held = solve + checker.that( + "z" in held and "z" not in targets, + f"execute does not hold the press axis back: " + f"{sorted(targets)} held={held}", + ) + plan = plan_pose_move(window.poller.service, targets, hold=held) + checker.that( + [step.role for step in plan.steps] == ["yaw", "pitch", "x", "y"], + f"the move order is {[step.role for step in plan.steps]}", + ) + checker.that( + all(math.isfinite(step.units) for step in plan.steps), + "a planned step does not convert to user units", + ) + # Straight to the mover: the confirmation box is the operator's, and + # a modal dialog in a headless check is a hang (it has been one). + result = window.mover.start(plan) + checker.that(result.ok, f"the mover refused the plan: {result.message}") + moved = _wait_for(app, lambda w=window: not w.mover.running, 25.0) + checker.that(moved, "the pose move did not finish") + checker.that( + not window.mover.state.failure, + f"the pose move failed: {window.mover.state.failure}", + ) + for step in plan.steps: + state = window._last_snapshot.axis(step.axis) + if state is None: + continue + if step.unit == "mm": + actual = window.poller.service.to_mm(step.axis, state.dpos) + else: + actual = user_units_to_deg(step.axis, state.dpos) + checker.that( + abs(actual - step.target) <= 0.05, + f"{step.role} ended at {actual:.4f}{step.unit}, " + f"target {step.target:.4f}{step.unit}", + ) + print( + " move: " + + ", ".join(f"{s.role}={s.target:+.3f}{s.unit}" for s in plan.steps) + + " reached" + ) + + # Returning to the initial state, press axis first. + back = plan_return_to_initial(window.poller.service) + checker.that( + [step.role for step in back.steps] + == ["z", "pitch", "yaw", "x", "y"], + f"the return order is {[step.role for step in back.steps]}", + ) + result = window.mover.start(back) + checker.that(result.ok, f"the return was refused: {result.message}") + returned = _wait_for(app, lambda w=window: not w.mover.running, 30.0) + checker.that( + returned and not window.mover.state.failure, + f"the return did not finish: {window.mover.state.failure}", + ) + zeroed = [ + window._last_snapshot.axis(step.axis).dpos + for step in back.steps + if window._last_snapshot.axis(step.axis) is not None + ] + checker.that( + all(abs(value) <= 1.0 for value in zeroed), + f"the axes did not return to zero: {zeroed}", + ) + print(" move: every axis returned to zero") + + # Stop cancels the queue: no further axis may be commanded. + window.mover.start(plan) + window._on_stop_all() + app.processEvents() + checker.that(not window.mover.running, "stop left the move queued") + checker.that( + window.mover.state.cancelled, + "stop did not mark the move cancelled", + ) + print(" move: stop cancels the queue") + + # The guards, on the same connected session: a mapping that claims one + # drive twice must refuse motion rather than quietly pick a role. + service = window.poller.service + saved = service.axis_roles + service.set_axis_roles({"x": 0, "y": 0, "pitch": 2, "yaw": 3, "z": 4}) + refusal = service.check_axes_move({"x": 1.0, "y": 1.0}) + checker.that( + refusal is not None, + f"a duplicated drive was not refused: {refusal}", + ) + service.set_axis_roles(saved) + refusal = service.check_axes_move({"x": 500.0}) + checker.that( + refusal is not None and "500" in refusal.message, + f"a target past the travel limit was not refused: {refusal}", + ) + refusal = service.check_axes_move({"yaw": 400.0}) + checker.that( + refusal is not None, + f"a yaw past the stage envelope was not refused: {refusal}", + ) + # The mapping page must agree with the service it feeds. + page = window.device_setup.roles_page + checker.that( + page.roles() == saved, + f"the mapping page shows {page.roles()}, service holds {saved}", + ) + page.combos["y"].setCurrentIndex(page.combos["y"].findData(0)) + app.processEvents() + checker.that( + bool(page.problem()), "the mapping page accepted two roles on one drive" + ) + checker.that( + service.roles_problem() != "", + "the service did not pick up the duplicated mapping", + ) + page.combos["y"].setCurrentIndex(page.combos["y"].findData(1)) + app.processEvents() + checker.that( + page.problem() == "" and service.roles_problem() == "", + "correcting the mapping did not clear the refusal", + ) + print(" move: guards refuse a duplicate mapping and an out-of-range target") + window.workspace.set_current_key("axes") + app.processEvents() + try: window.poller.shutdown() except Exception: # noqa: BLE001 - a shutdown must not mask the result diff --git a/apps/autocal/tools/panel_snapshot.py b/apps/autocal/tools/panel_snapshot.py index 8441a4d..d0b49ff 100644 --- a/apps/autocal/tools/panel_snapshot.py +++ b/apps/autocal/tools/panel_snapshot.py @@ -53,21 +53,19 @@ def main() -> int: parser.add_argument( "--setup-dialog", default="", - choices=("", "connection", "bus"), + choices=("", "connection", "bus", "axes"), help="photograph the device-setup dialog on this tab instead of the window", ) args = parser.parse_args() width, height = (int(part) for part in args.size.lower().split("x")) - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import set_locale from orisys_app_shared.theme import build_qss - from orisys_autocal.motion import ConnectionSpec, DemoTransport from orisys_autocal.preferences import load_preferences from orisys_autocal.ui.bus_panel import default_bas_dir from orisys_autocal.ui.window import AutoCalMainWindow + from PySide6.QtWidgets import QApplication set_locale(args.language) app = QApplication.instance() or QApplication([]) diff --git a/apps/autocal/tools/preview_snapshot.py b/apps/autocal/tools/preview_snapshot.py index 9089d3f..ebac06a 100644 --- a/apps/autocal/tools/preview_snapshot.py +++ b/apps/autocal/tools/preview_snapshot.py @@ -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) diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 8a91e6a..f701223 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -9,21 +9,6 @@ from pathlib import Path from typing import Any import numpy as np -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage -from PySide6.QtWidgets import ( - QApplication, - QFrame, - QHBoxLayout, - QLabel, - QMainWindow, - QMessageBox, - QSplitter, - QStatusBar, - QVBoxLayout, - QWidget, -) - from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ from orisys_app_shared.acquisition.image_backend import ( DEPTH, @@ -39,24 +24,33 @@ from orisys_app_shared.force import select_display_forces from orisys_app_shared.force.calibration_recorder import CalibrationRecorder from orisys_app_shared.force.filters import FilterSettings from orisys_app_shared.force.session import SessionStore -from orisys_app_shared.paths import recordings_dir +from orisys_app_shared.i18n import set_locale, t +from orisys_app_shared.logging import report_error +from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path +from orisys_app_shared.prefs import save_preferences from orisys_app_shared.record import RecordConfig from orisys_app_shared.record.sample import to_record_sample +from orisys_app_shared.theme import build_qss, clear_theme_cache, color +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QAction, QActionGroup, QIcon +from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QMessageBox, + QSplitter, + QStatusBar, + QVBoxLayout, + QWidget, +) + +from orisys_studio import session_metadata +from orisys_studio.calibration_service import CalibrationService +from orisys_studio.display_prefs import DisplayPrefsController from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.replay import ReplayEngine from orisys_studio.replay_controller import ReplayController -from orisys_studio.calibration_service import CalibrationService -from orisys_studio.display_prefs import DisplayPrefsController -from orisys_studio.view_layout import ViewLayoutController -from orisys_studio import session_metadata -from orisys_studio.session_controller import SessionController from orisys_studio.scene3d.prefs import settings_to_prefs -from orisys_app_shared.i18n import set_locale, t -from orisys_app_shared.logging import report_error -from orisys_app_shared.paths import logo_path, sensor_json_path -from orisys_app_shared.prefs import save_preferences -from orisys_app_shared.theme import build_qss, clear_theme_cache, color - +from orisys_studio.session_controller import SessionController from orisys_studio.ui import record_status as record_states from orisys_studio.ui.about_dialog import AboutDialog from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage @@ -74,6 +68,7 @@ from orisys_studio.ui.plot_settings_dialog import ( ) from orisys_studio.ui.sensor_picker import SensorPickerWidget from orisys_studio.ui.toolbar import ToolbarWidget +from orisys_studio.view_layout import ViewLayoutController logger = logging.getLogger(__name__) @@ -2476,7 +2471,7 @@ class StudioMainWindow(QMainWindow): return "" return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}") - def _latched_domain_mask(self, frame: "ImageFrame"): + def _latched_domain_mask(self, frame: ImageFrame): """The 2D domain boundary to draw, latched for the session. Exactly the treatment ``_depth_range`` gives the colour scale, and for the same diff --git a/apps/studio/src/orisys_studio/calibration_service.py b/apps/studio/src/orisys_studio/calibration_service.py index 4d9fac7..babd5c4 100644 --- a/apps/studio/src/orisys_studio/calibration_service.py +++ b/apps/studio/src/orisys_studio/calibration_service.py @@ -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( diff --git a/apps/studio/src/orisys_studio/display_prefs.py b/apps/studio/src/orisys_studio/display_prefs.py index 5b52b73..6941e23 100644 --- a/apps/studio/src/orisys_studio/display_prefs.py +++ b/apps/studio/src/orisys_studio/display_prefs.py @@ -19,17 +19,17 @@ repaint when the 2D view mode changes. from __future__ import annotations import logging -from typing import Any, Optional, Protocol +from typing import Any, Protocol from PySide6.QtCore import QObject from orisys_studio.ui.plot_settings_dialog import ( DEFAULT_PLOT_DEADBAND_N, DEFAULT_PLOT_DEADBAND_RAW, - DEFAULT_PLOT_FN_MIN, DEFAULT_PLOT_FN_MAX, - DEFAULT_PLOT_FN_RAW_MIN, + DEFAULT_PLOT_FN_MIN, DEFAULT_PLOT_FN_RAW_MAX, + DEFAULT_PLOT_FN_RAW_MIN, DEFAULT_PLOT_SHEAR_MAX, DEFAULT_PLOT_SHEAR_RAW_MAX, DEFAULT_PLOT_WINDOW_SEC, @@ -66,7 +66,7 @@ class DisplayHost(Protocol): prefs: dict @property - def _latest_fps(self) -> Optional[float]: ... + def _latest_fps(self) -> float | None: ... def _persist_prefs(self) -> None: ... @@ -94,7 +94,7 @@ class DisplayPrefsController(QObject): arrow_rotate_types: frozenset, mesh_panel: Any = None, rotate_arrows_action: Any = None, - parent: Optional[QObject] = None, + parent: QObject | None = None, ) -> None: super().__init__(parent) self._host = host diff --git a/apps/studio/src/orisys_studio/main.py b/apps/studio/src/orisys_studio/main.py index ea80352..2b4d7ca 100644 --- a/apps/studio/src/orisys_studio/main.py +++ b/apps/studio/src/orisys_studio/main.py @@ -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__) diff --git a/apps/studio/src/orisys_studio/recording.py b/apps/studio/src/orisys_studio/recording.py index 5f0ef2a..3de3c85 100644 --- a/apps/studio/src/orisys_studio/recording.py +++ b/apps/studio/src/orisys_studio/recording.py @@ -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( diff --git a/apps/studio/src/orisys_studio/replay.py b/apps/studio/src/orisys_studio/replay.py index d88e15b..ce91f6f 100644 --- a/apps/studio/src/orisys_studio/replay.py +++ b/apps/studio/src/orisys_studio/replay.py @@ -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 diff --git a/apps/studio/src/orisys_studio/replay_controller.py b/apps/studio/src/orisys_studio/replay_controller.py index 20533aa..af7aa91 100644 --- a/apps/studio/src/orisys_studio/replay_controller.py +++ b/apps/studio/src/orisys_studio/replay_controller.py @@ -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 [] diff --git a/apps/studio/src/orisys_studio/scene3d/adapters.py b/apps/studio/src/orisys_studio/scene3d/adapters.py index aaf740b..789ea5c 100644 --- a/apps/studio/src/orisys_studio/scene3d/adapters.py +++ b/apps/studio/src/orisys_studio/scene3d/adapters.py @@ -33,10 +33,9 @@ Two things the v1 adapters deliberately do **not** do: from __future__ import annotations -from typing import Any, Optional +from typing import Any import numpy as np - from orisys_app_shared.acquisition.models import SdkPacket from orisys_app_shared.scene3d.contracts import ( Diagnostic, @@ -45,7 +44,6 @@ from orisys_app_shared.scene3d.contracts import ( FrameIdentity, SurfaceFieldFrame, diagnostic, - error, freeze, ) from orisys_app_shared.scene3d.sampling import remap_to_domain @@ -84,7 +82,7 @@ def _refusal_rank(item: Diagnostic) -> int: return _REFUSAL_RANK.get(str(item.code), 0) -def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]: +def flow_magnitude(packet: SdkPacket) -> np.ndarray | None: """``|flow|`` as float32, or ``None`` when the packet carries no flow.""" flow = getattr(packet, "flow", None) if flow is None: @@ -97,11 +95,11 @@ def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]: return None -def _shape_of(array: Optional[np.ndarray]) -> str: +def _shape_of(array: np.ndarray | None) -> str: return "none" if array is None else "x".join(str(int(v)) for v in array.shape[:2]) -def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Optional[Diagnostic]: +def _field_validity(packet: SdkPacket, field: str, frame_id: int) -> Diagnostic | None: """Whether the packet may claim ``field`` for *this* frame. Three answers, and the middle one is the point: ``None`` on the packet means the @@ -145,7 +143,7 @@ def _device_mask( packet: SdkPacket, shape_hw: tuple[int, int], latch: Any = None, -) -> tuple[Optional[np.ndarray], Optional[Diagnostic]]: +) -> tuple[np.ndarray | None, Diagnostic | None]: """The device's own validity, or a diagnostic when it describes another grid. Both silent choices are wrong: stretching a mask from another grid marks samples @@ -177,7 +175,7 @@ def _device_mask( return array, None -def _intersect(mask: np.ndarray, device: Optional[np.ndarray]) -> np.ndarray: +def _intersect(mask: np.ndarray, device: np.ndarray | None) -> np.ndarray: """Inside the UV island AND the device's own valid area, when it stated one.""" if device is None: return freeze(mask, dtype=np.bool_) @@ -218,8 +216,8 @@ class FingerFrameAdapter: geometry_id: str, field_domain_id: str, shape_hw: tuple[int, int], - map_x: Optional[np.ndarray] = None, - map_y: Optional[np.ndarray] = None, + map_x: np.ndarray | None = None, + map_y: np.ndarray | None = None, preferred_field: str = "depth", ) -> None: self.geometry_id = str(geometry_id) @@ -227,7 +225,7 @@ class FingerFrameAdapter: self.shape_hw = (int(shape_hw[0]), int(shape_hw[1])) self.map_x = map_x self.map_y = map_y - self.last_diagnostic: Optional[Diagnostic] = None + self.last_diagnostic: Diagnostic | None = None #: Which quantity the operator asked for (``"depth"`` or ``"flow"``). The other #: one is tried when this one cannot be coloured, and the substitution is stated #: rather than silent — a frame that shows another quantity than the setting says @@ -236,7 +234,7 @@ class FingerFrameAdapter: self.preferred_field = _field_name(preferred_field) #: Set (and cleared) by every snapshot: the reason the picture is not the field #: the operator chose. ``None`` when the preferred field was the one drawn. - self.last_substitution: Optional[Diagnostic] = None + self.last_substitution: Diagnostic | None = None #: The session's island latch (``image_backend.DomainMaskLatch``), when the #: window has one. See ``_device_mask``: the surface's validity is the *latched* #: island — the same mask the 2D canvas paints its island and its "no depth" @@ -247,7 +245,7 @@ class FingerFrameAdapter: self.preferred_field = _field_name(name) @classmethod - def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter": + def from_domain(cls, domain: Any, *, geometry_id: str) -> FingerFrameAdapter: """Build from a ``LegacyFieldDomain`` (which carries the map, when loaded).""" return cls( geometry_id=geometry_id, @@ -259,7 +257,7 @@ class FingerFrameAdapter: def snapshot( self, packet: SdkPacket, runtime: Any, identity: FrameIdentity - ) -> Optional[SurfaceFieldFrame]: + ) -> SurfaceFieldFrame | None: """Colour the surface with the field the operator chose, or say why not. Two quantities can be drawn and the operator picks one (``DisplaySettings.field``); @@ -306,7 +304,7 @@ class FingerFrameAdapter: identity: FrameIdentity, name: str, refused: list[Diagnostic], - ) -> Optional[SurfaceFieldFrame]: + ) -> SurfaceFieldFrame | None: """One quantity, or ``None`` with the reason appended to ``refused``.""" if name == "depth": depth = getattr(packet, "depth", None) @@ -401,7 +399,7 @@ class FingerFrameAdapter: mask: np.ndarray, descriptor: FieldDescriptor, refused: list[Diagnostic], - ) -> Optional[SurfaceFieldFrame]: + ) -> SurfaceFieldFrame | None: """Intersect with the device's own mask and build the frame.""" # Inside the UV island is the only place a value means anything: the tracker is # looking at masked black outside it. @@ -469,13 +467,13 @@ class BoxFrameAdapter: self.geometry_id = str(geometry_id) self.field_domain_id = str(field_domain_id) self.shape_hw = (int(shape_hw[0]), int(shape_hw[1])) - self.last_diagnostic: Optional[Diagnostic] = None + self.last_diagnostic: Diagnostic | None = None #: See ``FingerFrameAdapter.domain_latch``: one island, both views. self.domain_latch: Any = None def snapshot( self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry - ) -> Optional[SurfaceFieldFrame]: + ) -> SurfaceFieldFrame | None: if tuple(domain.shape_hw) != self.shape_hw: self.last_diagnostic = diagnostic( "field_domain_mismatch", @@ -492,7 +490,7 @@ class BoxFrameAdapter: if mask_problem is not None: self.last_diagnostic = mask_problem return None - refused: Optional[Diagnostic] = None + refused: Diagnostic | None = None for name, field in candidates: if field is None: continue diff --git a/apps/studio/src/orisys_studio/scene3d/asset_source.py b/apps/studio/src/orisys_studio/scene3d/asset_source.py index f576362..58a85bb 100644 --- a/apps/studio/src/orisys_studio/scene3d/asset_source.py +++ b/apps/studio/src/orisys_studio/scene3d/asset_source.py @@ -28,12 +28,11 @@ exactly the kind of thing this feature is not allowed to cause. from __future__ import annotations import logging +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Optional - -from PySide6.QtCore import QObject, Signal +from typing import TYPE_CHECKING, Any from orisys_app_shared.paths import config_dir from orisys_app_shared.scene3d.contracts import ( @@ -43,6 +42,8 @@ from orisys_app_shared.scene3d.contracts import ( diagnostic, error, ) +from PySide6.QtCore import QObject, Signal + from orisys_studio.scene3d.keys import SceneKeys, check_release_requirement if TYPE_CHECKING: # the description is a value object; the callable returns one @@ -76,7 +77,7 @@ class SceneSource: return f"{self.kind}: {self.path}" -def scene_dir(sensor_type: str, *, root: Optional[Path] = None) -> Path: +def scene_dir(sensor_type: str, *, root: Path | None = None) -> Path: """Where a compiled package for this sensor belongs.""" base = Path(root) if root is not None else config_dir() return base / "sensors" / str(sensor_type) / "scene" @@ -86,8 +87,8 @@ def find_package( sensor_type: str, *, override: str | Path | None = None, - root: Optional[Path] = None, -) -> Optional[SceneSource]: + root: Path | None = None, +) -> SceneSource | None: """The package to load, or ``None``. Never raises: absence is a state.""" if override: path = Path(str(override)).expanduser() @@ -126,8 +127,8 @@ def no_asset_diagnostic(sensor_type: str, *, override: str | Path | None = None) def load_package( source: SceneSource, - expected: Optional[ExpectedGeometry] = None, - keys: "Optional[SceneKeys]" = None, + expected: ExpectedGeometry | None = None, + keys: SceneKeys | None = None, ) -> SceneAssets: """Read and validate a package. Runs on a worker thread; raises ``ThreeDError``. @@ -153,10 +154,10 @@ def load_package( def _resolve_and_load( source: SceneSource, - expected: Optional[ExpectedGeometry], - provider: "Optional[Callable[[], DeviceGeometry]]", - keys: "Optional[SceneKeys]" = None, -) -> tuple[SceneAssets, "Optional[DeviceGeometry]"]: + expected: ExpectedGeometry | None, + provider: Callable[[], DeviceGeometry] | None, + keys: SceneKeys | None = None, +) -> tuple[SceneAssets, DeviceGeometry | None]: """Worker-thread half: build the *device's* description, then read the package. Deriving the description digests the unroll map the runtime loads, so it belongs on @@ -183,21 +184,21 @@ class SceneAssetLoader(QObject): loaded = Signal(object, str, int, object) # SceneAssets, description, id, device failed = Signal(object, int) # Diagnostic, request id - def __init__(self, parent: Optional[QObject] = None) -> None: + def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) - self._executor: Optional[ThreadPoolExecutor] = None + self._executor: ThreadPoolExecutor | None = None self._inflight = False self._request_id = 0 #: The newest request that could not start yet (source, expected, provider, id). - self._pending: Optional[tuple[SceneSource, Optional[ExpectedGeometry], Any, int]] = None + self._pending: tuple[SceneSource, ExpectedGeometry | None, Any, int] | None = None def load( self, source: SceneSource, - expected: Optional[ExpectedGeometry] = None, + expected: ExpectedGeometry | None = None, *, - expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None, - keys: "Optional[SceneKeys]" = None, + expected_provider: Callable[[], DeviceGeometry] | None = None, + keys: SceneKeys | None = None, ) -> int: """Ask for a scene; returns the request id this call was given.""" self._request_id += 1 diff --git a/apps/studio/src/orisys_studio/scene3d/camera.py b/apps/studio/src/orisys_studio/scene3d/camera.py index 88c93ae..babc5bc 100644 --- a/apps/studio/src/orisys_studio/scene3d/camera.py +++ b/apps/studio/src/orisys_studio/scene3d/camera.py @@ -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: diff --git a/apps/studio/src/orisys_studio/scene3d/coordinator.py b/apps/studio/src/orisys_studio/scene3d/coordinator.py index bff30b3..111e369 100644 --- a/apps/studio/src/orisys_studio/scene3d/coordinator.py +++ b/apps/studio/src/orisys_studio/scene3d/coordinator.py @@ -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( diff --git a/apps/studio/src/orisys_studio/scene3d/gl_backend.py b/apps/studio/src/orisys_studio/scene3d/gl_backend.py index 5a51493..d367142 100644 --- a/apps/studio/src/orisys_studio/scene3d/gl_backend.py +++ b/apps/studio/src/orisys_studio/scene3d/gl_backend.py @@ -24,14 +24,15 @@ zero: the reference model is not a measurement and must not look like one. from __future__ import annotations +import logging import math import time -from typing import Any, Optional +from typing import Any import numpy as np - from orisys_app_shared.scene3d.contracts import ( CameraMatrices, + Diagnostic, DisplaySettings, GLCapabilities, MeshPart, @@ -43,7 +44,8 @@ from orisys_app_shared.scene3d.contracts import ( error, freeze, ) -from orisys_app_shared.scene3d.contracts import Diagnostic + +logger = logging.getLogger(__name__) #: Renderer names that are not a GPU. A board must not be signed off on one of #: these, and neither must a laptop with a broken driver. @@ -261,7 +263,7 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]: return header + vertex, header + body -def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]: +def _valid_scalar_range(scalar, valid) -> tuple[float, float] | None: """Robust P5/P95 range of *valid* samples, or ``None`` when there are none. Using min/max made empty-field noise paint the whole LUT. A percentile span @@ -294,11 +296,11 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]: def _blend_scale_range( - previous: Optional[tuple[float, float]], - current: Optional[tuple[float, float]], + previous: tuple[float, float] | None, + current: tuple[float, float] | None, *, alpha: float = 0.15, -) -> Optional[tuple[float, float]]: +) -> tuple[float, float] | None: """Slowly update an automatic colour range to avoid frame-to-frame flicker.""" if current is None: return previous @@ -310,7 +312,7 @@ def _blend_scale_range( (1.0 - a) * float(previous[1]) + a * float(current[1]), ) -def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]: +def _scale_bounds(scale, automatic: tuple[float, float] | None) -> tuple[float, float]: """The colour range to draw with: the operator's, or the data's own. ``automatic`` mode exists because the quantities differ in magnitude by orders @@ -327,7 +329,7 @@ def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[floa else: fallback = (float(scale.lower), float(scale.upper)) lower, upper = float(fallback[0]), float(fallback[1]) - if not (lower == lower) or not (upper == upper): # NaN + if math.isnan(lower) or math.isnan(upper): return 0.0, 1.0 if upper <= lower: # A constant field still has to be visible rather than all one clipped colour. @@ -404,7 +406,7 @@ def _extend_edges(values: np.ndarray, usable: np.ndarray) -> np.ndarray: return np.where(usable, values, filled) -def pack_part(part: MeshPart, *, uvs: Optional[np.ndarray] = None) -> np.ndarray: +def pack_part(part: MeshPart, *, uvs: np.ndarray | None = None) -> np.ndarray: """Interleave one part into the layout the shaders read. ``uvs`` are the render-vertex UVs on the domain's pixel grid @@ -431,7 +433,7 @@ def pack_part(part: MeshPart, *, uvs: Optional[np.ndarray] = None) -> np.ndarray def pack_scene_part( - part: MeshPart, *, uvs: Optional[np.ndarray] = None + part: MeshPart, *, uvs: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray]: """``(vertices, indices)`` as the GPU wants them, both read-only.""" indices = np.asarray(part.triangles, dtype=np.uint32).reshape(-1) @@ -598,7 +600,14 @@ class _PartBuffers: """One part's GPU state. Kept dumb on purpose: no Qt, no ownership games.""" __slots__ = ( - "part_id", "role", "vao", "vbo", "ebo", "triangles", "vertices", "rows" + "ebo", + "part_id", + "role", + "rows", + "triangles", + "vao", + "vbo", + "vertices" ) def __init__( @@ -620,7 +629,7 @@ class GLRenderer: """Shaders, buffers and draw calls for one scene.""" def __init__(self) -> None: - self._capabilities: Optional[GLCapabilities] = None + self._capabilities: GLCapabilities | None = None self._programs: dict[str, int] = {} self._uniforms: dict[str, dict[str, int]] = {} self._parts: dict[str, _PartBuffers] = {} @@ -632,13 +641,13 @@ class GLRenderer: #: allocation and no per-frame vertex traffic. self._field_texture: int = 0 self._valid_texture: int = 0 - self._field_shape: Optional[tuple[int, int]] = None + self._field_shape: tuple[int, int] | None = None self._field_internal_format: int = 0 #: Range of the valid samples of the field currently uploaded, for the #: automatic colour scale. ``None`` until a frame carries valid data. - self._scalar_range: Optional[tuple[float, float]] = None + self._scalar_range: tuple[float, float] | None = None #: The colour bounds of the last draw and how they were chosen. - self._last_scale: Optional[tuple[float, float]] = None + self._last_scale: tuple[float, float] | None = None self._last_scale_mode = "" self._sensing_part_id: str = "" self._estimated_gpu_bytes: int = 0 @@ -926,8 +935,12 @@ class GLRenderer: GL.glDeleteVertexArrays(1, [int(vao)]) GL.glDeleteBuffers(1, [int(vbo)]) GL.glDeleteBuffers(1, [int(ebo)]) - except Exception: # noqa: BLE001 - pass + except Exception as exc: # noqa: BLE001 - best-effort delete, reported below + # A context that has already been lost refuses the delete, and this also + # runs on the live overlay-replacement path: raising here would replace a + # frame with a crash. Reported rather than swallowed, because a delete + # that keeps failing leaks GPU memory for the rest of the session. + logger.warning("could not free the %s overlay's GL objects: %s", kind, exc) def draw( self, @@ -1021,7 +1034,7 @@ class GLRenderer: scale_mode=self._last_scale_mode, ) - def effective_scale(self) -> Optional[tuple[float, float]]: + def effective_scale(self) -> tuple[float, float] | None: """The colour bounds the last draw actually used (``None`` before any draw).""" return self._last_scale @@ -1059,7 +1072,7 @@ class GLRenderer: GL.glActiveTexture(GL.GL_TEXTURE0) @property - def field_shape(self) -> Optional[tuple[int, int]]: + def field_shape(self) -> tuple[int, int] | None: """The allocated field texture's ``(H, W)``, or ``None`` before the first frame.""" return self._field_shape @@ -1150,7 +1163,7 @@ class GLRenderer: GL.glBindTexture(GL.GL_TEXTURE_2D, 0) self._lut_id = lut_id - def capabilities(self) -> Optional[GLCapabilities]: + def capabilities(self) -> GLCapabilities | None: return self._capabilities @property @@ -1246,7 +1259,7 @@ def _decode(value: Any) -> str: return str(value).strip() -def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None: +def _set_mat4(GL: Any, location: int | None, values: np.ndarray) -> None: if location is None: return # Column-major upload, explicitly: the layout of the NumPy array must not @@ -1254,17 +1267,17 @@ def _set_mat4(GL: Any, location: Optional[int], values: np.ndarray) -> None: GL.glUniformMatrix4fv(location, 1, GL.GL_FALSE, np.ascontiguousarray(values.T)) -def _set_float(GL: Any, location: Optional[int], value: float) -> None: +def _set_float(GL: Any, location: int | None, value: float) -> None: if location is not None: GL.glUniform1f(location, float(value)) -def _set_vec3(GL: Any, location: Optional[int], values: tuple[float, float, float]) -> None: +def _set_vec3(GL: Any, location: int | None, values: tuple[float, float, float]) -> None: if location is not None: GL.glUniform3f(location, float(values[0]), float(values[1]), float(values[2])) -def _set_vec4(GL: Any, location: Optional[int], values: tuple[float, float, float, float]) -> None: +def _set_vec4(GL: Any, location: int | None, values: tuple[float, float, float, float]) -> None: if location is not None: GL.glUniform4f( location, float(values[0]), float(values[1]), float(values[2]), float(values[3]) diff --git a/apps/studio/src/orisys_studio/scene3d/keys.py b/apps/studio/src/orisys_studio/scene3d/keys.py index 1268344..ca3c827 100644 --- a/apps/studio/src/orisys_studio/scene3d/keys.py +++ b/apps/studio/src/orisys_studio/scene3d/keys.py @@ -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 diff --git a/apps/studio/src/orisys_studio/scene3d/panel.py b/apps/studio/src/orisys_studio/scene3d/panel.py index 5391757..35692ab 100644 --- a/apps/studio/src/orisys_studio/scene3d/panel.py +++ b/apps/studio/src/orisys_studio/scene3d/panel.py @@ -18,10 +18,20 @@ Two rules it exists to enforce in the interface: from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import numpy as np - +from orisys_app_shared.i18n import t +from orisys_app_shared.scene3d.contracts import ( + ColorScale, + Diagnostic, + DisplaySettings, + FieldDescriptor, + RenderStats, + SceneAssets, + SceneCapabilities, + SceneState, +) from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QImage from PySide6.QtWidgets import ( @@ -38,18 +48,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from orisys_app_shared.i18n import t -from orisys_app_shared.scene3d.contracts import ( - ColorScale, - Diagnostic, - DisplaySettings, - FieldDescriptor, - RenderStats, - SceneAssets, - SceneCapabilities, - SceneState, -) -from orisys_studio.scene3d.prefs import FPS_CHOICES, DEFAULT_FIELD +from orisys_studio.scene3d.prefs import DEFAULT_FIELD, FPS_CHOICES from orisys_studio.ui.fit_text import ShrinkableLabel if TYPE_CHECKING: # the panel talks to these two by interface, not by import @@ -82,7 +81,7 @@ class ColorScaleBar(QLabel): because in automatic mode the spin boxes beside it held values nobody was using. """ - def __init__(self, parent: Optional[QWidget] = None) -> None: + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("colorScaleBar") self.setFixedHeight(12) @@ -136,23 +135,23 @@ class ThreeDPanel(QFrame): self, coordinator: ThreeDCoordinator, widget: Sensor3DWidget, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, ) -> None: super().__init__(parent) self.setObjectName("contentPanel") self._coordinator = coordinator self._widget = widget - self._assets: Optional[SceneAssets] = None - self._descriptor: Optional[FieldDescriptor] = None + self._assets: SceneAssets | None = None + self._descriptor: FieldDescriptor | None = None self._state: SceneState = "disabled" self._source_kind = "live" self._syncing = False #: What the data source can provide (see ``contracts.SceneCapabilities``): an #: unavailable capability disables its control and says why, rather than #: letting the operator operate an input that does not exist. - self._capabilities: Optional[SceneCapabilities] = None + self._capabilities: SceneCapabilities | None = None #: The bounds the last draw actually used (the widget's RenderStats). - self._effective_scale: Optional[RenderStats] = None + self._effective_scale: RenderStats | None = None #: Whether the pane explains the pipeline around the picture (the window's #: 「诊断信息」 switch). Off by default: the picture and what it *is* stay. self._show_diagnostics = False @@ -355,7 +354,7 @@ class ThreeDPanel(QFrame): self.set_state("disabled") # ------------------------------------------------------------------ inputs - def set_scene(self, assets: Optional[SceneAssets]) -> None: + def set_scene(self, assets: SceneAssets | None) -> None: """Fill the preset list from the asset: its default view is the asset's.""" self._assets = assets self._syncing = True @@ -377,7 +376,7 @@ class ThreeDPanel(QFrame): self.preset_combo.setEnabled(bool(assets is not None and self.preset_combo.count())) self.reset_button.setEnabled(assets is not None) - def set_descriptor(self, descriptor: Optional[FieldDescriptor]) -> None: + def set_descriptor(self, descriptor: FieldDescriptor | None) -> None: """The measurement currently on screen — or none, which hides the bar.""" if descriptor != self._descriptor: self._effective_scale = None @@ -417,7 +416,7 @@ class ThreeDPanel(QFrame): """ self.scale_bar.set_ramp(ramp) - def set_capabilities(self, capabilities: Optional[SceneCapabilities]) -> None: + def set_capabilities(self, capabilities: SceneCapabilities | None) -> None: """Offer only the controls this data source can actually feed. Today's adapters colour a scalar in place and publish no displacement at all, so @@ -451,7 +450,7 @@ class ThreeDPanel(QFrame): else t("scene3d.contact_arrow_tip") ) - def capabilities(self) -> Optional[SceneCapabilities]: + def capabilities(self) -> SceneCapabilities | None: return self._capabilities def _render_legend(self) -> None: @@ -551,7 +550,7 @@ class ThreeDPanel(QFrame): self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip")) self.source_label.setVisible(self._show_diagnostics) - def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None: + def set_diagnostic(self, diagnostic: Diagnostic | None) -> None: """One diagnostic line. The 2D views stay usable behind it. Warnings and errors are always stated: they are claims about the *measurement* diff --git a/apps/studio/src/orisys_studio/scene3d/prefs.py b/apps/studio/src/orisys_studio/scene3d/prefs.py index 0d17b62..55be27c 100644 --- a/apps/studio/src/orisys_studio/scene3d/prefs.py +++ b/apps/studio/src/orisys_studio/scene3d/prefs.py @@ -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") diff --git a/apps/studio/src/orisys_studio/scene3d/settings_dialog.py b/apps/studio/src/orisys_studio/scene3d/settings_dialog.py index a4302c9..c0e4c33 100644 --- a/apps/studio/src/orisys_studio/scene3d/settings_dialog.py +++ b/apps/studio/src/orisys_studio/scene3d/settings_dialog.py @@ -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") diff --git a/apps/studio/src/orisys_studio/scene3d/view.py b/apps/studio/src/orisys_studio/scene3d/view.py index 5897d5a..4971915 100644 --- a/apps/studio/src/orisys_studio/scene3d/view.py +++ b/apps/studio/src/orisys_studio/scene3d/view.py @@ -25,11 +25,9 @@ description disagrees with the asset is refused with both shapes named. from __future__ import annotations import logging +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor -from typing import Any, Callable, Optional - -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QVBoxLayout, QWidget +from typing import Any from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.contracts import ( @@ -47,6 +45,9 @@ from orisys_app_shared.scene3d.contracts import ( diagnostic, validate_assets, ) +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QVBoxLayout, QWidget + from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.coordinator import ThreeDCoordinator @@ -90,24 +91,24 @@ class Scene3DView(QWidget): descriptorChanged = Signal(object) # Optional[FieldDescriptor] enabledChanged = Signal(bool) - def __init__(self, parent: Optional[QWidget] = None) -> None: + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._available, self._reason = gl_available() - self._widget: Optional[Sensor3DWidget] = None - self._panel: Optional[ThreeDPanel] = None - self._coordinator: Optional[ThreeDCoordinator] = None + self._widget: Sensor3DWidget | None = None + self._panel: ThreeDPanel | None = None + self._coordinator: ThreeDCoordinator | None = None self._camera = OrbitCamera() - self._executor: Optional[ThreadPoolExecutor] = None - self._assets: Optional[SceneAssets] = None - self._composer: Optional[SceneComposer] = None + self._executor: ThreadPoolExecutor | None = None + self._assets: SceneAssets | None = None + self._composer: SceneComposer | None = None #: The session's island latch, shared with the 2D canvas when the window hands #: one over (``image_backend.DomainMaskLatch``). One latch, two views: the #: surface's validity texture and the canvas's island are the same mask, so #: they cannot drift apart. ``None`` means "no session": the adapter then uses #: each frame's own mask, which is what the tests and the PoC want. self._domain_latch: Any = None - self._expected: Optional[ExpectedGeometry] = None - self._snapshot: Optional[Callable[[Any, FrameIdentity], Any]] = None + self._expected: ExpectedGeometry | None = None + self._snapshot: Callable[[Any, FrameIdentity], Any] | None = None #: The adapter behind ``_snapshot``: it owns the reason a packet was refused, #: and (for a source that cannot provide a displacement) what the pane may #: offer. Kept rather than closed over so those answers can be asked. @@ -115,11 +116,11 @@ class Scene3DView(QWidget): self._source_id = "" self._enabled = False self._source_kind = "live" - self._last_diagnostic: Optional[Diagnostic] = None - self._last_descriptor: Optional[FieldDescriptor] = None - self._last_stats: Optional[RenderStats] = None + self._last_diagnostic: Diagnostic | None = None + self._last_descriptor: FieldDescriptor | None = None + self._last_stats: RenderStats | None = None #: The coordinator's last diagnostic as *this* view has already reported it. - self._reported_diagnostic: Optional[Diagnostic] = None + self._reported_diagnostic: Diagnostic | None = None #: Whether the loaded asset was compared against the device's own description. self._device_verified = False #: Frames the producer did not even build (a frame was already waiting). @@ -173,7 +174,7 @@ class Scene3DView(QWidget): self, assets: SceneAssets, source_id: str, - expected: Optional[ExpectedGeometry] = None, + expected: ExpectedGeometry | None = None, ) -> bool: """Adopt a validated scene. Returns whether the view accepted it. @@ -242,8 +243,8 @@ class Scene3DView(QWidget): return not violations def _make_snapshot( - self, assets: SceneAssets, expected: Optional[ExpectedGeometry] - ) -> Optional[Callable[[Any, FrameIdentity], Any]]: + self, assets: SceneAssets, expected: ExpectedGeometry | None + ) -> Callable[[Any, FrameIdentity], Any] | None: """Bind the adapter that fits this scene, or refuse with a diagnostic. The two adapters have different call signatures because they need different @@ -358,12 +359,12 @@ class Scene3DView(QWidget): except Exception: # noqa: BLE001 - a display sink must never break capture logger.exception("the 3D sink failed for one packet") - def _snapshot_diagnostic(self) -> Optional[Diagnostic]: + def _snapshot_diagnostic(self) -> Diagnostic | None: """Why the adapter refused the last packet (the adapter owns the answer).""" item = getattr(self._adapter, "last_diagnostic", None) return item if isinstance(item, Diagnostic) else None - def identity_for(self, packet: Any) -> Optional[FrameIdentity]: + def identity_for(self, packet: Any) -> FrameIdentity | None: """The identity a field frame built from this packet would carry.""" if self._coordinator is None or self._assets is None: return None @@ -448,7 +449,7 @@ class Scene3DView(QWidget): self._panel.set_source_kind(kind) self._publish_capabilities() - def capabilities(self) -> Optional[SceneCapabilities]: + def capabilities(self) -> SceneCapabilities | None: """What this data source can provide, for the panel's controls. Read off the adapter that is actually bound: an adapter that colours a scalar in @@ -538,27 +539,27 @@ class Scene3DView(QWidget): return self._enabled @property - def assets(self) -> Optional[SceneAssets]: + def assets(self) -> SceneAssets | None: return self._assets @property - def coordinator(self) -> Optional[ThreeDCoordinator]: + def coordinator(self) -> ThreeDCoordinator | None: return self._coordinator @property - def gl_widget(self) -> Optional[Sensor3DWidget]: + def gl_widget(self) -> Sensor3DWidget | None: return self._widget @property - def panel(self) -> Optional[ThreeDPanel]: + def panel(self) -> ThreeDPanel | None: return self._panel @property - def last_diagnostic(self) -> Optional[Diagnostic]: + def last_diagnostic(self) -> Diagnostic | None: return self._last_diagnostic @property - def last_descriptor(self) -> Optional[FieldDescriptor]: + def last_descriptor(self) -> FieldDescriptor | None: return self._last_descriptor def state(self) -> SceneState: @@ -568,7 +569,7 @@ class Scene3DView(QWidget): return "disabled" return self._coordinator.state - def render_stats(self) -> Optional[RenderStats]: + def render_stats(self) -> RenderStats | None: return self._last_stats def describe(self) -> dict: @@ -751,7 +752,7 @@ class Scene3DView(QWidget): self._panel.set_state(state) self.stateChanged.emit(state) - def _publish_coordinator_diagnostic(self, item: Optional[Diagnostic]) -> None: + def _publish_coordinator_diagnostic(self, item: Diagnostic | None) -> None: """Raise a coordinator diagnostic once, when it changes.""" if item is None or item == self._reported_diagnostic: return diff --git a/apps/studio/src/orisys_studio/scene3d/widget.py b/apps/studio/src/orisys_studio/scene3d/widget.py index 5cc70c8..258e8b2 100644 --- a/apps/studio/src/orisys_studio/scene3d/widget.py +++ b/apps/studio/src/orisys_studio/scene3d/widget.py @@ -17,23 +17,24 @@ two-dimensional application carries on. from __future__ import annotations import time -from typing import Optional - -from PySide6.QtCore import QEvent, Qt, Signal -from PySide6.QtGui import QMouseEvent, QWheelEvent -# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows. -from PySide6.QtOpenGLWidgets import QOpenGLWidget -from PySide6.QtWidgets import QWidget from orisys_app_shared.scene3d.contracts import ( Diagnostic, DisplaySettings, + FrameIdentity, GLCapabilities, RenderFrame, RenderStats, SceneAssets, diagnostic, ) +from PySide6.QtCore import QEvent, Qt, Signal +from PySide6.QtGui import QMouseEvent, QWheelEvent + +# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows. +from PySide6.QtOpenGLWidgets import QOpenGLWidget +from PySide6.QtWidgets import QWidget + from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.gl_backend import ( GLDispatch, @@ -62,27 +63,27 @@ class Sensor3DWidget(QOpenGLWidget): self, renderer: GLRenderer, camera: OrbitCamera, - parent: Optional[QWidget] = None, + parent: QWidget | None = None, *, - dispatch: Optional[GLDispatch] = None, + dispatch: GLDispatch | None = None, ) -> None: super().__init__(parent) self._renderer = renderer self._camera = camera self._dispatch = dispatch or GLDispatch() - self._assets: Optional[SceneAssets] = None - self._frame: Optional[RenderFrame] = None + self._assets: SceneAssets | None = None + self._frame: RenderFrame | None = None self._settings = DisplaySettings() - self._capabilities: Optional[GLCapabilities] = None - self._diagnostic: Optional[Diagnostic] = None + self._capabilities: GLCapabilities | None = None + self._diagnostic: Diagnostic | None = None self._needs_scene_upload = False self._needs_surface_upload = False self._last_stats_at = 0.0 #: What reached the screen, counted by identity, and what it cost to compose. self._unique_frames = 0 - self._last_frame_identity: Optional[FrameIdentity] = None + self._last_frame_identity: FrameIdentity | None = None self._last_compose_ms = 0.0 - self._last_press: Optional[tuple[float, float]] = None + self._last_press: tuple[float, float] | None = None self._unavailable = False #: Frames the display never showed, set by the coordinator. Never a #: recording figure. @@ -119,7 +120,7 @@ class Sensor3DWidget(QOpenGLWidget): def camera(self) -> OrbitCamera: return self._camera - def capabilities(self) -> Optional[GLCapabilities]: + def capabilities(self) -> GLCapabilities | None: return self._capabilities def displayed_frames(self) -> int: @@ -130,7 +131,7 @@ class Sensor3DWidget(QOpenGLWidget): """What the frame on screen cost to compose, measured where it was composed.""" return float(self._last_compose_ms) - def diagnostic(self) -> Optional[Diagnostic]: + def diagnostic(self) -> Diagnostic | None: return self._diagnostic @property diff --git a/apps/studio/src/orisys_studio/session_controller.py b/apps/studio/src/orisys_studio/session_controller.py index a4231e0..a357a11 100644 --- a/apps/studio/src/orisys_studio/session_controller.py +++ b/apps/studio/src/orisys_studio/session_controller.py @@ -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 diff --git a/apps/studio/src/orisys_studio/session_metadata.py b/apps/studio/src/orisys_studio/session_metadata.py index 76e73ce..629379d 100644 --- a/apps/studio/src/orisys_studio/session_metadata.py +++ b/apps/studio/src/orisys_studio/session_metadata.py @@ -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 diff --git a/apps/studio/src/orisys_studio/ui/about_dialog.py b/apps/studio/src/orisys_studio/ui/about_dialog.py index d509a05..ca90eed 100644 --- a/apps/studio/src/orisys_studio/ui/about_dialog.py +++ b/apps/studio/src/orisys_studio/ui/about_dialog.py @@ -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 diff --git a/apps/studio/src/orisys_studio/ui/arrow_panel.py b/apps/studio/src/orisys_studio/ui/arrow_panel.py index a114638..35f5930 100644 --- a/apps/studio/src/orisys_studio/ui/arrow_panel.py +++ b/apps/studio/src/orisys_studio/ui/arrow_panel.py @@ -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``), diff --git a/apps/studio/src/orisys_studio/ui/brand_bar.py b/apps/studio/src/orisys_studio/ui/brand_bar.py index 7d8483b..1bfd8a0 100644 --- a/apps/studio/src/orisys_studio/ui/brand_bar.py +++ b/apps/studio/src/orisys_studio/ui/brand_bar.py @@ -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__) diff --git a/apps/studio/src/orisys_studio/ui/cal_panel.py b/apps/studio/src/orisys_studio/ui/cal_panel.py index 5a3f6a8..7993080 100644 --- a/apps/studio/src/orisys_studio/ui/cal_panel.py +++ b/apps/studio/src/orisys_studio/ui/cal_panel.py @@ -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 diff --git a/apps/studio/src/orisys_studio/ui/cal_point_dialog.py b/apps/studio/src/orisys_studio/ui/cal_point_dialog.py index 34a9140..419bfee 100644 --- a/apps/studio/src/orisys_studio/ui/cal_point_dialog.py +++ b/apps/studio/src/orisys_studio/ui/cal_point_dialog.py @@ -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 diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index f5a187d..cf37631 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -5,16 +5,6 @@ from __future__ import annotations import time import numpy as np -from PySide6.QtCore import Qt, QSize, Signal -from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics -from PySide6.QtWidgets import ( - QFrame, - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, -) - from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ from orisys_app_shared.force import MetricHistory from orisys_app_shared.force.history import DEFAULT_MAX_FPS @@ -27,6 +17,15 @@ from orisys_app_shared.force.source import StoreCurveSource from orisys_app_shared.i18n import t from orisys_app_shared.record.sample import plottable_axis_mask from orisys_app_shared.theme import color +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) from orisys_studio.ui import icons from orisys_studio.ui.icons import ICON_SIZE @@ -130,11 +129,11 @@ def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont: class _Sync: """Marks a block of range changes as programmatic (not a user gesture).""" - def __init__(self, panel: "ForcePlotsPanel") -> None: + def __init__(self, panel: ForcePlotsPanel) -> None: self._panel = panel self._previous = panel._syncing - def __enter__(self) -> "_Sync": + def __enter__(self) -> _Sync: self._panel._syncing = True return self @@ -1054,7 +1053,7 @@ class ForcePlotsPanel(QFrame): self._window = (x0, x1) self.refresh_from_store() - def _guard(self) -> "_Sync": + def _guard(self) -> _Sync: """Context manager marking range changes as programmatic.""" return _Sync(self) diff --git a/apps/studio/src/orisys_studio/ui/icons.py b/apps/studio/src/orisys_studio/ui/icons.py index e72d52a..2dfd108 100644 --- a/apps/studio/src/orisys_studio/ui/icons.py +++ b/apps/studio/src/orisys_studio/ui/icons.py @@ -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") diff --git a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py index 5c7f5c5..93c8ab0 100644 --- a/apps/studio/src/orisys_studio/ui/mesh3d_panel.py +++ b/apps/studio/src/orisys_studio/ui/mesh3d_panel.py @@ -13,9 +13,15 @@ acquisition, and no path that reports a 3D problem as a recording problem. from __future__ import annotations +from collections.abc import Callable from pathlib import Path -from typing import TYPE_CHECKING, Callable, Optional +from typing import TYPE_CHECKING +from orisys_app_shared.i18n import t +from orisys_app_shared.scene3d.contracts import ( + Diagnostic, + ExpectedGeometry, +) from PySide6.QtCore import QSize, Qt, Signal from PySide6.QtWidgets import ( QFrame, @@ -27,13 +33,6 @@ from PySide6.QtWidgets import ( QWidget, ) -from orisys_app_shared.i18n import t -from orisys_app_shared.scene3d.contracts import ( - Diagnostic, - ExpectedGeometry, - SceneAssets, - diagnostic, -) from orisys_studio.scene3d.asset_source import ( SceneAssetLoader, SceneSource, @@ -44,6 +43,7 @@ from orisys_studio.ui import icons if TYPE_CHECKING: # the view imports GL bindings lazily; the type is enough here from orisys_app_shared.acquisition.geometry import DeviceGeometry + from orisys_studio.scene3d.view import Scene3DView #: Shown when the pane is on but this sensor has no 3D scene at all (box, mini). @@ -81,17 +81,17 @@ class Mesh3dPanel(QFrame): enabledChanged = Signal(bool) def __init__( - self, parent: Optional[QWidget] = None, *, theme: str = "dark" + self, parent: QWidget | None = None, *, theme: str = "dark" ) -> None: super().__init__(parent) self.setObjectName("contentPanel") self._theme = str(theme) - self._view: Optional["Scene3DView"] = None - self._loader: Optional[SceneAssetLoader] = None - self._source: Optional[SceneSource] = None + self._view: Scene3DView | None = None + self._loader: SceneAssetLoader | None = None + self._source: SceneSource | None = None self._sensor_type = "" - self._expected: Optional[ExpectedGeometry] = None - self._expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None + self._expected: ExpectedGeometry | None = None + self._expected_provider: Callable[[], DeviceGeometry] | None = None self._source_kind = "live" self._enabled = False self._loaded = False @@ -103,8 +103,8 @@ class Mesh3dPanel(QFrame): self._keys = None #: The package whose scene is in the view, so switching sensor away and back #: does not reload it (and does not pretend to be loading it either). - self._loaded_path: Optional[Path] = None - self._reported: Optional[Diagnostic] = None + self._loaded_path: Path | None = None + self._reported: Diagnostic | None = None self._pending_settings = None self._message_kind = _UNAVAILABLE self._message_args: dict[str, str] = {"reason": ""} @@ -225,8 +225,8 @@ class Mesh3dPanel(QFrame): self, sensor_type: str, *, - expected: Optional[ExpectedGeometry] = None, - expected_provider: "Optional[Callable[[], DeviceGeometry]]" = None, + expected: ExpectedGeometry | None = None, + expected_provider: Callable[[], DeviceGeometry] | None = None, package_override: str | Path | None = None, ) -> None: """Tell the pane which sensor it is looking at. Cheap; no IO. @@ -276,7 +276,7 @@ class Mesh3dPanel(QFrame): return self._show_message(_LOADING, name=self._source.path.name) - def attach_view(self, view: "Scene3DView") -> None: + def attach_view(self, view: Scene3DView) -> None: """Adopt a view: the real one (built lazily) or a double in a test. Public because it is the seam tests use — poking ``_view`` from a test would @@ -308,7 +308,7 @@ class Mesh3dPanel(QFrame): if not view.available: self._show_message(_UNAVAILABLE, reason=view.reason) - def ensure_view(self) -> Optional[QWidget]: + def ensure_view(self) -> QWidget | None: """Build the 3D view on first use, so a headless machine never imports GL.""" if self._view is not None or self._sensor_type != "finger": return self._view @@ -448,11 +448,11 @@ class Mesh3dPanel(QFrame): # ------------------------------------------------------------------ inputs @property - def view(self) -> Optional["Scene3DView"]: + def view(self) -> Scene3DView | None: return self._view @property - def last_diagnostic(self) -> Optional[Diagnostic]: + def last_diagnostic(self) -> Diagnostic | None: return self._reported @property @@ -471,7 +471,7 @@ class Mesh3dPanel(QFrame): """Whether a 3D picture is possible here at all (GL and an asset).""" return self._view is not None and bool(self._view.available) - def scene_path(self) -> Optional[Path]: + def scene_path(self) -> Path | None: return None if self._source is None else self._source.path def set_source_kind(self, kind: str) -> None: diff --git a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py index 40143d9..11b7d92 100644 --- a/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py +++ b/apps/studio/src/orisys_studio/ui/plot_settings_dialog.py @@ -10,6 +10,12 @@ reports as done is what the record will say (see ``force/filters.py``). from __future__ import annotations +from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ +from orisys_app_shared.force.filters import ( + FilterSettings, + available_presets, +) +from orisys_app_shared.i18n import t from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -25,14 +31,6 @@ from PySide6.QtWidgets import ( QVBoxLayout, ) -from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ -from orisys_app_shared.force.filters import ( - SUGGESTED_LOWPASS_HZ, - FilterSettings, - available_presets, -) -from orisys_app_shared.i18n import t - DEFAULT_PLOT_WINDOW_SEC = 4.0 #: Lower bounds. FN is allowed below zero: a resting normal force has no reason #: to sit exactly on the frame, and with the lower bound at 0 the idle curve is diff --git a/apps/studio/src/orisys_studio/ui/record_status.py b/apps/studio/src/orisys_studio/ui/record_status.py index 97c76bb..9c8e0a0 100644 --- a/apps/studio/src/orisys_studio/ui/record_status.py +++ b/apps/studio/src/orisys_studio/ui/record_status.py @@ -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" diff --git a/apps/studio/src/orisys_studio/ui/sensor_dialog.py b/apps/studio/src/orisys_studio/ui/sensor_dialog.py index f0c9304..bbd392f 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_dialog.py +++ b/apps/studio/src/orisys_studio/ui/sensor_dialog.py @@ -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 diff --git a/apps/studio/src/orisys_studio/ui/sensor_picker.py b/apps/studio/src/orisys_studio/ui/sensor_picker.py index 49427e3..887868d 100644 --- a/apps/studio/src/orisys_studio/ui/sensor_picker.py +++ b/apps/studio/src/orisys_studio/ui/sensor_picker.py @@ -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 diff --git a/apps/studio/src/orisys_studio/ui/toolbar.py b/apps/studio/src/orisys_studio/ui/toolbar.py index 0a0c636..74b7e56 100644 --- a/apps/studio/src/orisys_studio/ui/toolbar.py +++ b/apps/studio/src/orisys_studio/ui/toolbar.py @@ -19,6 +19,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row. from __future__ import annotations +from orisys_app_shared.i18n import t +from orisys_app_shared.theme import color from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QComboBox, @@ -33,9 +35,6 @@ from PySide6.QtWidgets import ( QWidget, ) -from orisys_app_shared.i18n import t -from orisys_app_shared.theme import color - from orisys_studio.ui.fit_text import ShrinkableLabel from orisys_studio.ui.record_status import RecordStatusChip diff --git a/apps/studio/src/orisys_studio/view_layout.py b/apps/studio/src/orisys_studio/view_layout.py index 5d99884..5b89b85 100644 --- a/apps/studio/src/orisys_studio/view_layout.py +++ b/apps/studio/src/orisys_studio/view_layout.py @@ -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 diff --git a/apps/studio/tests/conftest.py b/apps/studio/tests/conftest.py index 73f2c17..423b4da 100644 --- a/apps/studio/tests/conftest.py +++ b/apps/studio/tests/conftest.py @@ -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" diff --git a/apps/studio/tests/test_force_plots_display.py b/apps/studio/tests/test_force_plots_display.py index af48446..969ec62 100644 --- a/apps/studio/tests/test_force_plots_display.py +++ b/apps/studio/tests/test_force_plots_display.py @@ -9,7 +9,6 @@ the curves and the value cards were actually given. from __future__ import annotations import numpy as np - from orisys_app_shared.force.session import SessionSample, SessionStore from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force diff --git a/apps/studio/tests/test_pause_controls.py b/apps/studio/tests/test_pause_controls.py index ff41a5e..380360f 100644 --- a/apps/studio/tests/test_pause_controls.py +++ b/apps/studio/tests/test_pause_controls.py @@ -14,7 +14,6 @@ button, is what tells stopped from paused. from __future__ import annotations from orisys_app_shared.i18n import t - from tests.conftest import finish_run, running_window, show_window diff --git a/apps/studio/tests/test_replay_controller.py b/apps/studio/tests/test_replay_controller.py index 0cb649b..f706d04 100644 --- a/apps/studio/tests/test_replay_controller.py +++ b/apps/studio/tests/test_replay_controller.py @@ -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 diff --git a/apps/studio/tests/test_scene3d_adapters.py b/apps/studio/tests/test_scene3d_adapters.py index cb8f9dc..e9951e1 100644 --- a/apps/studio/tests/test_scene3d_adapters.py +++ b/apps/studio/tests/test_scene3d_adapters.py @@ -9,16 +9,13 @@ the grid does not match, with a diagnostic that says which shapes were seen. from __future__ import annotations from dataclasses import dataclass -from typing import Optional import numpy as np import pytest - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( ExpectedGeometry, FrameIdentity, - ThreeDError, validate_surface_frame, ) from orisys_studio.scene3d.adapters import ( @@ -40,16 +37,16 @@ class FakePacket: behaviour under test. """ - flow: Optional[np.ndarray] = None - depth: Optional[np.ndarray] = None - valid_mask: Optional[np.ndarray] = None + flow: np.ndarray | None = None + depth: np.ndarray | None = None + valid_mask: np.ndarray | None = None frame_id: int = 1 invalid_reason: str = "" - flow_valid: Optional[bool] = True - flow_frame_id: Optional[int] = None - depth_valid: Optional[bool] = True - depth_frame_id: Optional[int] = None - forces_valid: Optional[bool] = True + flow_valid: bool | None = True + flow_frame_id: int | None = None + depth_valid: bool | None = True + depth_frame_id: int | None = None + forces_valid: bool | None = True def _identity(generation: int = 1) -> FrameIdentity: diff --git a/apps/studio/tests/test_scene3d_coordinator.py b/apps/studio/tests/test_scene3d_coordinator.py index e96e76e..6e14e24 100644 --- a/apps/studio/tests/test_scene3d_coordinator.py +++ b/apps/studio/tests/test_scene3d_coordinator.py @@ -15,7 +15,6 @@ from dataclasses import replace import numpy as np import pytest - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.contracts import ( diff --git a/apps/studio/tests/test_scene3d_gl_smoke.py b/apps/studio/tests/test_scene3d_gl_smoke.py index 1a303cb..dfb204e 100644 --- a/apps/studio/tests/test_scene3d_gl_smoke.py +++ b/apps/studio/tests/test_scene3d_gl_smoke.py @@ -23,7 +23,6 @@ import os import numpy as np import pytest - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( ColorScale, @@ -105,7 +104,11 @@ def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool: def gl_widget(qt_app): """A real, shown QOpenGLWidget with the synthetic scene, driven by Qt.""" from orisys_studio.scene3d.camera import OrbitCamera - from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer, configure_surface_format + from orisys_studio.scene3d.gl_backend import ( + GLDispatch, + GLRenderer, + configure_surface_format, + ) from orisys_studio.scene3d.widget import Sensor3DWidget configure_surface_format("desktop33") diff --git a/apps/studio/tests/test_scene3d_overlays.py b/apps/studio/tests/test_scene3d_overlays.py index 090b82b..9fa8f6c 100644 --- a/apps/studio/tests/test_scene3d_overlays.py +++ b/apps/studio/tests/test_scene3d_overlays.py @@ -3,7 +3,6 @@ from __future__ import annotations import numpy as np - from orisys_app_shared.acquisition.arrows import _policy_from_style from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.contracts import ( diff --git a/apps/studio/tests/test_scene3d_pane_ui.py b/apps/studio/tests/test_scene3d_pane_ui.py index 3ebee68..7746513 100644 --- a/apps/studio/tests/test_scene3d_pane_ui.py +++ b/apps/studio/tests/test_scene3d_pane_ui.py @@ -13,11 +13,10 @@ view menu / the pane's header both reach that dialog. from __future__ import annotations import pytest -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QWidget - from orisys_app_shared.i18n import t from orisys_studio.ui import icons +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget @pytest.fixture @@ -186,7 +185,11 @@ def test_the_pane_offers_the_two_quantities_and_says_which_one_it_drew(panel): def test_the_diagnostics_switch_governs_what_the_pane_explains(panel): """Warnings are claims about the measurement and always show; info is explanation.""" - from orisys_app_shared.scene3d.contracts import FieldDescriptor, RenderStats, diagnostic + from orisys_app_shared.scene3d.contracts import ( + FieldDescriptor, + RenderStats, + diagnostic, + ) panel.set_descriptor(FieldDescriptor("flow_magnitude", "px", "approximate", False)) panel.set_render_stats( diff --git a/apps/studio/tests/test_scene3d_protected_package.py b/apps/studio/tests/test_scene3d_protected_package.py index c975444..26f1a5a 100644 --- a/apps/studio/tests/test_scene3d_protected_package.py +++ b/apps/studio/tests/test_scene3d_protected_package.py @@ -13,7 +13,6 @@ import json from pathlib import Path import pytest - from orisys_app_shared.scene3d import protect, synthetic from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError from orisys_app_shared.scene3d.package import write_package diff --git a/apps/studio/tests/test_scene3d_region_agreement.py b/apps/studio/tests/test_scene3d_region_agreement.py index 973087c..b529ae6 100644 --- a/apps/studio/tests/test_scene3d_region_agreement.py +++ b/apps/studio/tests/test_scene3d_region_agreement.py @@ -404,8 +404,6 @@ def _render_region(qt_app, assets, island, uvs, preset_name, viewport): RenderFrame, freeze, ) - from PySide6.QtGui import QImage - from orisys_studio.scene3d import gl_backend from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.gl_backend import ( @@ -415,6 +413,7 @@ def _render_region(qt_app, assets, island, uvs, preset_name, viewport): lut_rgba, ) from orisys_studio.scene3d.widget import Sensor3DWidget + from PySide6.QtGui import QImage configure_surface_format("desktop33") renderer = GLRenderer() diff --git a/apps/studio/tests/test_scene3d_skeleton.py b/apps/studio/tests/test_scene3d_skeleton.py index 87e5380..f464815 100644 --- a/apps/studio/tests/test_scene3d_skeleton.py +++ b/apps/studio/tests/test_scene3d_skeleton.py @@ -49,9 +49,8 @@ def test_the_skeleton_does_not_import_a_gl_binding(qt_app): def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format): - from PySide6.QtGui import QSurfaceFormat - from orisys_studio.scene3d.gl_backend import configure_surface_format + from PySide6.QtGui import QSurfaceFormat configure_surface_format("desktop33") desktop = QSurfaceFormat.defaultFormat() @@ -72,7 +71,7 @@ def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_sur def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app): """Constructing the widget must not need a current context or a GPU.""" - from orisys_app_shared.scene3d.contracts import DisplaySettings, FrameIdentity + from orisys_app_shared.scene3d.contracts import DisplaySettings from orisys_studio.scene3d.camera import OrbitCamera from orisys_studio.scene3d.gl_backend import GLRenderer from orisys_studio.scene3d.widget import Sensor3DWidget diff --git a/apps/studio/tests/test_scene3d_stage1.py b/apps/studio/tests/test_scene3d_stage1.py index e44e052..76c267a 100644 --- a/apps/studio/tests/test_scene3d_stage1.py +++ b/apps/studio/tests/test_scene3d_stage1.py @@ -9,7 +9,6 @@ from __future__ import annotations import numpy as np import pytest - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( CameraPreset, diff --git a/apps/studio/tests/test_scene3d_view.py b/apps/studio/tests/test_scene3d_view.py index 66cc2cf..e5e2455 100644 --- a/apps/studio/tests/test_scene3d_view.py +++ b/apps/studio/tests/test_scene3d_view.py @@ -20,6 +20,7 @@ mathematics (the camera, the sampler and the composer have their own files): from __future__ import annotations +import math import time from concurrent.futures import Future from dataclasses import replace @@ -27,17 +28,14 @@ from pathlib import Path import numpy as np import pytest - import ui_check -from PySide6.QtCore import Signal -from PySide6.QtWidgets import QWidget from orisys_app_shared.i18n import t from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( + UNAVAILABLE_DESCRIPTOR, ColorScale, DisplaySettings, FieldDescriptor, - UNAVAILABLE_DESCRIPTOR, ) from orisys_app_shared.scene3d.package import write_package from orisys_studio.app_window import MIN_PANEL_WIDTHS @@ -49,9 +47,15 @@ from orisys_studio.scene3d.asset_source import ( load_package, no_asset_diagnostic, ) +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget from tests.conftest import ( finish_run as _finish, +) +from tests.conftest import ( running_window as _running_window, +) +from tests.conftest import ( show_window as _show, ) @@ -60,7 +64,7 @@ from tests.conftest import ( def write_poc(tmp_path): """A real package on disk, produced by the shipping writer.""" - def _write(name: str = "finger-display-1.0.0") -> "object": + def _write(name: str = "finger-display-1.0.0") -> object: path = tmp_path / f"{name}{PACKAGE_SUFFIX}" write_package( synthetic.poc_scene(), @@ -520,7 +524,9 @@ def test_a_hand_edited_settings_block_is_repaired(block): assert settings.field in scene3d_prefs.FIELD_CHOICES if settings.color_scale is not None: lower, upper = settings.color_scale.lower, settings.color_scale.upper - assert lower == lower and upper == upper, "a NaN survived the repair" + # Not a tautology: ``float("nan")`` is in the parametrisation above, and this + # is the assertion that the repair actually replaced it. + assert not math.isnan(lower) and not math.isnan(upper), "a NaN survived the repair" assert upper > lower assert abs(lower) <= scene3d_prefs.MAX_SCALE_BOUND assert abs(upper) <= scene3d_prefs.MAX_SCALE_BOUND @@ -1129,8 +1135,8 @@ def test_every_run_gets_a_new_generation(make_window, qt_app): Without this the pane accepts the first run's frames and refuses every later run's, so the picture stays on the first run's last frame forever. """ - from orisys_app_shared.scene3d.contracts import FrameIdentity import ui_check + from orisys_app_shared.scene3d.contracts import FrameIdentity window = _show(make_window(sensor_type="finger"), (1440, 880)) try: diff --git a/apps/studio/tests/test_sensor_types.py b/apps/studio/tests/test_sensor_types.py index 2d6ee58..6b3a2e9 100644 --- a/apps/studio/tests/test_sensor_types.py +++ b/apps/studio/tests/test_sensor_types.py @@ -10,8 +10,6 @@ from __future__ import annotations from pathlib import Path -import pytest - from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types from orisys_studio import app_window diff --git a/apps/studio/tests/test_session_controller.py b/apps/studio/tests/test_session_controller.py index 4c95a13..9d1204a 100644 --- a/apps/studio/tests/test_session_controller.py +++ b/apps/studio/tests/test_session_controller.py @@ -9,7 +9,6 @@ recording coordinator and fake workers. from __future__ import annotations import pytest - from orisys_studio import session_controller from orisys_studio.session_controller import SessionController diff --git a/apps/studio/tests/test_session_metadata.py b/apps/studio/tests/test_session_metadata.py index 865688f..57963ed 100644 --- a/apps/studio/tests/test_session_metadata.py +++ b/apps/studio/tests/test_session_metadata.py @@ -14,7 +14,6 @@ from types import SimpleNamespace import numpy as np import pytest - from orisys_app_shared.force.filters import FilterSettings from orisys_app_shared.record import RecordConfig from orisys_studio import session_metadata as sm @@ -133,7 +132,7 @@ def test_the_session_block_states_the_device_the_filters_and_the_picture(): ) worker = SimpleNamespace( capture_status=lambda: {"requested_fps": 30.0, "measured_fps": 21.0}, - filter_state=lambda: {}, + filter_state=dict, filter_settings=lambda: FilterSettings(), filters_unsupported=lambda: (), ) diff --git a/apps/studio/tests/test_transport_review.py b/apps/studio/tests/test_transport_review.py index 5e8e0ec..0d60924 100644 --- a/apps/studio/tests/test_transport_review.py +++ b/apps/studio/tests/test_transport_review.py @@ -13,7 +13,6 @@ Written against the requirement rather than the implementation: from __future__ import annotations import ui_check - from tests.conftest import finish_run, running_window, show_window diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 25467ed..3fbdd8f 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -5,14 +5,18 @@ from __future__ import annotations from pathlib import Path import numpy as np - import pytest - import ui_check from tests.conftest import ( finish_run as _finish, +) +from tests.conftest import ( running_window as _running_window, +) +from tests.conftest import ( show_window as _show, +) +from tests.conftest import ( visible_descendants, ) @@ -369,7 +373,6 @@ def test_the_panel_shows_the_numbers_of_the_file_it_exports(make_window, record_ from pathlib import Path import orisys.calibration as sdk_calibration - from orisys_app_shared.force.calibration_io import build_force_calibration_v2 window = _show(make_window(), (1400, 880)) @@ -529,8 +532,11 @@ def test_applying_another_devices_calibration_is_flagged(make_window, record_roo """The apply path states a sensor/config mismatch instead of silently applying.""" import json - from orisys_app_shared.force.calibration_io import build_export_model, write_force_calibration - from orisys_app_shared.force.calibration_io import CalSample, CalSessionStore + from orisys_app_shared.force.calibration_io import ( + CalSample, + CalSessionStore, + build_export_model, + ) window, stuck = _running_window(make_window) store = CalSessionStore() @@ -816,7 +822,6 @@ def test_a_confirmed_calibration_is_snapshotted_into_the_recording(make_window, from pathlib import Path import orisys_studio.app_window as app_module - from orisys_app_shared.i18n import t window, stuck = _running_window(make_window) app_module.recordings_dir = lambda: record_root @@ -1179,9 +1184,8 @@ def test_the_live_rail_is_a_play_pause_toggle_with_its_own_stop(make_window): icon-only pause button next to it, so the same position meant two different things depending on which view was on screen. """ - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) stuck = ui_check._StuckWorker() @@ -1232,7 +1236,6 @@ def test_the_live_rail_is_a_play_pause_toggle_with_its_own_stop(make_window): def test_record_strip_separates_acquiring_from_recording(make_window): from orisys_app_shared.i18n import t from orisys_app_shared.record.experiment import RecordStats - from orisys_studio.ui import record_status window, stuck = _running_window(make_window) @@ -1315,9 +1318,8 @@ def test_overview_region_is_the_timeline_control(make_window): def test_the_charts_start_in_follow_mode(make_window): """Every live entry point resumes following; only browsing is a gesture.""" - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window, stuck = _running_window(make_window) panel = window.force_plots @@ -1424,9 +1426,8 @@ def test_the_arrow_legend_follows_the_view_mode(make_window): def test_the_sensor_summary_opens_the_dialog(make_window): """The rail states the sensor; the pickers live in the dialog it opens.""" - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) assert window.toolbar.btn_sensor.text() == t( @@ -1614,9 +1615,8 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window): def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording): """The replay view reads the recording: charts, 2D panel and transport.""" - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() @@ -1726,7 +1726,6 @@ def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window): """One click on the unit chip changes the unit everywhere, not just locally.""" from orisys_app_shared.i18n import t from orisys_studio.ui.force_plots import unit_display - from PySide6.QtWidgets import QPushButton window = _show(make_window(), (1440, 880)) @@ -1813,7 +1812,6 @@ def test_the_video_follows_the_sensor_rate_and_keeps_every_row(make_window, reco import csv import cv2 - import orisys_studio.app_window as app_module window, stuck = _running_window(make_window) @@ -1911,9 +1909,8 @@ def test_the_readout_describes_the_point_the_cursor_is_on( A replay used to state the live acquisition in cards sitting next to a chart of 7.5e5 raw counts, which reads as "the replay never opened". """ - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() @@ -1945,9 +1942,8 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor for the axes it calibrated. Asking for newton anyway drew three blank charts and a dash in every card, which looks exactly like "the replay did not load". """ - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.toolbar.set_units("newton") @@ -2056,9 +2052,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec there: plain playback and a recompute pass. The tracker reset owns no device here, so it is disabled rather than repurposed. """ - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() @@ -2135,9 +2130,8 @@ def test_escape_stops_the_replay_too(make_window, replay_recording): def test_the_live_view_keeps_the_tracker_reset(make_window): - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_live.click() @@ -2160,9 +2154,8 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor import cv2 import numpy as np - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication bare = Path(record_root) / "bare_video.mp4" writer = cv2.VideoWriter( @@ -2223,15 +2216,14 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco """ import cv2 import numpy as np - from PySide6.QtWidgets import QApplication - import orisys_studio.app_window as app_module from orisys_app_shared.acquisition.image_backend import ( DERIVED_MODES, - ImageFrame, SOURCE_LIVE, SOURCE_RECOMPUTE, + ImageFrame, ) + from PySide6.QtWidgets import QApplication bare = Path(record_root) / "backend_video.mp4" writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)) @@ -2318,9 +2310,8 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro """ import cv2 import numpy as np - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication bare = Path(record_root) / "gated_video.mp4" writer = cv2.VideoWriter(str(bare), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (64, 48)) @@ -2373,8 +2364,6 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason( """Not a recording *and* not decodable: say what is missing and offer recompute.""" from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t - broken = Path(record_root) / "not_really_a_video.mp4" broken.write_text("this is not a video at all\n", encoding="utf-8") @@ -2404,9 +2393,8 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason( def test_a_video_inside_a_recording_opens_the_recording_and_plays( make_window, replay_recording ): - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) window.brand_bar.btn_playback.click() @@ -2432,9 +2420,8 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor import csv import json - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import t + from PySide6.QtWidgets import QApplication directory = Path(record_root) / "no_video_recording" directory.mkdir(parents=True, exist_ok=True) @@ -2531,9 +2518,8 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window): """ import time - from PySide6.QtWidgets import QApplication - import orisys_studio.app_window as app_module + from PySide6.QtWidgets import QApplication window = _show(make_window(), (1440, 880)) assert not hasattr(window, "device_label"), ( diff --git a/apps/studio/tests/test_view_layout.py b/apps/studio/tests/test_view_layout.py index dceceba..6750f9f 100644 --- a/apps/studio/tests/test_view_layout.py +++ b/apps/studio/tests/test_view_layout.py @@ -8,8 +8,6 @@ both can be stood in for — which is the point of the split, and what these tes from __future__ import annotations -import pytest - from orisys_studio.view_layout import ViewLayoutController VIEW_PRESETS = ("custom", "live", "replay", "calibration") @@ -295,7 +293,8 @@ def test_a_preset_that_gives_no_width_to_the_3d_pane_takes_it_from_the_widest(): def test_the_shipped_live_layout_makes_the_3d_pane_the_widest_panel(): """The 3D view is the subject when it is on, not an inset beside the charts.""" - from orisys_studio.app_window import DEFAULT_SPLITTER_SIZES, VIEW_SPLITTER_SIZES as LIVE + from orisys_studio.app_window import DEFAULT_SPLITTER_SIZES + from orisys_studio.app_window import VIEW_SPLITTER_SIZES as LIVE mesh, image, charts = DEFAULT_SPLITTER_SIZES assert mesh > image, DEFAULT_SPLITTER_SIZES diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index c43d036..9727a4e 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -18,6 +18,7 @@ import sys import threading import time from pathlib import Path + APP_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = APP_ROOT.parents[1] for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): @@ -185,7 +186,6 @@ def _fake_packet( something visible (see ``ui_snapshot --scene replay``). """ import numpy as np - from orisys_app_shared.acquisition.sdk_worker import SdkPacket nan = float("nan") @@ -558,12 +558,10 @@ def audit_palette(verbose: bool = False) -> list[str]: def main() -> int: - from PySide6.QtWidgets import QApplication - - from orisys_app_shared.force.calibration_io import CalSample from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.theme import build_qss, load_colors - from orisys_studio.app_window import VIEW_SPLITTER_SIZES, StudioMainWindow + from orisys_studio.app_window import StudioMainWindow + from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication(sys.argv) set_locale("zh_CN") @@ -768,12 +766,14 @@ def main() -> int: window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"), "the excluded row does not show its state", ) + import orisys.calibration as _sdk_calibration from orisys_app_shared.force.calibration_io import ( build_export_model, - build_force_calibration_v2 as _build_v2, predict_channel, ) - import orisys.calibration as _sdk_calibration + from orisys_app_shared.force.calibration_io import ( + build_force_calibration_v2 as _build_v2, + ) store = window.cal_panel._store model = build_export_model( @@ -1798,6 +1798,7 @@ def main() -> int: section("acquisition correctness") import csv as _csv import shutil as _shutil + import numpy as np # A device that only confirms its calibration a moment after the first @@ -2090,6 +2091,10 @@ def main() -> int: env=env, capture_output=True, timeout=60, + # Deliberately not ``check=True``: a non-zero exit *is* one of the things + # being measured here (a crash on shutdown), and it is reported through + # this checker's own ``check()`` below rather than as a traceback. + check=False, ) elapsed = time.perf_counter() - started print(f" window closed and the process returned in {elapsed:.1f}s") @@ -2326,17 +2331,18 @@ def main() -> int: import json as _cal_json from unittest.mock import patch as _patch_mismatch - import orisys_studio.ui.cal_panel as _cal_panel_module import orisys_studio.calibration_service as _cal_service_module - from PySide6.QtWidgets import QMessageBox as _QMessageBox - + import orisys_studio.ui.cal_panel as _cal_panel_module from orisys_app_shared.force.calibration_io import ( build_force_calibration_v2 as _build_v2b, + ) + from orisys_app_shared.force.calibration_io import ( write_force_calibration as _write_cal, ) from orisys_app_shared.force.calibration_session import ( build_session_document as _build_session_doc, ) + from PySide6.QtWidgets import QMessageBox as _QMessageBox record_dir = APP_ROOT / "outputs" / "_cal_record_check" if record_dir.exists(): @@ -3065,7 +3071,6 @@ def main() -> int: recorder.on_packet(_fake_packet(index)) recorder.stop() - from orisys_studio.replay import ReplayEngine # The replay lives in the main window: one engine, one progress bar, and the # charts read the recording itself. @@ -3314,7 +3319,6 @@ def main() -> int: # Long-history browsing: an interval read is bounded by the interval, and the # whole-session envelope is a band rather than every row. from orisys_app_shared.force.source import RecordingCurveSource - from orisys_app_shared.record.index import RecordingIndex from orisys_app_shared.record.playback import RecordingReader as _RecordingReader reader = _RecordingReader(recording, max_rows=10) diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py index 8aa58ab..f785a14 100644 --- a/apps/studio/tools/ui_snapshot.py +++ b/apps/studio/tools/ui_snapshot.py @@ -38,9 +38,7 @@ os.chdir(APP_ROOT) def _replay_data(window, app) -> Path: """Synthesize one short recording (with visible frames) and open it.""" import numpy as np - from orisys_app_shared.record import ExperimentRecorder, RecordConfig - from ui_check import _fake_packet root = APP_ROOT / "outputs" / "_snapshot_replay" @@ -121,7 +119,6 @@ def _run_scene(window, app, scene: str): window._run_timer.stop() from orisys_app_shared.record.experiment import RecordStats - from orisys_studio.ui import record_status directory = "20240101T101500Z_session0001_a1b2c3d4e5f6" @@ -160,7 +157,6 @@ def _flow_scene(window, app, *, frames: int = 240) -> None: import numpy as np from orisys_app_shared.acquisition.sdk_worker import SdkPacket - from ui_check import _StuckWorker stuck = _StuckWorker() @@ -304,14 +300,12 @@ def _demo_data(window) -> None: # Synthetic outward flow so the branded canvas and flow ramp are reviewable. import numpy as np - from orisys_app_shared.acquisition.arrows import ( draw_arrow_display, flow_colormap, hex_to_bgr, ) from orisys_app_shared.theme import color - from orisys_studio.app_window import _bgr_to_qimage size = 320 @@ -393,11 +387,10 @@ def main() -> int: panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} - from PySide6.QtWidgets import QApplication - from orisys_app_shared.i18n import set_locale from orisys_app_shared.theme import build_qss from orisys_studio.app_window import StudioMainWindow + from PySide6.QtWidgets import QApplication app = QApplication(sys.argv) set_locale(args.lang) diff --git a/docs/architecture/studio-3d-contracts.pyi b/docs/architecture/studio-3d-contracts.pyi index 87bb9c2..7f0c059 100644 --- a/docs/architecture/studio-3d-contracts.pyi +++ b/docs/architecture/studio-3d-contracts.pyi @@ -6,19 +6,20 @@ All published NumPy arrays must be C-contiguous and read-only (frozen dataclasse alone do not freeze ndarray contents). Shapes are contractual, not type-checked by NDArray. See studio-3d-design.md for threading and validation requirements. """ +from collections.abc import Callable, Mapping +from concurrent.futures import Executor from dataclasses import dataclass from pathlib import Path -from typing import Callable, Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar -from concurrent.futures import Executor +from typing import Generic, Literal, Protocol, TypeAlias, TypeVar import numpy as np from numpy.typing import NDArray +from orisys.finger.runtime import FingerRuntime +from orisys_app_shared.acquisition.models import SdkPacket from PySide6.QtCore import QEvent, SignalInstance from PySide6.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import QWidget -from orisys_app_shared.acquisition.models import SdkPacket -from orisys.finger.runtime import FingerRuntime F32: TypeAlias = NDArray[np.float32] F64: TypeAlias = NDArray[np.float64] @@ -305,7 +306,6 @@ class LatestFrameMailbox(Generic[T]): def __init__(self) -> None: ... def offer(self, frame: T) -> OfferResult: """Thread-safe capacity-one replacement, constant-time under the lock.""" - ... def take(self) -> T | None: ... def clear(self) -> None: ... @property @@ -323,7 +323,6 @@ class OrbitCamera(Protocol): def configure_surface_format(profile: Profile) -> None: """Before QApplication and before importing a platform-specific GL binding.""" - ... class GLDispatch(Protocol): @@ -351,7 +350,6 @@ class Sensor3DWidget(QOpenGLWidget): parent: QWidget | None = None) -> None: ... def set_scene(self, assets: SceneAssets) -> None: """GUI: retain CPU data, schedule upload when context is current.""" - ... def present(self, frame: RenderFrame) -> None: ... def set_settings(self, settings: DisplaySettings) -> None: ... def initializeGL(self) -> None: ... diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..66c7191 --- /dev/null +++ b/docs/code-review.md @@ -0,0 +1,74 @@ +# Code review: the automated part + +Four gates existed before this document (the test suites, `apps/studio/tools/ui_check.py`, the +real-GL smoke tests, the binding-purity test) and none of them is a linter. This adds the missing +*static* layer, deliberately small. + +## Why it exists, in one measured fact + +Until now the three repos had **no Ruff configuration at all**. `ruff check .` therefore ran +whatever the installed version enables by default — Ruff 0.16 enables a very broad set +(`UP`/`RUF`/`PLR`/`S`/`TRY`/`PERF`/`FURB`/`YTT`/`ASYNC`…) — so the same tree produced **1038 / +438 / 117** findings in `orisys-app` / `orisys-sdk` / `orisys-sensor-design`, and a different +Ruff version produced different ones. A gate built on that is not a gate. `ruff.toml` now pins it. + +## What is gated, and what is deliberately not + +| rule | meaning | gated? | why | +|---|---|---|---| +| `BLE001` | broad `except Exception` | **no** | A broad catch is the *right* choice around device I/O, GL teardown and camera probing. This codebase already records that choice inline as `# noqa: BLE001 - `. Gating it would produce ~100 findings whose only correct fix is "add a reasoned suppression". | +| `S110`, `S112` | handler is only `pass` / `continue` | **yes** | The complaint is not the breadth of the catch, it is that the failure becomes **invisible**. Silence is acceptable only with a reasoned suppression or a handler that reports/re-raises. | +| `E722` | bare `except:` | **yes** | Strictly worse than `except Exception:` — it also swallows `KeyboardInterrupt`/`SystemExit`. | +| `F` | unused import, redefinition, undefined name, unused variable | **yes** | Real defects: `F821`/`F822` are latent `NameError`s, `F811` can be a shadowed definition. | +| `B023` | closure over a loop variable | **yes** | Late binding silently produces wrong geometry in the headless Blender scripts. | +| `PLW1510` | `subprocess.run` without `check` | **yes** | A swallowed tool failure. | +| `RUF100` | unused suppression | reported, **never auto-fixed** | The `- reason` in a `noqa` comment is documentation. A machine must not delete it. Triage by hand. | +| `UP`, `I`, `PIE`, `RET501`, `PLR1711`, `SIM114`, `RUF022`, `RUF023` | mechanical modernisation | in the lint config, **not** in the gate | Applied once by `--fix`; gating them would only churn AI-authored diffs. | + +`E402` (module import not at top of file) is **selected but not gated**. `import` after +`os.environ[...]` is deliberate in this codebase — the environment must be set before Qt/cv2/the SDK +load — so gating it would be pure noise. But it is the only rule that would catch an *accidental* +import moved above that setup, so it stays visible. When you touch one of those files, record the +intent as `# noqa: E402 - `; `E701`/`E702` are style, which `ruff format` owns. A gate that +cries wolf gets ignored, which is worse than no gate. + +## The three commands + +```powershell +# 1. The agent loop, after every edit — mechanical, no review needed. +ruff check --fix # never --unsafe-fixes +ruff check --output-format json # what remains, machine-readable + +# 2. The gate before a change lands. Changed files only, machine-readable, non-zero on findings. +python tools/lint_gate.py # vs HEAD +python tools/lint_gate.py --base origin/main +python tools/lint_gate.py --all # gate rules over the whole repo +python tools/lint_gate.py --json + +# 3. Formatting, changed files only — never a whole-tree rewrite mid-diff. +ruff format +``` + +`$RUFF` may point at a specific ruff binary; otherwise `tools/lint_gate.py` uses `PATH` or the one +beside the interpreter. **Pin the version**: the gate prints the version it ran, and findings differ +between versions, so a runner must install the same one the developers use (currently 0.16.9). + +## What this found when it was introduced + +Filtering to rules that can indicate a defect produced ~18 candidates rather than style noise — +which is the argument for the narrow set: + +- six latent `NameError`s (`np`, `sys`, `Callable`, `FrameIdentity`, `tessellate_polygon`, and an + `__all__` entry naming a function that does not exist) — none caught by any test; +- a **duplicated** function definition that silently shadowed the first; +- `lower == lower` / `upper == upper` in a range check, and the same pattern inside a **test + assertion that could never fail**; +- 13 closures over loop variables in the headless Blender/patch scripts; +- two `subprocess.run` calls whose failure was silently ignored. + +## Follow-up deliberately not done yet + +`PLR0124` ("name compared with itself") is not in `select`. Eleven of its sites are the deliberate +`value != value` NaN idiom and must become `math.isnan(...)` (or carry a reasoned suppression) +before the rule can be gated — otherwise it is noise. Once those are converted, add `PLR0124` to +`select` and to `GATE_SELECT` in `tools/lint_gate.py`. diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..9b7a78e --- /dev/null +++ b/ruff.toml @@ -0,0 +1,56 @@ +# Ruff configuration — pinned deliberately. +# +# Before this file existed the repo had no Ruff config, so `ruff check .` ran whatever the +# installed Ruff version enables by default (Ruff 0.16 turns on a very broad set: UP/RUF/PLR/ +# S/TRY/PERF/FURB/YTT/ASYNC...). The same tree therefore produced different findings on +# different machines and between a developer and a runner. Never rely on tool defaults. +# +# Policy, decided from the measured findings rather than from taste: +# +# * `select` is explicit. High-signal correctness rules first, then only the mechanical +# modernisation rules we are willing to apply without human review. +# * BLE001 is selected so the existing `# noqa: BLE001 - ` suppressions stay *used* +# and RUF100 can never delete their rationale — a broad `except Exception` is a legitimate +# choice in hardware, GL and teardown paths. It is deliberately NOT part of the gate. +# * S110 / S112 / E722 ARE part of the gate: a silent handler hides a failure. Silence is +# acceptable only with a reasoned suppression, or with a handler that reports/re-raises. +# * RUF100 reports unused suppressions but is `unfixable`: a machine must not remove a +# suppression whose comment records why it exists. Triage those by hand. +# * The mechanical sweep never uses `--unsafe-fixes`. +# +# The gate (pre-merge / runner) selects the narrow set explicitly, so BLE001 cannot fail a +# build, and only ever touches changed files: +# +# tools/lint_gate.py # the gate: changed files, machine-readable +# ruff format --check # never a whole-tree rewrite mid-diff + +target-version = "py310" +line-length = 88 + +[lint] +select = [ + "E9", "E722", # syntax/IO errors, and bare `except:` + "E402", # module import not at top of file — SELECTED for visibility, NOT gated. + # `import` after `os.environ[...]` is deliberate in this codebase (the + # environment must be set before Qt/cv2 load), so gating it would be noise; + # but it is the only rule that would catch an accidental import moved above + # that setup, so it stays reported. An intentional site belongs in a + # `# noqa: E402 - ` when it is touched. + "F", # pyflakes: F401 unused import, F811 redefinition, F821/F822 undefined name + "S110", "S112", # try-except-pass / try-except-continue — an invisible failure + "B023", # function definition does not bind loop variable + "PLW1510", # subprocess.run without an explicit check + "RUF100", # unused noqa — reported for triage, never auto-removed + "UP", # pyupgrade (Optional[X] -> X | None, deprecated imports) + "I", # import sorting + "RUF022", "RUF023", # __all__ / __slots__ ordering + "PIE", # flake8-pie + "RET501", "PLR1711", # useless return / return None + "SIM114", # if-with-same-arms + "BLE001", # selected for suppression hygiene only — excluded from the gate +] +unfixable = ["RUF100"] + +[format] +quote-style = "double" +indent-style = "space" diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py index b0c1f2f..718ca6b 100644 --- a/shared/src/orisys_app_shared/acquisition/arrows.py +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence from functools import lru_cache -from typing import Optional, Sequence import cv2 import numpy as np @@ -204,9 +204,9 @@ def _paint_shared_flow_arrows( canvas: np.ndarray, flow: np.ndarray, *, - valid_mask: Optional[np.ndarray], + valid_mask: np.ndarray | None, policy, - colormap: Optional[np.ndarray], + colormap: np.ndarray | None, below_threshold_color: tuple[int, int, int], ) -> np.ndarray: """Draw arrows from :func:`select_flow_samples` so 2D matches 3D sample points.""" @@ -240,14 +240,14 @@ def _paint_shared_flow_arrows( def draw_arrow_display( processed_frame: np.ndarray, - flow: Optional[np.ndarray], + flow: np.ndarray | None, *, background: str = "pure_color", canvas_bgr: Sequence[int] | None = None, draw_style: str = "box", - valid_mask: Optional[np.ndarray] = None, - domain_mask: Optional[np.ndarray] = None, - colormap: Optional[np.ndarray] = None, + valid_mask: np.ndarray | None = None, + domain_mask: np.ndarray | None = None, + colormap: np.ndarray | None = None, exterior_bgr: Sequence[int] | None = None, island_bgr: Sequence[int] | None = None, flow_on_px: float | None = None, @@ -333,12 +333,12 @@ def draw_arrow_display( def depth_map_to_bgr( - depth: Optional[np.ndarray], - colormap: Optional[np.ndarray] = None, + depth: np.ndarray | None, + colormap: np.ndarray | None = None, *, - value_range: Optional[tuple[float, float]] = None, - invalid_mask: Optional[np.ndarray] = None, -) -> Optional[np.ndarray]: + value_range: tuple[float, float] | None = None, + invalid_mask: np.ndarray | None = None, +) -> np.ndarray | None: """Convert a float depth/divergence map to a colormap BGR image. ``value_range`` pins the colour scale. Per-frame re-normalisation (the old diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index 3d20ffe..92d9782 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -5,23 +5,26 @@ from __future__ import annotations import logging import threading import time +from collections.abc import Callable from pathlib import Path -from typing import Callable, Optional import numpy as np -from orisys_app_shared.acquisition.sinks import RecordSinkMixin # The packet and the geometry come from ``models``, so the finger worker does not # depend on the box worker; only genuinely shared helpers are imported from it. from orisys_app_shared.acquisition.models import ( ContactObservation, FixedCameraMode, SdkPacket, - ensure_bgr as _ensure_bgr, - is_file_source as _is_file_source, load_fixed_mode, orisys_importable, ) +from orisys_app_shared.acquisition.models import ( + ensure_bgr as _ensure_bgr, +) +from orisys_app_shared.acquisition.models import ( + is_file_source as _is_file_source, +) from orisys_app_shared.acquisition.sdk_worker import ( CAMERA_DISCONNECT_TIMEOUT_S, FILE_FIRST_FRAME_TIMEOUT_S, @@ -38,6 +41,7 @@ from orisys_app_shared.acquisition.sdk_worker import ( process_chain, read_forces_from_sensor, ) +from orisys_app_shared.acquisition.sinks import RecordSinkMixin from orisys_app_shared.force.filters import ALL_KEYS, FilterSettings, effective_filters logger = logging.getLogger(__name__) @@ -74,10 +78,10 @@ class FingerAcquisitionWorker(RecordSinkMixin): def __init__( self, *, - on_error: Optional[Callable[[str], None]] = None, - on_status: Optional[Callable[[str], None]] = None, - on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None, - on_reset_applied: Optional[Callable[[int, float, int], None]] = None, + on_error: Callable[[str], None] | None = None, + on_status: Callable[[str], None] | None = None, + on_calibration_applied: Callable[[str, str, int, float, int], None] | None = None, + on_reset_applied: Callable[[int, float, int], None] | None = None, ): self._init_sinks() self._on_error = on_error @@ -87,23 +91,23 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._on_calibration_applied = on_calibration_applied #: Called from the acquisition thread once a queued zeroing took effect. self._on_reset_applied = on_reset_applied - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._stop = threading.Event() #: Set while the operator has paused: the camera stays open and the source keeps #: being read, but no frame is processed and no packet is emitted. self._pause = threading.Event() self._lock = threading.Lock() - self._latest: Optional[SdkPacket] = None + self._latest: SdkPacket | None = None self._session_id = 0 # Keyed by session so a session that is still winding down can only # ever close its own runtime, never the runtime of a newer session. self._runtimes: dict[int, object] = {} - self._mode: Optional[FixedCameraMode] = None - self._video_source: Optional[str | int] = None - self._export_dir: Optional[Path] = None - self._end_reason: Optional[str] = None + self._mode: FixedCameraMode | None = None + self._video_source: str | int | None = None + self._export_dir: Path | None = None + self._end_reason: str | None = None self._phase: str = "idle" - self._pending_calibration: Optional[str] = None + self._pending_calibration: str | None = None #: A zeroing requested from another thread, applied by this one. self._pending_reset: bool = False self._mono_t0: float = 0.0 @@ -119,7 +123,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): #: reported rate is an echo of the request and cannot be trusted). self._measured_capture_fps: float = 0.0 - def set_filter_settings(self, settings: Optional[FilterSettings]) -> None: + def set_filter_settings(self, settings: FilterSettings | None) -> None: """Choose the filters for the **next** session.""" with self._lock: self._filter_settings = (settings or FilterSettings()).normalised() @@ -159,11 +163,11 @@ class FingerAcquisitionWorker(RecordSinkMixin): with self._lock: return self._phase - def latest(self) -> Optional[SdkPacket]: + def latest(self) -> SdkPacket | None: with self._lock: return self._latest - def consume_end_reason(self) -> Optional[str]: + def consume_end_reason(self) -> str | None: with self._lock: reason = self._end_reason self._end_reason = None @@ -233,7 +237,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._settle(owner=thread) # Otherwise the thread settles itself on the way out. - def _settle(self, *, owner: Optional[threading.Thread]) -> None: + def _settle(self, *, owner: threading.Thread | None) -> None: """Release worker state once no thread owns the runtime any more.""" if owner is not None and self._thread is not owner: return # a newer session owns the worker now @@ -352,7 +356,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): if self._on_status is not None: self._on_status(message) - def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None: + def _run(self, session: int, previous: threading.Thread | None = None) -> None: # A new session always starts unpaused: pausing is a statement about the session the # operator is watching, not a sticky property of the device. self._pause.clear() @@ -368,7 +372,7 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._latest = None self._phase = "idle" - def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None: + def _run_session(self, session: int, previous: threading.Thread | None) -> None: from orisys.finger import FingerRuntime if previous is not None and previous.is_alive(): @@ -443,14 +447,14 @@ class FingerAcquisitionWorker(RecordSinkMixin): capture_fps = 0.0 saw_frame = False last_frame_mono = 0.0 - none_streak_start: Optional[float] = None + none_streak_start: float | None = None # Per-session monotonic frame index: the join key shared by the video, # the CSV and the calibration records. frame_id = 0 #: The last frame each derived array was *actually* produced for, so a stale #: array can say which frame it belongs to instead of being read as new data. - last_flow_frame_id: Optional[int] = None - last_depth_frame_id: Optional[int] = None + last_flow_frame_id: int | None = None + last_depth_frame_id: int | None = None #: When this session's loop started, for the file-source deadline below. session_started = time.perf_counter() diff --git a/shared/src/orisys_app_shared/acquisition/geometry.py b/shared/src/orisys_app_shared/acquisition/geometry.py index 509a795..c5257f3 100644 --- a/shared/src/orisys_app_shared/acquisition/geometry.py +++ b/shared/src/orisys_app_shared/acquisition/geometry.py @@ -28,9 +28,9 @@ from __future__ import annotations import json import logging +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Mapping, Optional from orisys_app_shared.scene3d.contracts import ExpectedGeometry, error from orisys_app_shared.scene3d.digest import source_geometry_digest @@ -56,7 +56,7 @@ class DeviceGeometry: detail: Mapping[str, str] -def map_path_for(assets_dir: Path, source_scale: Optional[float]) -> Path: +def map_path_for(assets_dir: Path, source_scale: float | None) -> Path: """The unroll map the SDK would load for this scale, inside ``assets_dir``.""" if source_scale is None or abs(float(source_scale) - 1.0) < 1e-9: return Path(assets_dir) / f"{_MAP_STEM}{_MAP_SUFFIX}" @@ -75,7 +75,7 @@ def _read_json(path: Path) -> dict: return data if isinstance(data, dict) else {} -def _bundle_root(config_ref: str | Path) -> Optional[Path]: +def _bundle_root(config_ref: str | Path) -> Path | None: path = Path(str(config_ref)) if path.is_file(): return path.parent diff --git a/shared/src/orisys_app_shared/acquisition/image_backend.py b/shared/src/orisys_app_shared/acquisition/image_backend.py index bbb906c..727ddd0 100644 --- a/shared/src/orisys_app_shared/acquisition/image_backend.py +++ b/shared/src/orisys_app_shared/acquisition/image_backend.py @@ -22,8 +22,8 @@ NumPy only: the Qt conversion belongs to the UI layer (``ui/arrow_panel.bgr_to_q from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass -from typing import Optional, Sequence import numpy as np @@ -113,16 +113,16 @@ class DomainMaskLatch: array a frame carries, which is what makes holding it safe. """ - __slots__ = ("_mask", "min_iou", "adoptions", "held") + __slots__ = ("_mask", "adoptions", "held", "min_iou") def __init__(self, *, min_iou: float = DOMAIN_MASK_MIN_IOU) -> None: - self._mask: Optional[np.ndarray] = None + self._mask: np.ndarray | None = None self.min_iou = float(min_iou) self.adoptions = 0 self.held = 0 @property - def mask(self) -> Optional[np.ndarray]: + def mask(self) -> np.ndarray | None: return self._mask def reset(self) -> None: @@ -131,7 +131,7 @@ class DomainMaskLatch: self.adoptions = 0 self.held = 0 - def update(self, mask: Optional[np.ndarray]) -> Optional[np.ndarray]: + def update(self, mask: np.ndarray | None) -> np.ndarray | None: """The mask to draw the boundary with, given this frame's own mask. A frame that carries no mask cannot move the boundary, so it inherits the @@ -161,10 +161,10 @@ class ImageFrame: would have to invent them. """ - image_bgr: Optional[np.ndarray] = None - flow: Optional[np.ndarray] = None - depth: Optional[np.ndarray] = None - valid_mask: Optional[np.ndarray] = None + image_bgr: np.ndarray | None = None + flow: np.ndarray | None = None + depth: np.ndarray | None = None + valid_mask: np.ndarray | None = None source: str = SOURCE_LIVE frame_id: int = 0 t_rel_s: float = 0.0 @@ -172,10 +172,10 @@ class ImageFrame: #: coordinator's "stale" state); the panel can say so under the picture. stale: bool = False #: Per-field provenance mirroring ``SdkPacket``; ``None`` means unstated. - flow_valid: Optional[bool] = None - flow_frame_id: Optional[int] = None - depth_valid: Optional[bool] = None - depth_frame_id: Optional[int] = None + flow_valid: bool | None = None + flow_frame_id: int | None = None + depth_valid: bool | None = None + depth_frame_id: int | None = None def has_flow(self) -> bool: if self.flow is None: @@ -216,7 +216,7 @@ class ImagePlan: wanted = str(mode or "") return wanted if self.accepts(wanted) else self.default_mode - def needs(self, mode: str) -> Optional[str]: + def needs(self, mode: str) -> str | None: """The field a mode needs that this frame does not have, if any.""" wanted = str(mode or "") if wanted in (ARROW_PURE, ARROW_STITCHED) and not self.has_flow(): @@ -277,15 +277,15 @@ def plan_image(frame: ImageFrame) -> ImagePlan: def render_image( frame: ImageFrame, - plan: Optional[ImagePlan] = None, + plan: ImagePlan | None = None, *, mode: str = "", theme: str = "dark", draw_style: str = "box", - depth_range: Optional[tuple[float, float]] = None, - canvas_bgr: Optional[Sequence[int]] = None, - domain_mask: Optional[np.ndarray] = None, -) -> Optional[np.ndarray]: + depth_range: tuple[float, float] | None = None, + canvas_bgr: Sequence[int] | None = None, + domain_mask: np.ndarray | None = None, +) -> np.ndarray | None: """Draw ``frame`` in ``mode``; ``None`` when there is nothing to draw. The mode is resolved through the plan first, so a caller that asks for a view the diff --git a/shared/src/orisys_app_shared/acquisition/models.py b/shared/src/orisys_app_shared/acquisition/models.py index 69a8284..b645d30 100644 --- a/shared/src/orisys_app_shared/acquisition/models.py +++ b/shared/src/orisys_app_shared/acquisition/models.py @@ -11,7 +11,6 @@ import json import logging from dataclasses import dataclass from pathlib import Path -from typing import Optional import cv2 import numpy as np @@ -41,7 +40,7 @@ class ContactObservation: without this field do not invent a yellow contact arrow. """ - centroid_uv_xy: Optional[tuple[float, float]] = None + centroid_uv_xy: tuple[float, float] | None = None found: bool = False valid: bool = False frame_id: int = 0 @@ -82,9 +81,9 @@ class SdkPacket: #: clock that steps backwards cannot reorder history. ``None`` means the #: producer did not measure one, and the wall clock is used instead — a #: default of ``0.0`` would have made every frame look simultaneous. - mono_t: Optional[float] = None + mono_t: float | None = None #: Wall clock at hand-off, for metadata only. ``None`` means unmeasured. - wall_t: Optional[float] = None + wall_t: float | None = None #: False when the force readout failed. Invalid packets carry NaN forces and #: must never be averaged into calibration data or written as zeros. valid: bool = True @@ -99,9 +98,9 @@ class SdkPacket: #: False when the sensor exposes no RAW channels; the raw fields are then #: NaN rather than a copy of the calibrated values. raw_available: bool = True - flow: Optional[np.ndarray] = None - depth: Optional[np.ndarray] = None - valid_mask: Optional[np.ndarray] = None + flow: np.ndarray | None = None + depth: np.ndarray | None = None + valid_mask: np.ndarray | None = None # --- per-field provenance ------------------------------------------------- # The 3D view colours *one* array, and "the readout failed" is not the same # statement as "the tracker's field is stale". ``valid`` above is about the force @@ -109,18 +108,18 @@ class SdkPacket: # frame may claim. ``None`` means the producer made no statement, and a consumer # must not invent one: an unstated field is unverified, not fresh. #: Whether ``flow`` was produced for this frame's tracker pass. - flow_valid: Optional[bool] = None + flow_valid: bool | None = None #: The frame id ``flow`` belongs to, when it is not this frame's. - flow_frame_id: Optional[int] = None + flow_frame_id: int | None = None #: Whether ``depth`` is this frame's deformation output (a failed chain leaves the #: previous frame's depth in place). - depth_valid: Optional[bool] = None - depth_frame_id: Optional[int] = None + depth_valid: bool | None = None + depth_frame_id: int | None = None #: Whether the force triplet belongs to this frame (False when the chain or the #: readout failed, whatever ``valid`` says about the record). - forces_valid: Optional[bool] = None + forces_valid: bool | None = None #: Contact centre from ``compute_contact``; optional so old call sites stay valid. - contact: Optional[ContactObservation] = None + contact: ContactObservation | None = None def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode: diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 4222fea..21576ea 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -4,31 +4,32 @@ from __future__ import annotations import contextlib import ctypes -import json import logging import sys import threading import time -from dataclasses import dataclass +from collections.abc import Callable, Mapping from pathlib import Path -from typing import Any, Callable, Mapping, Optional +from typing import Any -import cv2 import numpy as np logger = logging.getLogger(__name__) -from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ -from orisys_app_shared.force.filters import FilterSettings, effective_filters from orisys_app_shared.acquisition.models import ( FixedCameraMode, SdkPacket, - ensure_bgr as _ensure_bgr, - is_file_source as _is_file_source, load_fixed_mode, orisys_importable, ) +from orisys_app_shared.acquisition.models import ( + ensure_bgr as _ensure_bgr, +) +from orisys_app_shared.acquisition.models import ( + is_file_source as _is_file_source, +) from orisys_app_shared.acquisition.sinks import RecordSinkMixin +from orisys_app_shared.force.filters import FilterSettings, effective_filters VIDEO_EOF_TIMEOUT_S = 0.5 #: How long a file source may deliver *nothing at all* before the session gives up. @@ -94,8 +95,8 @@ def _high_resolution_timer(): # definitions live in ``models`` so neither worker depends on the other. __all__ = [ "FixedCameraMode", - "SdkPacket", "SdkAcquisitionWorker", + "SdkPacket", "calibration_digest", "capture_status_of", "load_fixed_mode", @@ -127,7 +128,7 @@ def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> return False -def _capture_sequence(sensor) -> Optional[int]: +def _capture_sequence(sensor) -> int | None: """The capture source's delivered-frame counter, when it can mark a repeat. ``CaptureSource.read_latest`` returns the newest frame from its background @@ -148,7 +149,7 @@ def _capture_sequence(sensor) -> Optional[int]: return None -def _capture_dropped(sensor) -> Optional[int]: +def _capture_dropped(sensor) -> int | None: """Frames the capture source published that nobody took in time. The SDK's background reader counts one of these every time it replaces a @@ -252,7 +253,7 @@ def short_capture_fallacy( ) -def process_chain(sensor) -> tuple[Optional[np.ndarray], str]: +def process_chain(sensor) -> tuple[np.ndarray | None, str]: """Run the live processing chain for one already-read frame. ``get_img`` alone is not the pipeline: the SDK's depth map is produced by @@ -276,7 +277,7 @@ def process_chain(sensor) -> tuple[Optional[np.ndarray], str]: return read_depth(sensor), compute_error -def read_depth(sensor) -> Optional[np.ndarray]: +def read_depth(sensor) -> np.ndarray | None: """The current depth channel of ``sensor``, or ``None`` when unavailable.""" try: info = sensor.info @@ -455,7 +456,7 @@ def calibration_digest(path: str | Path) -> str: return "" -def loaded_path(sensor) -> Optional[str]: +def loaded_path(sensor) -> str | None: """Where the sensor's active calibration came from, if it knows.""" calibration = getattr(sensor, "calibration", None) if calibration is None: @@ -488,10 +489,10 @@ class SdkAcquisitionWorker(RecordSinkMixin): def __init__( self, *, - on_error: Optional[Callable[[str], None]] = None, - on_status: Optional[Callable[[str], None]] = None, - on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None, - on_reset_applied: Optional[Callable[[int, float, int], None]] = None, + on_error: Callable[[str], None] | None = None, + on_status: Callable[[str], None] | None = None, + on_calibration_applied: Callable[[str, str, int, float, int], None] | None = None, + on_reset_applied: Callable[[int, float, int], None] | None = None, ): self._init_sinks() self._on_error = on_error @@ -504,22 +505,22 @@ class SdkAcquisitionWorker(RecordSinkMixin): #: Called from the acquisition thread once a queued zeroing took effect, #: with ``(frame_id, mono_t, session_id)``. self._on_reset_applied = on_reset_applied - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._stop = threading.Event() #: Set while the operator has paused: the device stays open and the capture keeps #: publishing, but nothing is computed and no packet is emitted. self._pause = threading.Event() self._lock = threading.Lock() - self._latest: Optional[SdkPacket] = None + self._latest: SdkPacket | None = None self._session_id = 0 # Keyed by session so a session that is still winding down can only # ever close its own device, never the device of a newer session. self._sensors: dict[int, object] = {} - self._mode: Optional[FixedCameraMode] = None - self._video_source: Optional[str | int] = None - self._end_reason: Optional[str] = None + self._mode: FixedCameraMode | None = None + self._video_source: str | int | None = None + self._end_reason: str | None = None self._phase: str = "idle" - self._pending_calibration: Optional[str] = None + self._pending_calibration: str | None = None #: A zeroing requested from another thread, applied by this one. self._pending_reset: bool = False #: Monotonic origin for ``SdkPacket.mono_t``, reset per session. @@ -540,7 +541,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): #: reported rate is an echo of the request, so only this can be trusted. self._measured_capture_fps: float = 0.0 - def set_filter_settings(self, settings: Optional[FilterSettings]) -> None: + def set_filter_settings(self, settings: FilterSettings | None) -> None: """Choose the filters for the **next** session. Not applied to a running one on purpose: the filter controls are locked @@ -595,11 +596,11 @@ class SdkAcquisitionWorker(RecordSinkMixin): with self._lock: return self._phase - def latest(self) -> Optional[SdkPacket]: + def latest(self) -> SdkPacket | None: with self._lock: return self._latest - def consume_end_reason(self) -> Optional[str]: + def consume_end_reason(self) -> str | None: with self._lock: reason = self._end_reason self._end_reason = None @@ -671,7 +672,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): # Otherwise the thread settles itself on the way out. Leaving # ``self._thread`` set is what stops the next ``start`` from racing it. - def _settle(self, *, owner: Optional[threading.Thread]) -> None: + def _settle(self, *, owner: threading.Thread | None) -> None: """Release worker state once no thread owns the device any more.""" if owner is not None and self._thread is not owner: return # a newer session owns the worker now @@ -796,7 +797,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): if self._on_status is not None: self._on_status(message) - def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None: + def _run(self, session: int, previous: threading.Thread | None = None) -> None: # A new session always starts unpaused: pausing is a statement about the # session the operator is watching, not a sticky property of the device. self._pause.clear() @@ -816,7 +817,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._latest = None self._phase = "idle" - def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None: + def _run_session(self, session: int, previous: threading.Thread | None) -> None: import orisys if previous is not None and previous.is_alive(): @@ -874,10 +875,10 @@ class SdkAcquisitionWorker(RecordSinkMixin): frame_times: list[float] = [] saw_frame = False last_frame_mono = 0.0 - none_streak_start: Optional[float] = None + none_streak_start: float | None = None #: The capture sequence of the last frame that was actually *processed*: #: an unchanged counter means the same frame came back again. - last_delivered: Optional[int] = None + last_delivered: int | None = None #: When that frame arrived, and how far apart this device's frames are #: meant to be: the wait below never steps over the next frame's arrival. last_frame_at = 0.0 @@ -887,7 +888,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): #: two rates says whether a low frame rate is the camera's or ours. source_times: list[tuple[float, int]] = [] source_dropped = 0 - last_source_seq: Optional[int] = None + last_source_seq: int | None = None capture_fps = 0.0 frame_period = 1.0 / max(1.0, float(mode.fps or 30.0)) # Per-session monotonic frame index: the join key shared by the video, @@ -895,8 +896,8 @@ class SdkAcquisitionWorker(RecordSinkMixin): frame_id = 0 #: The last frame each derived array was *actually* produced for (see the #: finger worker: a stale array must name the frame it belongs to). - last_flow_frame_id: Optional[int] = None - last_depth_frame_id: Optional[int] = None + last_flow_frame_id: int | None = None + last_depth_frame_id: int | None = None #: When this session's loop started, for the file-source deadline below. session_started = time.perf_counter() diff --git a/shared/src/orisys_app_shared/acquisition/sinks.py b/shared/src/orisys_app_shared/acquisition/sinks.py index 1eef5b5..ff60529 100644 --- a/shared/src/orisys_app_shared/acquisition/sinks.py +++ b/shared/src/orisys_app_shared/acquisition/sinks.py @@ -15,7 +15,8 @@ from __future__ import annotations import logging import threading from collections import deque -from typing import Any, Callable, Iterable +from collections.abc import Callable, Iterable +from typing import Any import numpy as np diff --git a/shared/src/orisys_app_shared/camera/__init__.py b/shared/src/orisys_app_shared/camera/__init__.py index f9ee6b5..24a6cf0 100644 --- a/shared/src/orisys_app_shared/camera/__init__.py +++ b/shared/src/orisys_app_shared/camera/__init__.py @@ -1,6 +1,10 @@ """Camera discovery and raw preview helpers.""" -from orisys_app_shared.camera.discovery import CameraInfo, list_cameras, pick_default_camera +from orisys_app_shared.camera.discovery import ( + CameraInfo, + list_cameras, + pick_default_camera, +) from orisys_app_shared.camera.preview import RawPreviewSession __all__ = [ diff --git a/shared/src/orisys_app_shared/camera/discovery.py b/shared/src/orisys_app_shared/camera/discovery.py index 2bf54f2..1510970 100644 --- a/shared/src/orisys_app_shared/camera/discovery.py +++ b/shared/src/orisys_app_shared/camera/discovery.py @@ -114,7 +114,7 @@ def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]: backends = _backend_candidates() with _suppress_opencv_noise(): - for index in range(0, max_index + 1): + for index in range(max_index + 1): for backend_name, backend_api in backends: info = _try_open(index, backend_name, backend_api) if info is None: diff --git a/shared/src/orisys_app_shared/compat.py b/shared/src/orisys_app_shared/compat.py index 28ef559..9656384 100644 --- a/shared/src/orisys_app_shared/compat.py +++ b/shared/src/orisys_app_shared/compat.py @@ -4,7 +4,8 @@ from __future__ import annotations import logging -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 logger = logging.getLogger(__name__) diff --git a/shared/src/orisys_app_shared/force/__init__.py b/shared/src/orisys_app_shared/force/__init__.py index 6e05f60..65f2fc0 100644 --- a/shared/src/orisys_app_shared/force/__init__.py +++ b/shared/src/orisys_app_shared/force/__init__.py @@ -21,6 +21,11 @@ from orisys_app_shared.force.filters import ( settings_from_mapping, ) from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter +from orisys_app_shared.force.presentation import ( + DisplayForce, + ForcePresentation, + ZeroEstimate, +) from orisys_app_shared.force.session import ( NA, SelectionStats, @@ -30,11 +35,6 @@ from orisys_app_shared.force.session import ( summarise, ) from orisys_app_shared.force.units import DisplayForces, select_display_forces -from orisys_app_shared.force.presentation import ( - DisplayForce, - ForcePresentation, - ZeroEstimate, -) __all__ = [ "CHANNEL_KEYS", diff --git a/shared/src/orisys_app_shared/force/calibration_io.py b/shared/src/orisys_app_shared/force/calibration_io.py index e45e72b..d064df1 100644 --- a/shared/src/orisys_app_shared/force/calibration_io.py +++ b/shared/src/orisys_app_shared/force/calibration_io.py @@ -7,17 +7,26 @@ one format. ``CalSessionStore`` remains the UI sampling table. from __future__ import annotations import math +from collections.abc import Iterable from copy import deepcopy from dataclasses import asdict, dataclass, field, replace from datetime import datetime from pathlib import Path -from typing import Any, Iterable, Literal, Mapping +from typing import Any, Literal from orisys.calibration import ( CalSample as SdkCalSample, +) +from orisys.calibration import ( build as sdk_build, +) +from orisys.calibration import ( check as sdk_check, +) +from orisys.calibration import ( pipeline_from_sensor_json, +) +from orisys.calibration import ( write as sdk_write, ) @@ -76,20 +85,6 @@ def merge_samples(first: CalSample, second: CalSample) -> CalSample: ) -def build_export_model(samples: Mapping[str, list[CalSample]]): - """Build exactly the model that export and application would use. - - Diagnostics must be computed on *this* curve. A separate least-squares fit - can look healthy while the piecewise curve the device actually applies is - the one with the error. - """ - payload = { - key: [sample.to_sdk() for sample in (samples.get(key) or [])] - for key in CHANNEL_KEYS - } - return sdk_build(payload, origin="diagnostics") - - def predict_channel(cal, channel: str, raw: float) -> float: """Apply ``cal`` to one axis exactly as the runtime conversion does.""" triple = [0.0, 0.0, 0.0] @@ -424,7 +419,14 @@ def prepare_calibration( def build_export_model(samples: dict[str, list[CalSample]]): - """The SDK model for ``samples`` — the same preparation the panel uses.""" + """Build exactly the model that export and application would use. + + This is the panel's own preparation, so it is the curve the device will + actually apply: diagnostics are computed on *this* model, and a separate + least-squares fit can look healthy while the piecewise curve in force is what + carries the error. It also merges repeated loads, so a caller may hand in raw + (unmerged) samples and still get the exported model. + """ return prepare_from_samples(samples).model diff --git a/shared/src/orisys_app_shared/force/calibration_recorder.py b/shared/src/orisys_app_shared/force/calibration_recorder.py index 0aeda25..1bfbc0d 100644 --- a/shared/src/orisys_app_shared/force/calibration_recorder.py +++ b/shared/src/orisys_app_shared/force/calibration_recorder.py @@ -23,18 +23,16 @@ from __future__ import annotations import threading import time +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timezone -from typing import Callable, Optional import numpy as np from orisys_app_shared.force.models import ( CHANNEL_KEYS, - MAX_TRACE_POINTS, NA, SAMPLE_FIELDS, - STATUS_CANCELLED, STATUS_FEW_SAMPLES, STATUS_OK, AxisStats, @@ -48,11 +46,11 @@ from orisys_app_shared.force.session import SessionSample, summarise # Re-exported: the models live in ``force.models`` so the session file format can # describe a point without importing the sampling state machine. __all__ = [ + "SAMPLE_FIELDS", "AxisStats", "CalibrationPoint", "CalibrationRecorder", "FitResult", - "SAMPLE_FIELDS", "trace_dt", "trace_from", ] @@ -99,8 +97,8 @@ class CalibrationRecorder: def __init__( self, *, - on_finished: Optional[Callable[[CalibrationPoint], None]] = None, - on_cancelled: Optional[Callable[[], None]] = None, + on_finished: Callable[[CalibrationPoint], None] | None = None, + on_cancelled: Callable[[], None] | None = None, clock: Callable[[], float] = time.perf_counter, wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), ) -> None: @@ -211,7 +209,7 @@ class CalibrationRecorder: ) ) - def tick(self) -> Optional[CalibrationPoint]: + def tick(self) -> CalibrationPoint | None: """Finish on the UI thread once the deadline has passed.""" with self._lock: if not self._recording: @@ -221,7 +219,7 @@ class CalibrationRecorder: return None return self._finish() - def finish_now(self) -> Optional[CalibrationPoint]: + def finish_now(self) -> CalibrationPoint | None: """Stop early and keep whatever has been captured so far.""" with self._lock: if not self._recording: @@ -229,7 +227,7 @@ class CalibrationRecorder: return self._finish() # --------------------------------------------------------------- internals - def _finish(self) -> Optional[CalibrationPoint]: + def _finish(self) -> CalibrationPoint | None: with self._lock: samples = self._samples self._samples = [] @@ -289,7 +287,7 @@ class CalibrationRecorder: with self._lock: return list(self.points) - def _resolve(self, point: CalibrationPoint) -> Optional[CalibrationPoint]: + def _resolve(self, point: CalibrationPoint) -> CalibrationPoint | None: """Find the recorded point that is ``point``, by identity. Callers pass the object they are looking at. An integer row index would @@ -335,7 +333,7 @@ class CalibrationRecorder: self.points.remove(target) return True - def clear_points(self, channel: Optional[ChannelKey] = None) -> int: + def clear_points(self, channel: ChannelKey | None = None) -> int: """Drop every point, or every point of one channel. Returns the count.""" with self._lock: if channel is None: @@ -368,7 +366,7 @@ class CalibrationRecorder: with self._lock: return [point for point in self.points if point.channel == channel] - def prepared(self, *, meta: Optional[dict] = None): + def prepared(self, *, meta: dict | None = None): """The session prepared into one model plus its diagnostics. Everything that shows or writes a calibration goes through this, so the diff --git a/shared/src/orisys_app_shared/force/calibration_session.py b/shared/src/orisys_app_shared/force/calibration_session.py index ea5841b..8f40745 100644 --- a/shared/src/orisys_app_shared/force/calibration_session.py +++ b/shared/src/orisys_app_shared/force/calibration_session.py @@ -18,10 +18,12 @@ from __future__ import annotations import json import logging +import math +from collections.abc import Iterable from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Any, Iterable, Optional +from typing import Any from orisys_app_shared.force.models import ( SAMPLE_FIELDS, @@ -79,10 +81,19 @@ class CalibrationSessionRecord: return " / ".join(parts) +def _is_nan(value: Any) -> bool: + """Whether ``value`` is a NaN float. + + ``None`` is not one: a blank sample in a row is an absent reading, not a + number that failed, so it has to survive as it is. + """ + return isinstance(value, float) and math.isnan(value) + + def _jsonable(value: Any) -> Any: """NaN/Inf are not valid JSON; they are written as ``null``.""" if isinstance(value, float): - if value != value or value in (float("inf"), float("-inf")): + if math.isnan(value) or value in (float("inf"), float("-inf")): return None return value if isinstance(value, dict): @@ -95,8 +106,8 @@ def _jsonable(value: Any) -> Any: def build_session_document( points: Iterable, *, - meta: Optional[dict] = None, - created_at: Optional[str] = None, + meta: dict | None = None, + created_at: str | None = None, ) -> dict: """Build the document for ``points`` without touching the disk.""" documents = [] @@ -105,7 +116,7 @@ def build_session_document( # The full series is the audit data; the trace stays as a quick preview. entry["sample_fields"] = list(SAMPLE_FIELDS) entry["samples"] = [ - [None if value != value else value for value in row] + [None if _is_nan(value) else value for value in row] for row in getattr(point, "samples", ()) ] documents.append(entry) @@ -120,7 +131,7 @@ def build_session_document( } -def session_filename(sensor_type: str, *, stamp: Optional[str] = None) -> str: +def session_filename(sensor_type: str, *, stamp: str | None = None) -> str: when = stamp or datetime.now().strftime("%Y%m%d_%H%M%S") return f"calibration_session_{sensor_type or 'unknown'}_{when}.json" @@ -129,7 +140,7 @@ def write_calibration_session( path: str | Path, points: Iterable, *, - meta: Optional[dict] = None, + meta: dict | None = None, ) -> Path: """Write the session document. Returns the written path.""" target = Path(path) @@ -271,7 +282,7 @@ def restore_diagnostics(point) -> None: point.trace_dt_s = trace_dt(valid_block) if not point.duration_s and block.shape[0] > 1: span = float(block[-1, 1] - block[0, 1]) - if span == span and span >= 0.0: + if not math.isnan(span) and span >= 0.0: point.duration_s = span diff --git a/shared/src/orisys_app_shared/force/filters.py b/shared/src/orisys_app_shared/force/filters.py index b1df8cd..f4a0bb9 100644 --- a/shared/src/orisys_app_shared/force/filters.py +++ b/shared/src/orisys_app_shared/force/filters.py @@ -26,8 +26,9 @@ from __future__ import annotations import inspect import logging +from collections.abc import Iterable, Mapping from dataclasses import dataclass, replace -from typing import Any, Iterable, Mapping, Optional +from typing import Any import numpy as np @@ -159,10 +160,10 @@ class FilterSettings: show_unfiltered: bool = False #: The preset this came from, when it came from one (kept so a record can say #: "balanced" instead of only the numbers it expanded to). - preset: Optional[str] = None + preset: str | None = None # ------------------------------------------------------------- resolving - def normalised(self) -> "FilterSettings": + def normalised(self) -> FilterSettings: """Clamp to what the SDK can run, with unusable values meaning "off".""" alpha = _as_float(self.flow_ema_alpha, 1.0) if not 0.0 < alpha < 1.0: @@ -241,7 +242,7 @@ class FilterSettings: """A short line for the record, upstream stage first.""" return describe_state(self.as_state()) - def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]: + def nyquist_issue(self, sample_rate_hz: float | None) -> dict[str, float] | None: """The cutoff/Nyquist problem as numbers, for a UI to phrase itself. The SDK drops a low-pass whose cutoff is at or above Nyquist instead of @@ -262,7 +263,7 @@ class FilterSettings: "rate_hz": float(rate), } - def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]: + def problems(self, sample_rate_hz: float | None = None) -> tuple[str, ...]: """Why the device would not do what these settings ask (English, for logs).""" issue = self.nyquist_issue(sample_rate_hz) if issue is None: @@ -321,7 +322,7 @@ class FilterSettings: @classmethod def from_filters_state( cls, state: Mapping[str, Any], *, show_unfiltered: bool = False - ) -> "FilterSettings": + ) -> FilterSettings: """The settings a device reports as in effect. This is what the dialog displays and what a record stores: a config file @@ -342,7 +343,7 @@ class FilterSettings: preset=state.get("preset"), ).normalised() - def metadata(self, sensor_state: Optional[Mapping[str, Any]] = None) -> dict[str, Any]: + def metadata(self, sensor_state: Mapping[str, Any] | None = None) -> dict[str, Any]: """The ``filters`` block for ``session.json`` / a recording header. With a sensor state this records what the device was doing @@ -396,7 +397,7 @@ def describe_state(state: Mapping[str, Any]) -> str: return " + ".join(parts) if parts else "none" -def settings_from_mapping(data: Optional[Mapping[str, Any]]) -> FilterSettings: +def settings_from_mapping(data: Mapping[str, Any] | None) -> FilterSettings: """Build settings from a preferences/metadata mapping, with defaults.""" source = dict(data or {}) base = FilterSettings() @@ -442,12 +443,12 @@ class ForceFilterChain: ``zi``). Order is median then low-pass, so a spike cannot ring the IIR. """ - def __init__(self, settings: Optional[FilterSettings] = None) -> None: + def __init__(self, settings: FilterSettings | None = None) -> None: self._settings = (settings or FilterSettings()).normalised() self._median: list[list[float]] = [[] for _ in range(CHANNELS)] self._sos = None #: One IIR state per channel: the six readings are independent signals. - self._zi: Optional[list[np.ndarray]] = None + self._zi: list[np.ndarray] | None = None self._build() # ------------------------------------------------------------------ setup @@ -485,7 +486,7 @@ class ForceFilterChain: def active(self) -> bool: return self._settings.active() - def reset(self, first: Optional[Iterable[float]] = None) -> None: + def reset(self, first: Iterable[float] | None = None) -> None: """Drop the filter memory (a new session must not inherit the old one).""" self._median = [[] for _ in range(CHANNELS)] self._zi = None diff --git a/shared/src/orisys_app_shared/force/history.py b/shared/src/orisys_app_shared/force/history.py index 367a3ee..a69f3fa 100644 --- a/shared/src/orisys_app_shared/force/history.py +++ b/shared/src/orisys_app_shared/force/history.py @@ -8,7 +8,6 @@ from __future__ import annotations import math from dataclasses import dataclass -from typing import Optional import numpy as np @@ -97,8 +96,8 @@ class MetricHistory: (self._capacity, 1 + self.channels), np.nan, dtype=np.float64 ) self.sample_count = 0 - self._t0: Optional[float] = None - self._y_range_smooth: list[Optional[tuple[float, float]]] = [ + self._t0: float | None = None + self._y_range_smooth: list[tuple[float, float] | None] = [ None for _ in range(self.channels) ] @@ -176,7 +175,7 @@ class MetricHistory: pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01) return smoothed[0] - pad, smoothed[1] + pad - def state(self) -> Optional[PlotState]: + def state(self) -> PlotState | None: n = self.sample_count if n == 0: return None diff --git a/shared/src/orisys_app_shared/force/presentation.py b/shared/src/orisys_app_shared/force/presentation.py index d95f221..5671d15 100644 --- a/shared/src/orisys_app_shared/force/presentation.py +++ b/shared/src/orisys_app_shared/force/presentation.py @@ -33,8 +33,8 @@ contact present at session start stays visible instead of being zeroed away. from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass, field -from typing import Optional, Sequence import numpy as np @@ -55,7 +55,7 @@ DEFAULT_MIN_DEADBAND_N = 0.0 DEFAULT_PROVISIONAL_SAMPLES = 3 -def normalise_unit(unit: Optional[str]) -> str: +def normalise_unit(unit: str | None) -> str: """``"N"`` or ``"raw"`` — the two units a force reading can be shown in.""" return "N" if str(unit or "").upper().startswith("N") else "raw" @@ -88,16 +88,16 @@ class _UnitEstimate: collecting: bool = False samples: list[tuple[float, float, float]] = field(default_factory=list) sample_times: list[float] = field(default_factory=list) - t0: Optional[float] = None + t0: float | None = None #: Frozen estimate: set once the quiet window closes with quality ``ok``. - offsets: Optional[tuple[float, float, float]] = None - deadbands: Optional[tuple[float, float, float]] = None - estimate: Optional[ZeroEstimate] = None + offsets: tuple[float, float, float] | None = None + deadbands: tuple[float, float, float] | None = None + estimate: ZeroEstimate | None = None #: Provisional estimate, published from the first few quiet samples and #: re-fitted on each further one. It is what the display uses while the #: window is still open, and what it keeps if the window closes badly. - provisional_offsets: Optional[tuple[float, float, float]] = None - provisional_deadbands: Optional[tuple[float, float, float]] = None + provisional_offsets: tuple[float, float, float] | None = None + provisional_deadbands: tuple[float, float, float] | None = None provisional_samples: int = 0 #: A quiet sample was refused because a contact was present or the flow was #: too high: this window is not a trustworthy idle window any more. @@ -109,12 +109,12 @@ class _UnitEstimate: return self.offsets is not None and self.deadbands is not None @property - def display_offsets(self) -> Optional[tuple[float, float, float]]: + def display_offsets(self) -> tuple[float, float, float] | None: """What the display is centred on: frozen if there is one, else provisional.""" return self.offsets if self.offsets is not None else self.provisional_offsets @property - def display_deadbands(self) -> Optional[tuple[float, float, float]]: + def display_deadbands(self) -> tuple[float, float, float] | None: """The deadband the display is using (frozen if there is one).""" return ( self.deadbands if self.deadbands is not None else self.provisional_deadbands @@ -190,12 +190,12 @@ class ForcePresentation: """Operator-triggered restart of the idle window.""" self.reset_session(unit=self.unit) - def set_enabled(self, enabled: bool, *, unit: Optional[str] = None) -> None: + def set_enabled(self, enabled: bool, *, unit: str | None = None) -> None: """The master switch; switching it on restarts the quiet window.""" self.enabled = bool(enabled) self.reset_session(unit=self.unit if unit is None else unit) - def min_deadband_for(self, unit: Optional[str] = None) -> float: + def min_deadband_for(self, unit: str | None = None) -> float: """The floor under the estimated deadband, for one display unit.""" chosen = self.unit if unit is None else unit return ( @@ -279,7 +279,7 @@ class ForcePresentation: return "provisional" @property - def last_estimate(self) -> Optional[ZeroEstimate]: + def last_estimate(self) -> ZeroEstimate | None: """The window's outcome: the frozen estimate, the failed one, or the provisional estimate while the window is still open.""" state = self._states.get(self.unit) @@ -318,10 +318,10 @@ class ForcePresentation: *, t_rel_s: float, valid: bool = True, - flow_p95: Optional[float] = None, + flow_p95: float | None = None, contact_found: bool = False, - unit: Optional[str] = None, - ) -> Optional[ZeroEstimate]: + unit: str | None = None, + ) -> ZeroEstimate | None: """Feed one live sample into the startup estimator. ``values`` must already be expressed in ``unit`` (the displayed one). @@ -406,7 +406,7 @@ class ForcePresentation: state.provisional_samples = estimate.sample_count def fit_zero( - self, samples: Sequence[Sequence[float]], *, unit: Optional[str] = None + self, samples: Sequence[Sequence[float]], *, unit: str | None = None ) -> ZeroEstimate: """Estimate offsets and deadbands from quiet samples (does not apply them).""" floor = self.min_deadband_for(unit) @@ -456,7 +456,7 @@ class ForcePresentation: ) def apply_estimate( - self, estimate: ZeroEstimate, *, unit: Optional[str] = None + self, estimate: ZeroEstimate, *, unit: str | None = None ) -> None: """Explicitly freeze an estimate (e.g. after user confirmation).""" if estimate.quality != "ok": @@ -472,7 +472,7 @@ class ForcePresentation: self, values: Sequence[float], *, - unit: Optional[str] = None, + unit: str | None = None, available: tuple[bool, bool, bool] = (True, True, True), ) -> DisplayForce: """Return a display copy of one live sample; never mutates ``values``. @@ -530,7 +530,7 @@ class ForcePresentation: ) def present( - self, values, *, unit: Optional[str] = None + self, values, *, unit: str | None = None ) -> tuple[np.ndarray, np.ndarray]: """Present a whole block of readings: ``(display copy, suppressed mask)``. @@ -568,7 +568,7 @@ class ForcePresentation: suppressed[finite, axis] = quiet return block, suppressed - def format_force(self, value: float, unit: Optional[str] = None) -> str: + def format_force(self, value: float, unit: str | None = None) -> str: """Stable digit formatting for value cards.""" unit = normalise_unit(self.unit if unit is None else unit) if not np.isfinite(value): diff --git a/shared/src/orisys_app_shared/force/session.py b/shared/src/orisys_app_shared/force/session.py index 726cece..4d69274 100644 --- a/shared/src/orisys_app_shared/force/session.py +++ b/shared/src/orisys_app_shared/force/session.py @@ -24,8 +24,8 @@ Design notes from __future__ import annotations import threading +from collections.abc import Iterable, Iterator from dataclasses import dataclass, replace -from typing import Iterable, Iterator, Optional import numpy as np @@ -71,7 +71,7 @@ class SessionSample: calibrated_axes: tuple[str, ...] = () @classmethod - def from_record(cls, record) -> "SessionSample": + def from_record(cls, record) -> SessionSample: """Build from the canonical :class:`RecordSample`. The record channel has one conversion from a packet (see @@ -221,8 +221,8 @@ class SessionStore: self._dropped = 0 self._session_id = 0 self._calibration_id = "" - self._t0: Optional[float] = None - self._last_t: Optional[float] = None + self._t0: float | None = None + self._last_t: float | None = None self._invalid = 0 # ------------------------------------------------------------- lifecycle @@ -421,7 +421,7 @@ class SessionStore: return np.empty((0, _COLUMNS), dtype=np.float64) return np.vstack(selected) if len(selected) > 1 else selected[0] - def statistics(self, t0: float, t1: float, unit: str = "raw") -> "SelectionStats": + def statistics(self, t0: float, t1: float, unit: str = "raw") -> SelectionStats: """Duration, peak, mean and std over a selection, per channel.""" t0 = float(t0) t1 = float(t1) diff --git a/shared/src/orisys_app_shared/force/units.py b/shared/src/orisys_app_shared/force/units.py index 8f26eb1..f0835fc 100644 --- a/shared/src/orisys_app_shared/force/units.py +++ b/shared/src/orisys_app_shared/force/units.py @@ -11,6 +11,7 @@ reported as such. from __future__ import annotations +import math from dataclasses import dataclass from orisys_app_shared.record.sample import AXES @@ -48,7 +49,7 @@ class DisplayForces: def _finite(value: float) -> bool: try: - return value == value and value not in (float("inf"), float("-inf")) + return not math.isnan(value) and value not in (float("inf"), float("-inf")) except TypeError: # pragma: no cover - defensive return False diff --git a/shared/src/orisys_app_shared/logging/qt_handler.py b/shared/src/orisys_app_shared/logging/qt_handler.py index ceee7c7..956bcaa 100644 --- a/shared/src/orisys_app_shared/logging/qt_handler.py +++ b/shared/src/orisys_app_shared/logging/qt_handler.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Callable +from collections.abc import Callable class QtLogHandler(logging.Handler): diff --git a/shared/src/orisys_app_shared/record/__init__.py b/shared/src/orisys_app_shared/record/__init__.py index 5516eb2..6970b73 100644 --- a/shared/src/orisys_app_shared/record/__init__.py +++ b/shared/src/orisys_app_shared/record/__init__.py @@ -24,9 +24,15 @@ from orisys_app_shared.record.playback import ( ) from orisys_app_shared.record.visualization3d import ( BLOCK_VERSION as VISUALIZATION3D_BLOCK_VERSION, +) +from orisys_app_shared.record.visualization3d import ( VISUALIZATION3D_KEY, Visualization3DRecord, +) +from orisys_app_shared.record.visualization3d import ( build_block as build_visualization3d_block, +) +from orisys_app_shared.record.visualization3d import ( read_block as read_visualization3d_block, ) diff --git a/shared/src/orisys_app_shared/record/experiment.py b/shared/src/orisys_app_shared/record/experiment.py index 24d5937..043b41d 100644 --- a/shared/src/orisys_app_shared/record/experiment.py +++ b/shared/src/orisys_app_shared/record/experiment.py @@ -33,10 +33,10 @@ import threading import time from collections import deque from dataclasses import dataclass, field, replace -from uuid import uuid4 from datetime import datetime, timezone from pathlib import Path -from typing import Any, Optional +from typing import Any +from uuid import uuid4 import cv2 import numpy as np @@ -198,7 +198,7 @@ class RecordStats: class RecordReport: """Returned by :meth:`ExperimentRecorder.stop`.""" - directory: Optional[Path] + directory: Path | None stats: RecordStats problems: list[str] = field(default_factory=list) #: False when the writer had not finished closing the files. @@ -217,7 +217,7 @@ class RecordReport: class ExperimentRecorder: """Writes one experiment directory. Sink-compatible with the workers.""" - def __init__(self, config: Optional[RecordConfig] = None) -> None: + def __init__(self, config: RecordConfig | None = None) -> None: self.config = config or RecordConfig() #: One ordered stream for rows and events. It is **unbounded** on count: #: the producer checks ``ROW_BACKLOG``/``max_queue`` itself, so a slow @@ -229,7 +229,7 @@ class ExperimentRecorder: self._gaps: deque = deque(maxlen=GAP_BACKLOG) self._queued_bytes = 0 self._seq = 0 - self._thread: Optional[threading.Thread] = None + self._thread: threading.Thread | None = None self._stop = threading.Event() self._lock = threading.Lock() #: Serialises session.json writes: the UI thread writes at start and the @@ -237,26 +237,26 @@ class ExperimentRecorder: self._json_lock = threading.Lock() #: Explicit lifecycle. A boolean could not express "the writer died". self._state: str = STATE_IDLE - self._directory: Optional[Path] = None + self._directory: Path | None = None self._stats = RecordStats() self._session_id = 0 self._calibration_id = "" self._calibration_version = "" self._metadata: dict[str, Any] = {} self._events: list[dict] = [] - self._t0: Optional[float] = None - self._last_t: Optional[float] = None + self._t0: float | None = None + self._last_t: float | None = None #: When the next video frame is due, in the same acquisition clock. - self._next_video_t: Optional[float] = None + self._next_video_t: float | None = None self._started_at = "" self._consecutive_errors = 0 self._on_alarm = None - self._report: Optional[RecordReport] = None + self._report: RecordReport | None = None self._accepting = False #: Outcome of the pre-roll write, published for session.json. self._preroll: list = [] self._preroll_frames = 0 - self._preroll_segment: Optional[str] = None + self._preroll_segment: str | None = None #: Problems the writer found while closing, carried into the report. self._writer_problems: list[str] = [] #: Coefficient files stored inside the recording directory. @@ -281,7 +281,7 @@ class ExperimentRecorder: return self._state in (STATE_STARTING, STATE_RECORDING) @property - def report(self) -> Optional[RecordReport]: + def report(self) -> RecordReport | None: """The terminal report, available once the writer has finished.""" return self._report @@ -300,7 +300,7 @@ class ExperimentRecorder: return self._accepting and self._state in (STATE_STARTING, STATE_RECORDING) @property - def directory(self) -> Optional[Path]: + def directory(self) -> Path | None: return self._directory def stats(self) -> RecordStats: @@ -329,7 +329,7 @@ class ExperimentRecorder: root: Path, session_id: int, metadata: dict[str, Any], - config: Optional[RecordConfig] = None, + config: RecordConfig | None = None, calibration_id: str = "", calibration_version: str = "", preroll=(), @@ -461,7 +461,7 @@ class ExperimentRecorder: version: str, name: str, frame_id: int = -1, - mono_t: Optional[float] = None, + mono_t: float | None = None, ) -> None: """Remember a coefficient file stored inside the recording directory.""" entry = { @@ -480,9 +480,9 @@ class ExperimentRecorder: kind: str, detail: str = "", *, - mono_t: Optional[float] = None, + mono_t: float | None = None, frame_id: int = -1, - wall_at: Optional[str] = None, + wall_at: str | None = None, ) -> None: """Record an event, timestamped when it actually happened. @@ -704,7 +704,7 @@ class ExperimentRecorder: inside the guard for that reason: a full disk used to kill this thread before any statistics or alarm existed. """ - writer: Optional[_SessionWriter] = None + writer: _SessionWriter | None = None failure = "" try: writer = _SessionWriter( @@ -1022,9 +1022,9 @@ class _SessionWriter: self.bytes_written = 0 #: Frames captured before recording started, written as their own file. self.preroll_frames = 0 - self.preroll_segment: Optional[str] = None + self.preroll_segment: str | None = None self._last_flush = time.perf_counter() - self._t0: Optional[float] = None + self._t0: float | None = None self._problems: list[str] = [] #: The video side, in its own component: one rolling segment at a time. self._video = VideoSegments( diff --git a/shared/src/orisys_app_shared/record/index.py b/shared/src/orisys_app_shared/record/index.py index e42a226..cf89c73 100644 --- a/shared/src/orisys_app_shared/record/index.py +++ b/shared/src/orisys_app_shared/record/index.py @@ -20,9 +20,9 @@ from __future__ import annotations import csv import logging from array import array -from dataclasses import dataclass, field +from collections.abc import Iterator +from dataclasses import dataclass from pathlib import Path -from typing import Iterable, Iterator, Optional import numpy as np @@ -82,7 +82,7 @@ class ForceWindow: mask = plottable_axis_mask(values, self.valid)[:, index] return self.times[mask], values[mask, index] - def statistics(self, unit: str) -> "WindowStats": + def statistics(self, unit: str) -> WindowStats: values = self.value_for_unit(unit) mask = np.isfinite(values) & self.valid[:, None] peaks: list[float] = [] @@ -172,7 +172,7 @@ class RecordingIndex: """Size of the data file the index describes.""" return self._byte_size - def build(self) -> "RecordingIndex": + def build(self) -> RecordingIndex: """One streaming pass: times, flags, frame ids and byte offsets.""" path = self.path times: list[float] = [] @@ -240,7 +240,7 @@ class RecordingIndex: except (KeyError, IndexError, ValueError, StopIteration, csv.Error): return None - def ensure_built(self) -> "RecordingIndex": + def ensure_built(self) -> RecordingIndex: if not self._built: self.build() return self @@ -361,7 +361,7 @@ class RecordingIndex: decimated=bucket > 1, ) - def iter_rows(self, start: int = 0, stop: Optional[int] = None) -> Iterator[tuple]: + def iter_rows(self, start: int = 0, stop: int | None = None) -> Iterator[tuple]: """Yield logical rows in stable time order, seeking only the selection.""" self.ensure_built() start = max(0, int(start)) @@ -457,13 +457,17 @@ class RecordingIndex: values = newtons if newton else raw for axis in range(3): value = values[axis] - if value != value: # NaN + if np.isnan(value): continue current_low = lows[bucket, axis] current_high = highs[bucket, axis] - lows[bucket, axis] = value if current_low != current_low else min(current_low, value) - highs[bucket, axis] = value if current_high != current_high else max(current_high, value) - if starts[bucket] != starts[bucket]: + lows[bucket, axis] = ( + value if np.isnan(current_low) else min(current_low, value) + ) + highs[bucket, axis] = ( + value if np.isnan(current_high) else max(current_high, value) + ) + if np.isnan(starts[bucket]): starts[bucket] = t_rel counts[bucket] += 1 return Overview( diff --git a/shared/src/orisys_app_shared/record/playback.py b/shared/src/orisys_app_shared/record/playback.py index 7e81375..4f8b8b7 100644 --- a/shared/src/orisys_app_shared/record/playback.py +++ b/shared/src/orisys_app_shared/record/playback.py @@ -16,9 +16,9 @@ import csv import json import logging import threading -from dataclasses import dataclass, field +from collections.abc import Iterator +from dataclasses import dataclass from pathlib import Path -from typing import Iterator, Optional import numpy as np @@ -140,8 +140,8 @@ class RecordingReader: self.directory = Path(directory) if not self.directory.is_dir(): raise FileNotFoundError(f"Not a recording directory: {self.directory}") - self._info: Optional[RecordingInfo] = None - self._events: Optional[list[dict]] = None + self._info: RecordingInfo | None = None + self._events: list[dict] | None = None self.max_rows = int(max_rows or self.DEFAULT_MAX_ROWS) self._index = RecordingIndex(self.directory, checkpoint_rows=checkpoint_rows) self._overviews: dict[tuple[str, int], Overview] = {} @@ -259,7 +259,7 @@ class RecordingReader: window.frame_ids, index.dropped_rows, index.ordered) # ------------------------------------------------------------------ queries - def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]: + def frame_at(self, t_rel_s: float) -> RecordingFrame | None: """The recorded frame at or just before ``t_rel_s``. The index is sorted at load time, so the binary search is valid; the @@ -277,7 +277,7 @@ class RecordingReader: index = max(0, min(index, len(frames) - 1)) return frames[index] - def segment_path(self, segment: int) -> Optional[Path]: + def segment_path(self, segment: int) -> Path | None: if segment <= 0: return None path = self.directory / f"raw_{segment:04d}.mp4" @@ -312,7 +312,7 @@ class RecordingReader: found.append(frame_id) return found - def last_reset_frame(self, at_or_before: int) -> Optional[int]: + def last_reset_frame(self, at_or_before: int) -> int | None: """The most recent reset at or before a frame — a tracker state origin. ``Sensor.reset`` clears the optical-flow tracker and the force filters, so @@ -328,13 +328,13 @@ class RecordingReader: ] return max(candidates) if candidates else None - def frame_of(self, frame_id: int) -> Optional[RecordingFrame]: + def frame_of(self, frame_id: int) -> RecordingFrame | None: for frame in self.info().frames: if frame.frame_id == int(frame_id): return frame return None - def segment_start_frame(self, segment: int) -> Optional[RecordingFrame]: + def segment_start_frame(self, segment: int) -> RecordingFrame | None: """The first indexed frame that lives in ``segment``.""" rows = [frame for frame in self.info().frames if frame.segment == int(segment)] if not rows: @@ -344,7 +344,7 @@ class RecordingReader: def preroll_frames(self) -> int: return int(self.info().session.get("preroll_frames") or 0) - def preroll_segment(self) -> Optional[Path]: + def preroll_segment(self) -> Path | None: """The pre-roll video recorded just before this recording started.""" name = str(self.info().session.get("preroll_segment") or "") if not name: @@ -643,7 +643,7 @@ class VideoOnlySource: self._index = -1 self._lock = threading.Lock() - def video_frame_at(self, t_rel_s: float) -> tuple[Optional[np.ndarray], None]: + def video_frame_at(self, t_rel_s: float) -> tuple[np.ndarray | None, None]: """The frame at ``t_rel_s``: ``(bgr, None)`` or ``(None, None)``. The second element is always ``None``: there is no frame *record* to hand back, diff --git a/shared/src/orisys_app_shared/record/sample.py b/shared/src/orisys_app_shared/record/sample.py index fb1a252..7848f82 100644 --- a/shared/src/orisys_app_shared/record/sample.py +++ b/shared/src/orisys_app_shared/record/sample.py @@ -25,8 +25,12 @@ The rules are therefore: from __future__ import annotations import math +from collections.abc import Iterable from dataclasses import dataclass -from typing import Iterable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # numpy is imported lazily, by the one function that needs it + import numpy as np AXES: tuple[str, ...] = ("fn", "fx", "fy") @@ -221,7 +225,7 @@ def plottable_mask(values: Iterable[float], valid: Iterable[bool]) -> list[bool] ] -def plottable_axis_mask(values, valid) -> "np.ndarray": +def plottable_axis_mask(values, valid) -> np.ndarray: """``(n, axes)`` mask: one column per axis, decided for that axis alone. The rule has to be per axis. A single-axis calibration is legitimate — the diff --git a/shared/src/orisys_app_shared/record/video_writer.py b/shared/src/orisys_app_shared/record/video_writer.py index c7b85d2..59dcd1c 100644 --- a/shared/src/orisys_app_shared/record/video_writer.py +++ b/shared/src/orisys_app_shared/record/video_writer.py @@ -10,7 +10,6 @@ from __future__ import annotations import logging import time from pathlib import Path -from typing import Optional import cv2 @@ -27,8 +26,8 @@ class VideoSegments: self.segment_seconds = float(segment_seconds) self.segments = 0 self.video_frames = 0 - self.frame_shape: Optional[tuple[int, int]] = None - self._writer: Optional[cv2.VideoWriter] = None + self.frame_shape: tuple[int, int] | None = None + self._writer: cv2.VideoWriter | None = None self._index = 0 self._frames_in_segment = 0 self._started = 0.0 diff --git a/shared/src/orisys_app_shared/record/visualization3d.py b/shared/src/orisys_app_shared/record/visualization3d.py index ae13f61..858d62b 100644 --- a/shared/src/orisys_app_shared/record/visualization3d.py +++ b/shared/src/orisys_app_shared/record/visualization3d.py @@ -27,8 +27,9 @@ timestamps and which keeps "what was configured" and "what happened" apart. from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any, Mapping, Optional +from typing import Any #: The key under which the block lives in ``session.json``. VISUALIZATION3D_KEY = "visualization3d" @@ -42,10 +43,10 @@ def build_block( enabled: bool, available: bool, reason: str = "", - scene: Optional[Mapping[str, Any]] = None, - gl: Optional[Mapping[str, Any]] = None, - display: Optional[Mapping[str, Any]] = None, - stream: Optional[Mapping[str, Any]] = None, + scene: Mapping[str, Any] | None = None, + gl: Mapping[str, Any] | None = None, + display: Mapping[str, Any] | None = None, + stream: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """One block for ``session.json``. Plain JSON types only, no failures.""" return { @@ -62,7 +63,7 @@ def build_block( } -def _clean(value: Optional[Mapping[str, Any]]) -> Optional[dict[str, Any]]: +def _clean(value: Mapping[str, Any] | None) -> dict[str, Any] | None: """Drop nothing silently: nulls stay nulls, and unknown types become text.""" if value is None: return None @@ -109,7 +110,7 @@ class Visualization3DRecord: ) not in ("", "display_only") -def read_block(session: Optional[Mapping[str, Any]]) -> Visualization3DRecord: +def read_block(session: Mapping[str, Any] | None) -> Visualization3DRecord: """Read the block out of a ``session.json`` mapping, tolerantly. Never raises: a broken recording is still a recording, and a consumer asking diff --git a/shared/src/orisys_app_shared/scene3d/__init__.py b/shared/src/orisys_app_shared/scene3d/__init__.py index f001097..82e0ae7 100644 --- a/shared/src/orisys_app_shared/scene3d/__init__.py +++ b/shared/src/orisys_app_shared/scene3d/__init__.py @@ -12,8 +12,12 @@ their stage lands. from orisys_app_shared.scene3d.contracts import ( DIAGNOSTIC_SEVERITY, + F32, + F64, + I32, LENGTH_UNIT_M, SENSOR_FRAME, + U32, UNAVAILABLE_DESCRIPTOR, ArrowInstances, CameraMatrices, @@ -25,12 +29,9 @@ from orisys_app_shared.scene3d.contracts import ( DisplayField, DisplaySettings, ExpectedGeometry, - F32, - F64, FieldDescriptor, FrameIdentity, GLCapabilities, - I32, LatestFrameMailbox, Mask, Material, @@ -45,8 +46,6 @@ from orisys_app_shared.scene3d.contracts import ( SurfaceFieldFrame, SurfaceMapping, ThreeDError, - U32, - diagnostic, error, freeze, @@ -63,8 +62,12 @@ from orisys_app_shared.scene3d.contracts import ( __all__ = [ "DIAGNOSTIC_SEVERITY", + "F32", + "F64", + "I32", "LENGTH_UNIT_M", "SENSOR_FRAME", + "U32", "UNAVAILABLE_DESCRIPTOR", "ArrowInstances", "CameraMatrices", @@ -76,12 +79,9 @@ __all__ = [ "DisplayField", "DisplaySettings", "ExpectedGeometry", - "F32", - "F64", "FieldDescriptor", "FrameIdentity", "GLCapabilities", - "I32", "LatestFrameMailbox", "Mask", "Material", @@ -96,7 +96,6 @@ __all__ = [ "SurfaceFieldFrame", "SurfaceMapping", "ThreeDError", - "U32", "diagnostic", "error", "freeze", diff --git a/shared/src/orisys_app_shared/scene3d/assets.py b/shared/src/orisys_app_shared/scene3d/assets.py index f4743ba..37839b2 100644 --- a/shared/src/orisys_app_shared/scene3d/assets.py +++ b/shared/src/orisys_app_shared/scene3d/assets.py @@ -20,7 +20,7 @@ from __future__ import annotations from dataclasses import replace from pathlib import Path -from typing import Any, Optional +from typing import Any from orisys_app_shared.scene3d.contracts import ( CompileOptions, @@ -38,8 +38,8 @@ from orisys_app_shared.scene3d.package import ( ENVELOPE_VERSION, MAGIC, SCHEMA_VERSION, - PackagedAssetLoader, PackageContents, + PackagedAssetLoader, build_manifest, describe_package, read_package, @@ -53,8 +53,8 @@ __all__ = [ "MAGIC", "SCHEMA_VERSION", "LegacyFingerImporter", - "PackagedAssetLoader", "PackageContents", + "PackagedAssetLoader", "SceneCompiler", "build_manifest", "describe_package", @@ -78,7 +78,7 @@ class SceneCompiler: def compile( self, surface: SceneAssets, - housing_glb: Optional[Path], + housing_glb: Path | None, output: Path, options: CompileOptions, ) -> CompileReport: @@ -156,10 +156,10 @@ def load_or_import( path: Path, *, expected: ExpectedGeometry, - keys: Optional[KeyProvider] = None, - legacy_map: Optional[Path] = None, + keys: KeyProvider | None = None, + legacy_map: Path | None = None, transform_source_from_sensor: Any = None, - meters_per_source_unit: Optional[float] = None, + meters_per_source_unit: float | None = None, ) -> SceneAssets: """Load a package, or import a legacy directory, behind one entry point. diff --git a/shared/src/orisys_app_shared/scene3d/composer.py b/shared/src/orisys_app_shared/scene3d/composer.py index 11ad862..82460ec 100644 --- a/shared/src/orisys_app_shared/scene3d/composer.py +++ b/shared/src/orisys_app_shared/scene3d/composer.py @@ -29,7 +29,6 @@ from §3.2 of the design document remain its reason to exist: from __future__ import annotations from dataclasses import dataclass -from typing import Optional import numpy as np @@ -38,7 +37,6 @@ from orisys_app_shared.scene3d.contracts import ( DisplayField, DisplaySettings, FrameIdentity, - Quality, RenderFrame, SceneAssets, SurfaceFieldFrame, @@ -92,7 +90,7 @@ class SceneComposer: #: every frame was pure cost (measured as the bulk of a 20k-vertex compose). self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {} #: Last UV grid of "arrow was on" samples for flow hysteresis. - self._flow_previously_on: Optional[np.ndarray] = None + self._flow_previously_on: np.ndarray | None = None #: The resolved ``#RRGGBB,...`` ramp both views build their lookup table from. #: Set by the view when the theme changes; empty means this build's default. self.ramp = str(ramp) diff --git a/shared/src/orisys_app_shared/scene3d/contracts.py b/shared/src/orisys_app_shared/scene3d/contracts.py index 48b0ddc..4a759ad 100644 --- a/shared/src/orisys_app_shared/scene3d/contracts.py +++ b/shared/src/orisys_app_shared/scene3d/contracts.py @@ -19,9 +19,10 @@ Two rules from the design document are enforced here rather than trusted: from __future__ import annotations import threading +from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Generic, Iterable, Literal, Mapping, Optional, Protocol, TypeVar +from typing import Any, Generic, Literal, Protocol, TypeVar import numpy as np from numpy.typing import NDArray @@ -149,7 +150,7 @@ class FrameIdentity: generation: int request_id: int = 0 - def is_from(self, other: "FrameIdentity") -> bool: + def is_from(self, other: FrameIdentity) -> bool: return ( self.source_id == other.source_id and self.session_id == other.session_id @@ -165,7 +166,7 @@ class FieldDescriptor: unit: str quality: Quality signed: bool - calibration_id: Optional[str] = None + calibration_id: str | None = None #: The descriptor a reference frame carries: no measurement is claimed at all. @@ -190,20 +191,20 @@ class SurfaceFieldFrame: scalar: F32 # [H,W] scalar_descriptor: FieldDescriptor valid: Mask # [H,W] - displacement_sensor_m: Optional[F32] = None # [H,W,3], before display gain - displacement_quality: Optional[Quality] = None - diagnostic: Optional[Diagnostic] = None + displacement_sensor_m: F32 | None = None # [H,W,3], before display gain + displacement_quality: Quality | None = None + diagnostic: Diagnostic | None = None #: The acquisition frame the scalar array was actually computed for, when the #: producer can say so. ``None`` means "unstated" (the frame's own identity is the #: only claim). When it is older than ``identity.frame_id`` the array is a stale #: measurement: a consumer must not colour it as the current instant. - computed_frame_id: Optional[int] = None + computed_frame_id: int | None = None #: Optional flow field kept alongside a depth scalar so overlays can draw arrows #: without forcing the colour channel to switch away from depth. - flow_uv: Optional[F32] = None # [H,W,2] - flow_valid_mask: Optional[Mask] = None # [H,W] + flow_uv: F32 | None = None # [H,W,2] + flow_valid_mask: Mask | None = None # [H,W] #: Contact centre in the same UV domain as ``scalar``; absent on older packets. - contact_uv_xy: Optional[tuple[float, float]] = None + contact_uv_xy: tuple[float, float] | None = None contact_found: bool = False contact_valid: bool = False #: Normal-force reading used only for the contact-arrow length mapping. @@ -228,7 +229,7 @@ class ArrowInstances: colors: F32 # [M,4], RGBA 0–1 kind: ArrowKind #: Optional per-arrow thickness scale relative to a template (contact arrows). - thickness_scales: Optional[F32] = None # [M] + thickness_scales: F32 | None = None # [M] @dataclass(frozen=True) @@ -343,7 +344,7 @@ class PlatformLock: os_family: tuple[str, ...] = () machine: tuple[str, ...] = () - gl_profile: Optional[Profile] = None + gl_profile: Profile | None = None allowed_vendors: tuple[str, ...] = () allowed_renderers: tuple[str, ...] = () forbid_software_renderer: bool = True @@ -380,7 +381,7 @@ class PlatformLock: return out @classmethod - def from_dict(cls, data: Optional[Mapping[str, Any]]) -> Optional["PlatformLock"]: + def from_dict(cls, data: Mapping[str, Any] | None) -> PlatformLock | None: """Read one back. A malformed lock is an error, not a silently open one.""" if data is None: return None @@ -427,14 +428,14 @@ class PlatformInfo: os_family: str machine: str - gl_profile: Optional[Profile] = None + gl_profile: Profile | None = None vendor: str = "" renderer: str = "" software_renderer: bool = False max_texture_size: int = 0 @classmethod - def here(cls, capabilities: Optional[GLCapabilities] = None) -> "PlatformInfo": + def here(cls, capabilities: GLCapabilities | None = None) -> PlatformInfo: import platform as _platform family = _platform.system().lower() @@ -454,7 +455,7 @@ class PlatformInfo: def check_platform_lock( - lock: Optional[PlatformLock], info: PlatformInfo + lock: PlatformLock | None, info: PlatformInfo ) -> tuple[Diagnostic, ...]: """Every way this machine violates the lock. Empty means "allowed". @@ -548,7 +549,7 @@ class SceneAssets: #: the map back to the artist's file is many-to-one. Kept because "which #: triangle is this?" is a question an operator will ask, and re-deriving it #: from UVs alone is a guess. - source_vertex_of_render: Optional[I32] = None + source_vertex_of_render: I32 | None = None #: Unit-free identity of the source geometry this asset was built from, and what the #: package says it is compatible with. The first is what a device can verify about #: the surface; the second catches a package released for another sensor. @@ -557,12 +558,12 @@ class SceneAssets: compatible_hardware_revisions: tuple[str, ...] = () #: Where this package may be displayed (stage 5). ``None`` means "not locked", #: which is what every package written before the lock existed carries. - platform_lock: Optional[PlatformLock] = None + platform_lock: PlatformLock | None = None #: UV→scene positions/normals for overlays. Optional so older packages degrade #: to scalar-only rendering without failing to load. - surface_mapping: Optional[SurfaceMapping] = None + surface_mapping: SurfaceMapping | None = None - def part(self, part_id: str) -> Optional[MeshPart]: + def part(self, part_id: str) -> MeshPart | None: for item in self.parts: if item.part_id == part_id: return item @@ -600,7 +601,7 @@ class SceneCapabilities: unit: str = "" #: Whether the source can ever publish ``displacement_sensor_m``. displacement_available: bool = False - displacement_quality: Optional[Quality] = None + displacement_quality: Quality | None = None source_kind: str = "live" #: Whether flow / contact overlays can be built for this source. flow_arrows_available: bool = False @@ -613,7 +614,7 @@ class DisplaySettings: display_gain: float = 0.0 deform_enabled: bool = False - color_scale: Optional[ColorScale] = None + color_scale: ColorScale | None = None #: Non-sensing parts (the housing/frame) start hidden: the measurement is what the pane is #: for, and on the finger sensor the frame is ~33% wider than the sensing surface, so it #: dominated the picture. Operators who want it for a mechanical check turn it on in the 3D @@ -689,7 +690,7 @@ class RenderFrame: diagnostics: tuple[Diagnostic, ...] = () #: The acquisition frame the coloured values belong to, carried through from the #: field frame so the *display* side can re-check it before presenting. - computed_frame_id: Optional[int] = None + computed_frame_id: int | None = None #: Whether ``positions_sensor_m``/``normals_sensor`` differ from the asset's own #: rest geometry. ``False`` for every path the application can take today (no #: adapter publishes a displacement), and the GPU then uploads the vertex buffer @@ -699,8 +700,8 @@ class RenderFrame: #: rather than read from the composer's shared counter, which the next task rewrites. compose_ms: float = 0.0 #: Optional overlays; absent on older packages / frames without mapping. - flow_arrows: Optional[ArrowInstances] = None - contact_arrow: Optional[ArrowInstances] = None + flow_arrows: ArrowInstances | None = None + contact_arrow: ArrowInstances | None = None @dataclass(frozen=True) @@ -758,13 +759,13 @@ class Sealing: #: Which content key to use, and where the key comes from. ``None`` = plain. key_id: str = "" - provider: Optional["KeyProvider"] = None + provider: KeyProvider | None = None #: Build-time only. The private key never reaches the runtime path. - signing_key_pem: Optional[bytes] = None + signing_key_pem: bytes | None = None signing_key_reference: str = "" #: Optional: the writer's own public key, so a build can prove the package it just #: wrote opens again instead of discovering the problem at the customer. - verify_key_pem: Optional[bytes] = None + verify_key_pem: bytes | None = None def encrypts(self) -> bool: return bool(self.key_id) and self.provider is not None @@ -782,12 +783,12 @@ class CompileOptions: max_surface_vertices: int max_housing_triangles: int signing_key_reference: str - encryption_key_id: Optional[str] = None + encryption_key_id: str | None = None #: Where the released package may be displayed. ``None`` leaves it unlocked, which #: is the right default for an engineering build and the wrong one for a release. - platform_lock: Optional[PlatformLock] = None + platform_lock: PlatformLock | None = None #: The resolved key material for signing and sealing (see :class:`Sealing`). - sealing: Optional[Sealing] = None + sealing: Sealing | None = None @dataclass(frozen=True) @@ -1074,7 +1075,7 @@ def stale_housing_shading(assets: SceneAssets) -> tuple[Diagnostic, ...]: def validate_assets( - assets: SceneAssets, expected: Optional[ExpectedGeometry] = None + assets: SceneAssets, expected: ExpectedGeometry | None = None ) -> tuple[Diagnostic, ...]: """Everything a loader must check before an asset is published. @@ -1319,7 +1320,7 @@ class LatestFrameMailbox(Generic[T]): def __init__(self) -> None: self._lock = threading.Lock() - self._frame: Optional[T] = None + self._frame: T | None = None self._superseded = 0 def offer(self, frame: T) -> OfferResult: @@ -1332,12 +1333,12 @@ class LatestFrameMailbox(Generic[T]): self._superseded += 1 return "replaced" if replaced else "accepted" - def take(self) -> Optional[T]: + def take(self) -> T | None: with self._lock: frame, self._frame = self._frame, None return frame - def peek(self) -> Optional[T]: + def peek(self) -> T | None: with self._lock: return self._frame @@ -1360,7 +1361,7 @@ class KeyProvider(Protocol): class AssetLoader(Protocol): def load( - self, path: Path, expected: ExpectedGeometry, keys: Optional[KeyProvider] = None + self, path: Path, expected: ExpectedGeometry, keys: KeyProvider | None = None ) -> SceneAssets: """CPU/IO thread: authenticate and validate before publishing.""" ... @@ -1387,7 +1388,7 @@ class SceneCompiler(Protocol): def compile( self, surface: SceneAssets, - housing_glb: Optional[Path], + housing_glb: Path | None, output: Path, options: CompileOptions, ) -> CompileReport: ... @@ -1396,7 +1397,7 @@ class SceneCompiler(Protocol): class FingerFrameAdapter(Protocol): def snapshot( self, packet: Any, runtime: Any, identity: FrameIdentity - ) -> Optional[SurfaceFieldFrame]: + ) -> SurfaceFieldFrame | None: """Acquisition thread only: copy what is needed; no GL, no GUI.""" ... @@ -1404,7 +1405,7 @@ class FingerFrameAdapter(Protocol): class BoxFrameAdapter(Protocol): def snapshot( self, packet: Any, identity: FrameIdentity, domain: ExpectedGeometry - ) -> Optional[SurfaceFieldFrame]: ... + ) -> SurfaceFieldFrame | None: ... class SceneComposer(Protocol): diff --git a/shared/src/orisys_app_shared/scene3d/digest.py b/shared/src/orisys_app_shared/scene3d/digest.py index 43a1ea5..48cb4ff 100644 --- a/shared/src/orisys_app_shared/scene3d/digest.py +++ b/shared/src/orisys_app_shared/scene3d/digest.py @@ -20,7 +20,8 @@ from __future__ import annotations import hashlib import json -from typing import Any, Iterable, Mapping +from collections.abc import Iterable, Mapping +from typing import Any import numpy as np @@ -71,7 +72,7 @@ def _array_bytes(array: Any) -> bytes: return data.tobytes() -def _feed(hasher: "hashlib._Hash", label: str, payload: bytes) -> None: +def _feed(hasher: hashlib._Hash, label: str, payload: bytes) -> None: hasher.update(label.encode("utf-8")) hasher.update(b"\0") hasher.update(str(len(payload)).encode("ascii")) diff --git a/shared/src/orisys_app_shared/scene3d/glb.py b/shared/src/orisys_app_shared/scene3d/glb.py index da7a6df..da49670 100644 --- a/shared/src/orisys_app_shared/scene3d/glb.py +++ b/shared/src/orisys_app_shared/scene3d/glb.py @@ -29,9 +29,10 @@ from __future__ import annotations import json import struct +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping, Optional +from typing import Any import numpy as np @@ -81,7 +82,7 @@ def read_glb(path: Path) -> tuple[dict[str, Any], bytes]: f"{path}: the header declares {length} bytes but the file has {len(raw)}", ) offset = 12 - document: Optional[dict[str, Any]] = None + document: dict[str, Any] | None = None binary = b"" while offset + 8 <= len(raw): chunk_length, chunk_type = struct.unpack_from(" tuple[dict[str, Any], bytes]: raise error("asset_rejected", f"{path}: no JSON chunk") if int((document.get("asset") or {}).get("version", "0").split(".")[0]) != 2: raise error("asset_rejected", f"{path}: only glTF 2.0 is supported") - used = set((document.get("extensionsUsed") or [])) + used = set(document.get("extensionsUsed") or []) refused = sorted(used & set(REFUSED_EXTENSIONS)) if refused: raise error( diff --git a/shared/src/orisys_app_shared/scene3d/legacy.py b/shared/src/orisys_app_shared/scene3d/legacy.py index 9958035..5c906e2 100644 --- a/shared/src/orisys_app_shared/scene3d/legacy.py +++ b/shared/src/orisys_app_shared/scene3d/legacy.py @@ -30,9 +30,10 @@ scores a relative residual of 0.006, and the wrong flip scores 0.59. from __future__ import annotations +from collections.abc import Iterable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping, Optional +from typing import Any import numpy as np @@ -86,24 +87,24 @@ class LegacyFieldDomain: shape_hw: tuple[int, int] flip_v: bool - source_scale: Optional[float] + source_scale: float | None valid_mask: np.ndarray # bool [H,W], read-only domain_id: str origin: str = "top-left" index_order: str = "row-major" mapping_digest: str = "" - world_xyz: Optional[np.ndarray] = None # float32 [H,W,3], for verification only + world_xyz: np.ndarray | None = None # float32 [H,W,3], for verification only #: The unroll map itself: for every output pixel, the source pixel it came from #: (``-1`` where there is none). The adapters need it to bring a live field into #: this domain, and the digest above already covers it. - map_x: Optional[np.ndarray] = None - map_y: Optional[np.ndarray] = None + map_x: np.ndarray | None = None + map_y: np.ndarray | None = None #: The grid the map samples from, as the file states it (0 when unstated). source_width: int = 0 source_height: int = 0 @property - def source_hw(self) -> Optional[tuple[int, int]]: + def source_hw(self) -> tuple[int, int] | None: """The grid the map samples from, when the file states it.""" if self.source_width <= 0 or self.source_height <= 0: return None @@ -172,7 +173,7 @@ def load_legacy_domain(map_path: Path) -> LegacyFieldDomain: mapping_digest=mapping_digest, ) - def _optional_array(name: str) -> Optional[np.ndarray]: + def _optional_array(name: str) -> np.ndarray | None: value = data.get(name) return None if value is None else np.asarray(value) @@ -377,7 +378,7 @@ def vertex_normals( positions: np.ndarray, triangles: np.ndarray, *, - crease_angle_deg: Optional[float] = None, + crease_angle_deg: float | None = None, ) -> np.ndarray: """Area-weighted vertex normals: unit length, computed from the geometry. @@ -624,7 +625,7 @@ def build_binding( def estimate_meters_per_source_unit( - positions: np.ndarray, expected_extent_m: Optional[float] = None + positions: np.ndarray, expected_extent_m: float | None = None ) -> float: """What the mesh's units would have to be for its extent to be a real size. @@ -706,8 +707,8 @@ class LegacyFingerImporter: asset_id: str = "finger-display", asset_version: str = "1.0.0", lod_id: str = "lod0", - camera_presets: Optional[Iterable[CameraPreset]] = None, - housing_path: Optional[Any] = None, + camera_presets: Iterable[CameraPreset] | None = None, + housing_path: Any | None = None, ) -> SceneAssets: export_dir = Path(export_dir) map_path = Path(map_path) @@ -864,7 +865,7 @@ class LegacyFingerImporter: units: float, transform: np.ndarray, lod_id: str, - ) -> Optional[MeshPart]: + ) -> MeshPart | None: """The optional non-sensing part (the housing / mounting frame), or ``None``. The file is expected in the **same source frame and units as** ``mesh.npz``: an @@ -960,10 +961,10 @@ def sensing_surface_normal( def default_finger_presets( bounds_sensor_m: np.ndarray, - positions_sensor_m: Optional[np.ndarray] = None, - triangles: Optional[np.ndarray] = None, + positions_sensor_m: np.ndarray | None = None, + triangles: np.ndarray | None = None, *, - surface_normal_sensor: Optional[np.ndarray] = None, + surface_normal_sensor: np.ndarray | None = None, ) -> tuple[CameraPreset, ...]: """The default views for a flat sensing surface, in sensor metres. diff --git a/shared/src/orisys_app_shared/scene3d/overlays.py b/shared/src/orisys_app_shared/scene3d/overlays.py index 67b8c89..d33bdb9 100644 --- a/shared/src/orisys_app_shared/scene3d/overlays.py +++ b/shared/src/orisys_app_shared/scene3d/overlays.py @@ -7,7 +7,7 @@ examples tree. Outputs :class:`ArrowInstances` for the GL backend. from __future__ import annotations from dataclasses import dataclass -from typing import Literal, Optional +from typing import Literal import numpy as np @@ -118,7 +118,7 @@ def build_flow_overlay( samples: FlowSamples, mapping: SurfaceMapping, *, - style: Optional[VisualPolicy] = None, + style: VisualPolicy | None = None, normal_offset_m: float = 0.0, ) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]: """Map shared UV flow samples onto the sensing surface.""" @@ -194,7 +194,7 @@ def build_flow_overlay( def build_normal_overlay( *, - contact_uv_xy: Optional[tuple[float, float]], + contact_uv_xy: tuple[float, float] | None, contact_found: bool, contact_valid: bool, fn: float, diff --git a/shared/src/orisys_app_shared/scene3d/package.py b/shared/src/orisys_app_shared/scene3d/package.py index 05615d4..6d7c4a5 100644 --- a/shared/src/orisys_app_shared/scene3d/package.py +++ b/shared/src/orisys_app_shared/scene3d/package.py @@ -36,12 +36,14 @@ from __future__ import annotations import io import json import zipfile +from collections.abc import Iterable, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping, Optional +from typing import Any import numpy as np +from orisys_app_shared.scene3d import protect as _protect from orisys_app_shared.scene3d.contracts import ( CameraPreset, CompileReport, @@ -59,18 +61,19 @@ from orisys_app_shared.scene3d.contracts import ( ) from orisys_app_shared.scene3d.digest import ( DIGEST_SCHEMA, - asset_hash as compute_asset_hash, binding_id, content_digest, +) +from orisys_app_shared.scene3d.digest import ( + asset_hash as compute_asset_hash, +) +from orisys_app_shared.scene3d.digest import ( geometry_id as compute_geometry_id, ) from orisys_app_shared.scene3d.legacy import ( - crease_aware_mesh, default_finger_presets, - normals_disagree_with_faces, sensing_surface_normal, ) -from orisys_app_shared.scene3d import protect as _protect #: Re-exported so a caller can ask "can this build seal a package at all?" without #: importing the crypto module directly. @@ -303,7 +306,7 @@ def write_package( *, sensor_model: str = "finger", hardware_revisions: Iterable[str] = ("*",), - sealing: Optional[Sealing] = None, + sealing: Sealing | None = None, ) -> CompileReport: """Write a runtime package atomically: a half-written package never appears. @@ -477,7 +480,7 @@ def _read_envelope(archive: zipfile.ZipFile) -> dict[str, Any]: def _verify_members( - members: Mapping[str, bytes], *, expected_hash: Optional[str] = None + members: Mapping[str, bytes], *, expected_hash: str | None = None ) -> PackageContents: """The stage-2 verification, over a name→bytes mapping from either envelope.""" try: @@ -568,7 +571,7 @@ def read_package( path: Path, *, keys: Any = None, - verify_key_pem: Optional[bytes] = None, + verify_key_pem: bytes | None = None, ) -> PackageContents: """Open, verify and hand back the arrays. Refuses rather than repairs. @@ -646,7 +649,7 @@ def read_package( def scene_from_contents( - contents: PackageContents, expected: Optional[ExpectedGeometry] = None + contents: PackageContents, expected: ExpectedGeometry | None = None ) -> SceneAssets: """Turn verified arrays into a :class:`SceneAssets` and validate it.""" manifest = contents.manifest @@ -864,7 +867,7 @@ class PackagedAssetLoader: expected: ExpectedGeometry, keys: Any = None, *, - verify_key_pem: Optional[bytes] = None, + verify_key_pem: bytes | None = None, ) -> SceneAssets: return scene_from_contents( read_package(path, keys=keys, verify_key_pem=verify_key_pem), expected @@ -880,7 +883,7 @@ def describe_package( path: Path, *, keys: Any = None, - verify_key_pem: Optional[bytes] = None, + verify_key_pem: bytes | None = None, ) -> dict[str, Any]: """A report on a package without building a scene: for the validate tool.""" contents = read_package(path, keys=keys, verify_key_pem=verify_key_pem) diff --git a/shared/src/orisys_app_shared/scene3d/protect.py b/shared/src/orisys_app_shared/scene3d/protect.py index f207e7c..bb6899d 100644 --- a/shared/src/orisys_app_shared/scene3d/protect.py +++ b/shared/src/orisys_app_shared/scene3d/protect.py @@ -53,8 +53,9 @@ from __future__ import annotations import base64 import json import secrets +from collections.abc import Mapping from pathlib import Path -from typing import Any, Mapping, Optional +from typing import Any from orisys_app_shared.scene3d.contracts import ( Diagnostic, @@ -141,7 +142,7 @@ def _b64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") -def _unb64(text: Any, *, field: str, length: Optional[int] = None) -> bytes: +def _unb64(text: Any, *, field: str, length: int | None = None) -> bytes: if not isinstance(text, str) or not text: raise error("asset_auth_failed", f"the envelope's {field} is missing") try: @@ -245,7 +246,7 @@ class MappingKeyProvider: ``get_content_key(key_id)``. """ - def __init__(self, keys: Optional[Mapping[str, Any]] = None) -> None: + def __init__(self, keys: Mapping[str, Any] | None = None) -> None: self.keys: dict[str, bytes] = { str(key_id): _key_bytes(value, key_id=str(key_id)) for key_id, value in (keys or {}).items() @@ -328,9 +329,9 @@ def seal_payload( *, key_id: str, provider: KeyProvider, - signing_key_pem: Optional[bytes] = None, + signing_key_pem: bytes | None = None, signing_key_reference: str = "", - extra: Optional[Mapping[str, Any]] = None, + extra: Mapping[str, Any] | None = None, ) -> tuple[dict[str, Any], bytes]: """Encrypt (and optionally sign) one plaintext payload. @@ -441,8 +442,8 @@ def open_payload( envelope: Mapping[str, Any], ciphertext: bytes, *, - provider: Optional[KeyProvider], - public_key_pem: Optional[bytes] = None, + provider: KeyProvider | None, + public_key_pem: bytes | None = None, ) -> bytes: """Verify, decrypt and hand back the plaintext payload. diff --git a/shared/src/orisys_app_shared/scene3d/sampling.py b/shared/src/orisys_app_shared/scene3d/sampling.py index 6d7fcfd..fe2e05d 100644 --- a/shared/src/orisys_app_shared/scene3d/sampling.py +++ b/shared/src/orisys_app_shared/scene3d/sampling.py @@ -31,8 +31,6 @@ touches the frame's own arrays and never copies them (``tests/test_scene3d_live. from __future__ import annotations -from typing import Optional - import numpy as np from orisys_app_shared.scene3d.contracts import ( @@ -84,7 +82,7 @@ def check_field_domain(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> Non def sample_vertex_displacement( frame: SurfaceFieldFrame, binding: SurfaceBinding -) -> Optional[np.ndarray]: +) -> np.ndarray | None: """The frame's displacement, gathered onto the render vertices. ``None`` when the frame carries no displacement — the common case, and the only diff --git a/shared/src/orisys_app_shared/scene3d/visual_policy.py b/shared/src/orisys_app_shared/scene3d/visual_policy.py index 722034e..cdf0225 100644 --- a/shared/src/orisys_app_shared/scene3d/visual_policy.py +++ b/shared/src/orisys_app_shared/scene3d/visual_policy.py @@ -8,11 +8,16 @@ measurement difference rather than a display bug. from __future__ import annotations from dataclasses import dataclass -from typing import Optional import numpy as np -from orisys_app_shared.scene3d.contracts import Diagnostic, Mask, diagnostic, error, freeze +from orisys_app_shared.scene3d.contracts import ( + Diagnostic, + Mask, + diagnostic, + error, + freeze, +) @dataclass(frozen=True) @@ -32,7 +37,7 @@ class VisualPolicy: island_bgr: tuple[int, int, int] = (0x16, 0x13, 0x12) exterior_bgr: tuple[int, int, int] = (0x0A, 0x09, 0x08) - def normalised(self) -> "VisualPolicy": + def normalised(self) -> VisualPolicy: on = max(0.0, float(self.flow_on_px)) off = max(0.0, float(self.flow_off_px)) if on < off: @@ -65,11 +70,11 @@ class FlowSamples: def build_domain_mask( - geometry_mask: Optional[np.ndarray], - texture_roi: Optional[np.ndarray] = None, + geometry_mask: np.ndarray | None, + texture_roi: np.ndarray | None = None, *, - shape_hw: Optional[tuple[int, int]] = None, -) -> tuple[Optional[Mask], tuple[Diagnostic, ...]]: + shape_hw: tuple[int, int] | None = None, +) -> tuple[Mask | None, tuple[Diagnostic, ...]]: """Strict intersection of geometry and optional texture ROI. Shape mismatches raise rather than silently resize: a wrong mask invents @@ -115,10 +120,10 @@ def build_domain_mask( def select_flow_samples( flow: np.ndarray, - mask: Optional[np.ndarray], + mask: np.ndarray | None, policy: VisualPolicy, *, - previously_on: Optional[np.ndarray] = None, + previously_on: np.ndarray | None = None, ) -> FlowSamples: """Sample optical flow on a UV grid with hysteresis and mask clipping. @@ -207,7 +212,7 @@ def select_flow_samples( def paint_domain_background( shape_hw: tuple[int, int], - mask: Optional[np.ndarray], + mask: np.ndarray | None, *, island_bgr: tuple[int, int, int], exterior_bgr: tuple[int, int, int], diff --git a/shared/src/orisys_app_shared/sensors/catalog.py b/shared/src/orisys_app_shared/sensors/catalog.py index 1c7e83f..27b641a 100644 --- a/shared/src/orisys_app_shared/sensors/catalog.py +++ b/shared/src/orisys_app_shared/sensors/catalog.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pathlib import Path - from orisys_app_shared.paths import sensors_dir #: Product types the UI offers. diff --git a/shared/src/orisys_app_shared/zmotion/client.py b/shared/src/orisys_app_shared/zmotion/client.py index e07142b..c59790c 100644 --- a/shared/src/orisys_app_shared/zmotion/client.py +++ b/shared/src/orisys_app_shared/zmotion/client.py @@ -61,7 +61,7 @@ class ZmotionResult: def ok(self) -> bool: return self.code == 0 - def raise_for_status(self, detail: str = "") -> "ZmotionResult": + def raise_for_status(self, detail: str = "") -> ZmotionResult: if not self.ok: raise ZAuxError(self.code, detail) return self @@ -88,7 +88,7 @@ class ZmotionValue: """ return self.value if self.ok else None - def raise_for_status(self, detail: str = "") -> "ZmotionValue": + def raise_for_status(self, detail: str = "") -> ZmotionValue: if not self.ok: raise ZAuxError(self.code, detail) return self @@ -122,7 +122,7 @@ class ZmotionClient: """Directory the runtime was loaded from, for logs and About boxes.""" return self._dll_dir - def load(self) -> "ZmotionClient": + def load(self) -> ZmotionClient: """Resolve and load the native runtime. Idempotent.""" if self._dll is None: resolved = resolve_dll_dir() diff --git a/shared/tests/test_calibration_identity.py b/shared/tests/test_calibration_identity.py index 25ef583..5657cfd 100644 --- a/shared/tests/test_calibration_identity.py +++ b/shared/tests/test_calibration_identity.py @@ -13,7 +13,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker from orisys_app_shared.record import ExperimentRecorder, RecordConfig from orisys_app_shared.record.calibration_snapshot import ( diff --git a/shared/tests/test_calibration_model.py b/shared/tests/test_calibration_model.py index c75126a..b2f3a51 100644 --- a/shared/tests/test_calibration_model.py +++ b/shared/tests/test_calibration_model.py @@ -12,7 +12,6 @@ import shutil from pathlib import Path import pytest - from orisys_app_shared.force.calibration_io import ( CalSample, CalSessionStore, diff --git a/shared/tests/test_calibration_record.py b/shared/tests/test_calibration_record.py index 06bf66e..3735a85 100644 --- a/shared/tests/test_calibration_record.py +++ b/shared/tests/test_calibration_record.py @@ -12,7 +12,6 @@ import shutil from pathlib import Path import pytest - from orisys_app_shared.force.calibration_recorder import CalibrationRecorder from orisys_app_shared.force.calibration_session import ( SAMPLE_FIELDS, diff --git a/shared/tests/test_display_units.py b/shared/tests/test_display_units.py index 3cec0f9..af83db1 100644 --- a/shared/tests/test_display_units.py +++ b/shared/tests/test_display_units.py @@ -15,7 +15,6 @@ import math import numpy as np import pytest - from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.force import select_display_forces from orisys_app_shared.force.session import SessionSample, SessionStore diff --git a/shared/tests/test_experiment_recorder.py b/shared/tests/test_experiment_recorder.py index 1eb2ddf..d4db1c4 100644 --- a/shared/tests/test_experiment_recorder.py +++ b/shared/tests/test_experiment_recorder.py @@ -15,7 +15,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.record import ( EVENTS_HEADER, diff --git a/shared/tests/test_force_filters.py b/shared/tests/test_force_filters.py index 6c4fe23..4c980e9 100644 --- a/shared/tests/test_force_filters.py +++ b/shared/tests/test_force_filters.py @@ -15,7 +15,6 @@ from __future__ import annotations import numpy as np import pytest - from orisys_app_shared.force.filters import ( CHANNELS, FilterSettings, diff --git a/shared/tests/test_force_history.py b/shared/tests/test_force_history.py index 1e19c89..2d83f7f 100644 --- a/shared/tests/test_force_history.py +++ b/shared/tests/test_force_history.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from orisys_app_shared.force.history import MetricHistory diff --git a/shared/tests/test_force_presentation.py b/shared/tests/test_force_presentation.py index af46533..e775d24 100644 --- a/shared/tests/test_force_presentation.py +++ b/shared/tests/test_force_presentation.py @@ -3,7 +3,6 @@ from __future__ import annotations import numpy as np - from orisys_app_shared.force.presentation import ForcePresentation, ZeroEstimate #: The idle triplet from the customer's screenshot at run start: the numbers the diff --git a/shared/tests/test_history_source.py b/shared/tests/test_history_source.py index 79e1fae..1317f31 100644 --- a/shared/tests/test_history_source.py +++ b/shared/tests/test_history_source.py @@ -14,7 +14,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.force.session import SessionSample, SessionStore from orisys_app_shared.force.source import RecordingCurveSource, StoreCurveSource diff --git a/shared/tests/test_image_backend.py b/shared/tests/test_image_backend.py index b409771..1552e1d 100644 --- a/shared/tests/test_image_backend.py +++ b/shared/tests/test_image_backend.py @@ -10,7 +10,6 @@ from __future__ import annotations import numpy as np import pytest - from orisys_app_shared.acquisition import image_backend as backend from orisys_app_shared.acquisition.arrows import ( depth_map_to_bgr, diff --git a/shared/tests/test_module_boundaries.py b/shared/tests/test_module_boundaries.py index b942e13..6395c82 100644 --- a/shared/tests/test_module_boundaries.py +++ b/shared/tests/test_module_boundaries.py @@ -79,7 +79,8 @@ def test_the_session_format_does_not_import_the_sampling_state_machine(): def test_the_moved_models_are_the_same_objects_everywhere(): from orisys_app_shared.acquisition import finger_worker, models, sdk_worker - from orisys_app_shared.force import calibration_recorder, calibration_session, models as force_models + from orisys_app_shared.force import calibration_recorder, calibration_session + from orisys_app_shared.force import models as force_models assert sdk_worker.SdkPacket is models.SdkPacket is finger_worker.SdkPacket assert sdk_worker.FixedCameraMode is models.FixedCameraMode diff --git a/shared/tests/test_overlays.py b/shared/tests/test_overlays.py index 4161529..2e4404c 100644 --- a/shared/tests/test_overlays.py +++ b/shared/tests/test_overlays.py @@ -3,7 +3,6 @@ from __future__ import annotations import numpy as np - from orisys_app_shared.scene3d.overlays import ( arrow_instances_to_mesh, build_flow_overlay, diff --git a/shared/tests/test_playback.py b/shared/tests/test_playback.py index 16d857e..44e5cd5 100644 --- a/shared/tests/test_playback.py +++ b/shared/tests/test_playback.py @@ -7,16 +7,12 @@ that the policy's risks are reported instead of discovered later. from __future__ import annotations -import csv import json import shutil -import sys -import types from pathlib import Path import numpy as np import pytest - from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.record import ( ExperimentRecorder, diff --git a/shared/tests/test_record_channel.py b/shared/tests/test_record_channel.py index f5d8b7b..18822e7 100644 --- a/shared/tests/test_record_channel.py +++ b/shared/tests/test_record_channel.py @@ -18,13 +18,12 @@ import time import numpy as np import pytest - from orisys_app_shared.force.calibration_recorder import ( STATUS_FEW_SAMPLES, STATUS_OK, CalibrationRecorder, ) -from orisys_app_shared.force.session import NA, SessionSample, SessionStore +from orisys_app_shared.force.session import SessionSample, SessionStore def _sample(index: int, *, valid: bool = True, rate: float = 30.0) -> SessionSample: diff --git a/shared/tests/test_record_lifecycle.py b/shared/tests/test_record_lifecycle.py index 33eab3a..55fa393 100644 --- a/shared/tests/test_record_lifecycle.py +++ b/shared/tests/test_record_lifecycle.py @@ -18,7 +18,6 @@ from unittest.mock import patch import numpy as np import pytest - from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.record import ExperimentRecorder, RecordConfig from orisys_app_shared.record.experiment import ( diff --git a/shared/tests/test_record_semantics.py b/shared/tests/test_record_semantics.py index b532f6a..af4c6da 100644 --- a/shared/tests/test_record_semantics.py +++ b/shared/tests/test_record_semantics.py @@ -14,7 +14,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.record import ExperimentRecorder, RecordConfig from orisys_app_shared.record.sample import STALE_REASON, to_record_sample diff --git a/shared/tests/test_scene3d_assets.py b/shared/tests/test_scene3d_assets.py index 4564486..e37993c 100644 --- a/shared/tests/test_scene3d_assets.py +++ b/shared/tests/test_scene3d_assets.py @@ -16,16 +16,14 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ( - ExpectedGeometry, Material, MeshPart, SceneAssets, ThreeDError, - validate_assets, freeze, + validate_assets, ) from orisys_app_shared.scene3d.digest import ( asset_hash, @@ -414,7 +412,6 @@ def test_normals_disagree_with_faces_detects_the_defect(): def test_the_shipped_housing_has_no_stale_shading(): """The real housing: 134 of 2172 edges are creases, so plain averaging bands.""" import io - import zipfile package = ( Path(__file__).resolve().parents[2] diff --git a/shared/tests/test_scene3d_contracts.py b/shared/tests/test_scene3d_contracts.py index a65d219..ac924f6 100644 --- a/shared/tests/test_scene3d_contracts.py +++ b/shared/tests/test_scene3d_contracts.py @@ -15,7 +15,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.scene3d import contracts as c @@ -477,7 +476,6 @@ def test_the_contract_layer_imports_without_qt_or_opengl(): could be pulled in — the earlier spelling named PyQt5, before the migration. """ import subprocess - import sys script = ( "import sys;" diff --git a/shared/tests/test_scene3d_device_geometry.py b/shared/tests/test_scene3d_device_geometry.py index 51d5986..41378bd 100644 --- a/shared/tests/test_scene3d_device_geometry.py +++ b/shared/tests/test_scene3d_device_geometry.py @@ -22,7 +22,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.acquisition.geometry import ( describe_sensor_geometry, map_path_for, diff --git a/shared/tests/test_scene3d_live.py b/shared/tests/test_scene3d_live.py index 079618f..30005a2 100644 --- a/shared/tests/test_scene3d_live.py +++ b/shared/tests/test_scene3d_live.py @@ -9,26 +9,17 @@ grow. from __future__ import annotations -import threading -import time -from concurrent.futures import ThreadPoolExecutor - import numpy as np import pytest - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.composer import DISPLAY_ILLUSTRATIVE, SceneComposer from orisys_app_shared.scene3d.contracts import ( - ColorScale, DisplaySettings, FieldDescriptor, FrameIdentity, - Quality, - RenderFrame, SurfaceFieldFrame, ThreeDError, freeze, - rest_descriptor, ) from orisys_app_shared.scene3d.sampling import ( remap_to_domain, diff --git a/shared/tests/test_scene3d_package.py b/shared/tests/test_scene3d_package.py index 99b07cf..7ebd89b 100644 --- a/shared/tests/test_scene3d_package.py +++ b/shared/tests/test_scene3d_package.py @@ -16,19 +16,18 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError from orisys_app_shared.scene3d.digest import asset_hash, content_digest from orisys_app_shared.scene3d.glb import with_housing from orisys_app_shared.scene3d.package import ( MAGIC, + PackagedAssetLoader, describe_package, read_package, scene_from_contents, write_package, ) -from orisys_app_shared.scene3d.package import PackagedAssetLoader @pytest.fixture @@ -318,8 +317,8 @@ def test_an_encrypted_payload_needs_a_key_provider_and_a_verification_key(scene, "cryptography", reason="sealing needs the crypto backend" ) del crypto - from orisys_app_shared.scene3d.contracts import Sealing from orisys_app_shared.scene3d import protect as protect_module + from orisys_app_shared.scene3d.contracts import Sealing private = protect_module.generate_signing_key() public = protect_module.public_key_pem(private) diff --git a/shared/tests/test_scene3d_protect.py b/shared/tests/test_scene3d_protect.py index 63b57a7..4dd3c3f 100644 --- a/shared/tests/test_scene3d_protect.py +++ b/shared/tests/test_scene3d_protect.py @@ -30,7 +30,6 @@ from pathlib import Path import numpy as np import pytest - from orisys_app_shared.record.visualization3d import ( BLOCK_VERSION, build_block, diff --git a/shared/tests/test_visual_policy.py b/shared/tests/test_visual_policy.py index e160424..f95af9b 100644 --- a/shared/tests/test_visual_policy.py +++ b/shared/tests/test_visual_policy.py @@ -4,7 +4,6 @@ from __future__ import annotations import numpy as np import pytest - from orisys_app_shared.scene3d.contracts import ThreeDError from orisys_app_shared.scene3d.visual_policy import ( VisualPolicy, diff --git a/shared/tests/test_worker_cancellation.py b/shared/tests/test_worker_cancellation.py index b65d778..9d03528 100644 --- a/shared/tests/test_worker_cancellation.py +++ b/shared/tests/test_worker_cancellation.py @@ -25,7 +25,7 @@ STOP_BUDGET_S = 0.5 class FakeSensor: """Blocks in the constructor until ``release`` is set.""" - def __init__(self, release: threading.Event, tracker: "OpenTracker", **kwargs): + def __init__(self, release: threading.Event, tracker: OpenTracker, **kwargs): tracker.opened(self) if not release.wait(timeout=10.0): raise TimeoutError("fake sensor was never released") diff --git a/tools/lint_gate.py b/tools/lint_gate.py new file mode 100644 index 0000000..e1109b4 --- /dev/null +++ b/tools/lint_gate.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Merge gate for lint on a change — the narrow rule set a change must pass. + +This is deliberately *not* the repo's full `ruff.toml` selection. The policy, decided from the +measured findings rather than from taste: + + * ``BLE001`` (blind except) is **allowed**. A broad ``except Exception`` is the right choice + around device I/O, GL teardown and camera probing, and this codebase already records that + choice inline as ``# noqa: BLE001 - ``. Gating it would produce ~100 findings whose + only correct fix is "add a reasoned suppression". + * ``S110``/``S112`` (a handler that is only ``pass``/``continue``) and ``E722`` (bare + ``except:``) **are** gated: the complaint is not the breadth of the catch, it is that the + failure becomes invisible. Silence is acceptable only with a reasoned suppression or a + handler that reports/re-raises. + * ``RUF100`` (unused suppression) is reported for triage but is configured ``unfixable``: a + machine must never delete a suppression whose comment records why it exists. + +Run over the files a change touches, never the whole tree, so an AI-authored diff is not +drowned in pre-existing style churn. + +Usage:: + + tools/lint_gate.py # files changed vs HEAD (staged + unstaged) + tools/lint_gate.py --base origin/main # files changed vs a ref + tools/lint_gate.py --files a.py b.py # explicit files + tools/lint_gate.py --all # gate rules over the whole repo + tools/lint_gate.py --json # machine-readable result on stdout + +Exit codes: 0 clean, 1 findings, 2 a usage or environment problem (e.g. ruff missing). +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +#: The gate. Explicit, so a rule added to `ruff.toml` cannot silently start failing builds. +#: `E4`/`E7` are deliberately absent: `E402` fires on the intentional "set the environment before +#: importing Qt" pattern in scripts, and `E701`/`E702` are style, which `ruff format` handles. +GATE_SELECT = "E9,E722,F,S110,S112,B023,PLW1510" + +#: Rules we report but never let a tool auto-fix (RUF100 is `unfixable` in ruff.toml too). +REPORT_ONLY = "RUF100" + +REPO = Path(__file__).resolve().parents[1] + + +def find_ruff() -> str: + """Locate ruff: $RUFF, then PATH, then the interpreter environment.""" + for candidate in (os.environ.get("RUFF"), shutil.which("ruff")): + if candidate and Path(candidate).is_file(): + return candidate + beside = Path(sys.executable).parent / ("ruff.exe" if os.name == "nt" else "ruff") + if beside.is_file(): + return str(beside) + print( + "lint_gate: ruff not found. Set $RUFF, install it beside the interpreter, or put it on " + "PATH. The pin matters: findings differ between ruff versions, so the gate names its " + "version below when it runs.", + file=sys.stderr, + ) + raise SystemExit(2) + + +def git(*args: str) -> list[str]: + result = subprocess.run( + ["git", "-C", str(REPO), *args], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(f"lint_gate: git {' '.join(args)} failed: {result.stderr.strip()}", file=sys.stderr) + raise SystemExit(2) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def changed_python_files(base: str | None) -> list[str]: + """The .py files this change touches: working tree, index, and new files.""" + paths: set[str] = set() + if base: + paths.update(git("diff", "--name-only", "--diff-filter=ACMR", f"{base}...HEAD")) + paths.update(git("diff", "--name-only", "--diff-filter=ACMR")) + paths.update(git("diff", "--name-only", "--cached", "--diff-filter=ACMR")) + paths.update(git("ls-files", "--others", "--exclude-standard")) + return sorted(p for p in paths if p.endswith(".py") and (REPO / p).is_file()) + + +def run_ruff(ruff: str, args: list[str]) -> tuple[list[dict], str]: + version = subprocess.run( + [ruff, "--version"], capture_output=True, text=True, check=False + ).stdout.strip() + result = subprocess.run( + [ruff, "check", "--no-cache", "--output-format", "json", *args], + capture_output=True, + text=True, + check=False, + ) + if result.returncode not in (0, 1): + print(f"lint_gate: ruff failed ({result.returncode}): {result.stderr.strip()}", file=sys.stderr) + raise SystemExit(2) + try: + findings = json.loads(result.stdout or "[]") + except json.JSONDecodeError: + print(f"lint_gate: could not parse ruff output: {result.stdout[:400]}", file=sys.stderr) + raise SystemExit(2) + return findings, version + + +def describe(finding: dict) -> str: + code = finding.get("code") or "?" + location = finding.get("location") or {} + path = str(finding.get("filename", "")).replace(str(REPO) + os.sep, "") + return ( + f"{path}:{location.get('row', '?')}:{location.get('column', '?')}: {code}: " + f"{finding.get('message', '')}" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--base", default=None, help="compare against this ref (e.g. origin/main)") + parser.add_argument("--files", nargs="*", default=None, help="explicit files to check") + parser.add_argument("--all", action="store_true", help="run the gate rules over the whole repo") + parser.add_argument("--json", action="store_true", help="machine-readable result on stdout") + args = parser.parse_args(argv) + + ruff = find_ruff() + + if args.all: + targets = [str(REPO)] + else: + targets = args.files or changed_python_files(args.base) + if not targets: + print("lint_gate: no changed Python files; nothing to check.") + if args.json: + print(json.dumps({"findings": [], "checked": [], "status": "clean"})) + return 0 + + findings, version = run_ruff(ruff, ["--select", GATE_SELECT, *targets]) + report_only, _ = run_ruff(ruff, ["--select", REPORT_ONLY, *targets]) + + if args.json: + print( + json.dumps( + { + "ruff": version, + "select": GATE_SELECT, + "checked": targets, + "findings": [ + { + "file": str(f.get("filename", "")).replace(str(REPO) + os.sep, ""), + "row": (f.get("location") or {}).get("row"), + "column": (f.get("location") or {}).get("column"), + "code": f.get("code"), + "message": f.get("message"), + "fixable": bool((f.get("fix") or {}).get("applicability") == "safe"), + } + for f in findings + ], + "unused_suppressions": [describe(f) for f in report_only], + "status": "findings" if findings else "clean", + }, + indent=2, + ) + ) + return 1 if findings else 0 + + print(f"lint_gate: {version}") + print(f" select {GATE_SELECT} (BLE001 is allowed by policy; see ruff.toml)") + print(f" checked {len(targets)} target(s)") + if findings: + print(f"\n{len(findings)} finding(s):") + for finding in sorted(findings, key=describe): + hint = " [safe fix: ruff check --fix]" if (finding.get("fix") or {}).get("applicability") == "safe" else "" + print(f" {describe(finding)}{hint}") + else: + print(" no findings") + if report_only: + print(f"\n{len(report_only)} unused suppression(s) to triage by hand (never auto-removed):") + for finding in report_only: + print(f" {describe(finding)}") + print(f"\nstatus: {'FINDINGS' if findings else 'clean'}") + return 1 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/scene_assets/bench_scene.py b/tools/scene_assets/bench_scene.py index 5cf171a..8d242c2 100644 --- a/tools/scene_assets/bench_scene.py +++ b/tools/scene_assets/bench_scene.py @@ -77,7 +77,6 @@ def _grid_scene(target_vertices: int): stand-in. """ import numpy as np - from orisys_app_shared.scene3d.contracts import ( CameraPreset, Material, @@ -88,6 +87,8 @@ def _grid_scene(target_vertices: int): ) from orisys_app_shared.scene3d.digest import ( field_domain_id as compute_domain_id, + ) + from orisys_app_shared.scene3d.digest import ( geometry_id as compute_geometry_id, ) from orisys_app_shared.scene3d.legacy import vertex_normals @@ -207,7 +208,6 @@ def measure_cpu( what is left is the number an LOD decision should be made on. """ import numpy as np - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.contracts import ( @@ -310,12 +310,11 @@ def measure( ) -> tuple[dict, int]: """Run the assembled view and report what each stage cost.""" import numpy as np - from PySide6.QtWidgets import QApplication - from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.contracts import DisplaySettings from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.view import Scene3DView + from PySide6.QtWidgets import QApplication configure_surface_format(profile) # type: ignore[arg-type] app = QApplication.instance() or QApplication(sys.argv) @@ -337,8 +336,8 @@ def measure( report["reason"] = view.reason return report, 4 if package is not None: - from orisys_app_shared.scene3d.package import PackagedAssetLoader from orisys_app_shared.scene3d import protect + from orisys_app_shared.scene3d.package import PackagedAssetLoader provider = ( protect.load_key_file(Path(key_file), key_id=key_id) diff --git a/tools/scene_assets/compile_scene.py b/tools/scene_assets/compile_scene.py index 72d79c1..c94a2e0 100644 --- a/tools/scene_assets/compile_scene.py +++ b/tools/scene_assets/compile_scene.py @@ -89,8 +89,8 @@ def _platform_lock_from_args(args): def _sealing_from_args(args): """The key material the command line points at, or ``None`` for a plain package.""" - from orisys_app_shared.scene3d.contracts import Sealing from orisys_app_shared.scene3d import protect + from orisys_app_shared.scene3d.contracts import Sealing signing_pem = None if args.sign_key is not None: @@ -191,7 +191,6 @@ def main(argv: list[str] | None = None) -> int: _bootstrap_paths() import numpy as np - from orisys_app_shared.scene3d.contracts import ThreeDError from orisys_app_shared.scene3d.legacy import LegacyFingerImporter from orisys_app_shared.scene3d.package import describe_envelope, write_package diff --git a/tools/scene_assets/fit_design_frame.py b/tools/scene_assets/fit_design_frame.py index 03d3cf8..50735e8 100644 --- a/tools/scene_assets/fit_design_frame.py +++ b/tools/scene_assets/fit_design_frame.py @@ -71,7 +71,12 @@ MM_PER_SENSOR_UNIT = 0.001 def derive(design_dir: Path, sensor_dir: Path, *, tolerance: float) -> dict: - from align_housing import apply_similarity, fit_similarity, load_faces, load_vertices + from align_housing import ( + apply_similarity, + fit_similarity, + load_faces, + load_vertices, + ) design_mesh = design_dir / "mesh.npz" sensor_mesh = sensor_dir / "mesh.npz" @@ -160,7 +165,10 @@ def verify(design_dir: Path, sensor_dir: Path, rigid: np.ndarray, units: float) render vertices come out in the same order. """ from orisys_app_shared.scene3d.contracts import ThreeDError - from orisys_app_shared.scene3d.legacy import LegacyFingerImporter, validate_rigid_transform + from orisys_app_shared.scene3d.legacy import ( + LegacyFingerImporter, + validate_rigid_transform, + ) design_map = design_dir / "unroll_map_uv_s0.42.npz" sensor_map = sensor_dir / "unroll_map_uv_s0.4.npz" diff --git a/tools/scene_assets/probe_gl.py b/tools/scene_assets/probe_gl.py index 39e1f8f..2ff8b8d 100644 --- a/tools/scene_assets/probe_gl.py +++ b/tools/scene_assets/probe_gl.py @@ -21,7 +21,6 @@ from __future__ import annotations import argparse import json -import os import platform import sys import time @@ -43,10 +42,6 @@ def _bootstrap_paths() -> None: def collect(profile: str, *, keep_open_s: float) -> dict: """Create a real window, let Qt initialise it, and report what it got.""" - from PySide6.QtCore import QTimer - from PySide6.QtOpenGLWidgets import QOpenGLWidget - from PySide6.QtWidgets import QApplication - from orisys_studio.scene3d.gl_backend import ( GLDispatch, GLRenderer, @@ -54,6 +49,9 @@ def collect(profile: str, *, keep_open_s: float) -> dict: configure_surface_format, lut_rgba, ) + from PySide6.QtCore import QTimer + from PySide6.QtOpenGLWidgets import QOpenGLWidget + from PySide6.QtWidgets import QApplication configure_surface_format(profile) # type: ignore[arg-type] app = QApplication.instance() or QApplication(sys.argv) diff --git a/tools/scene_assets/soak_scene.py b/tools/scene_assets/soak_scene.py index 2b7019e..1cd6ba3 100644 --- a/tools/scene_assets/soak_scene.py +++ b/tools/scene_assets/soak_scene.py @@ -115,10 +115,9 @@ def run( late_rate_limit_mb_per_min: float, ) -> tuple[dict, int]: """Produce frames through the real pipeline and watch what it costs over time.""" - import numpy as np - from concurrent.futures import ThreadPoolExecutor + import numpy as np from orisys_app_shared.scene3d import synthetic from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.contracts import ( @@ -141,10 +140,9 @@ def run( view = None widget = None if use_gui: - from PySide6.QtWidgets import QApplication - from orisys_studio.scene3d.gl_backend import configure_surface_format from orisys_studio.scene3d.view import Scene3DView + from PySide6.QtWidgets import QApplication configure_surface_format(profile) # type: ignore[arg-type] app = QApplication.instance() or QApplication(sys.argv) diff --git a/tools/scene_assets/validate_scene.py b/tools/scene_assets/validate_scene.py index b4112a5..2c9bb1b 100644 --- a/tools/scene_assets/validate_scene.py +++ b/tools/scene_assets/validate_scene.py @@ -36,8 +36,11 @@ def _bootstrap_paths() -> None: def _legacy_report(args) -> tuple[int, dict]: import numpy as np - - from orisys_app_shared.scene3d.contracts import ExpectedGeometry, ThreeDError, validate_assets + from orisys_app_shared.scene3d.contracts import ( + ExpectedGeometry, + ThreeDError, + validate_assets, + ) from orisys_app_shared.scene3d.legacy import ( LegacyFingerImporter, estimate_meters_per_source_unit,