Compare commits

..

3 Commits

Author SHA1 Message Date
ykcheungab ccedb9ddd9 migrate pyqt5 to pyside6 2026-09-21 09:34:56 +08:00
ykcheungab e276b5de0f autocal layout first design 2026-09-20 21:15:39 +08:00
ykcheungab 07d7c3bad6 official 0.1.0 version 2026-09-16 09:43:57 +08:00
141 changed files with 20927 additions and 461 deletions
+1
View File
@@ -5,6 +5,7 @@ __pycache__/
.eggs/
dist/
build/_pyinstaller/
build/_smoke/
*.egg
.venv/
.venv-build/
+29 -5
View File
@@ -2,18 +2,42 @@
Desktop applications that consume the Orisys SDK.
| App | Status |
|-----|--------|
| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) |
| `apps/internal` | Stub for future calibration & automated testing |
| App | Status | Qt binding |
|-----|--------|-----------|
| `apps/studio` | The customer-facing shell (theme / locale / prefs / logging, acquisition, 3D) | **PySide6** |
| `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** |
| `apps/internal` | Stub, superseded by `apps/autocal` | — |
Both shipped apps are **PySide6** — Qt's **LGPLv3** binding. PyQt5's free option is
**GPLv3-or-commercial**, which is why the studio was migrated off it: a closed-source
product would otherwise need a licence purchase. The rule is enforced, not just
documented: neither app may import PyQt5/PyQt6/PySide2, and `shared/` (used by both) may
import PySide6 and nothing else. See `apps/studio/tests/test_binding_purity.py` and
`apps/autocal/tests/test_binding_purity.py`.
One practical consequence: `pyqtgraph` binds to whichever toolkit it imports first, so a
development environment that still has PyQt5 installed can build plots from the wrong
binding. `orisys_studio.main` sets `PYQTGRAPH_QT_LIB=PySide6` before anything imports it,
and the purity test asserts that importing the app pulls in no other binding.
## Layout
- `shared/` — common paths, prefs, theme, i18n, logging
- `shared/` — common paths, prefs, theme, i18n, logging, and the ZMotion client
- `shared/resources/zmotion/` — vendored ZMotion runtime DLLs (internal use only)
- `apps/studio/config/` — locales, default prefs, editable `sensors/` copies
- `apps/studio/outputs/` — generated logs and calibration exports (gitignored)
- `apps/autocal/` — internal platform control; see `apps/autocal/README.md`
- `build/` — Windows PyInstaller packaging (`build/studio/build.ps1`)
### Selecting which app's paths resolve
`orisys_app_shared.paths` reads `ORISYS_APP_ID` (an `apps/` directory name) to
decide which product's `config/`, `preference.json` and `outputs/` a process
owns. Each app's launcher sets it before importing the shared package; when it
is unset the studio is assumed, which keeps existing callers working unchanged.
A named app whose `config/` is missing raises rather than silently resolving
another product's tree.
## Run (studio shell)
```powershell
+145
View File
@@ -0,0 +1,145 @@
# Orisys AutoCal
Internal control application for the **5-axis calibration platform**, driving a
ZMotion ZMC bus controller (EtherCAT or RTEX) from the PC.
Replaces the vendor example program it was ported from:
D:\orisys_ws\zmotion\Python64\例程9-总线控制运动
> **Internal use only.** AutoCal is not part of the customer installer. See
> "Do not ship this app" below.
## What it does
- **Connect** over Ethernet (with an IP scan), serial, PCI, local/simulated, or
a built-in **demo** transport that needs no hardware and no DLL.
- **Initialise the bus** by downloading `ECAT.bas` or `RTEX.bas` to the
controller, then follow the controller's own state machine to completion — or
to a timeout, with the vendor's diagnostic hint.
- **Read axis state** for all five drives at once: enable, axis type, pulse
equivalent, speed, acceleration, commanded and measured position (in user
units *and* millimetres), axis status word, motion and homing state.
- **Move**: enable/disable, jog, absolute and relative moves, stop, stop-all,
driver homing, clear a drive alarm. Every command is guarded — see below.
- **Exchange raw bus data**: EtherCAT object-dictionary (SDO) reads and writes,
and RTEX parameters. Each family is enabled only when the controller reports
that bus type.
## Run it
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
for the same reason and no longer uses it.)
```powershell
# with the environment that already has PySide6 (Anaconda py310, for example)
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\autocal\src"
python D:\orisys_ws\orisys-app\apps\autocal\run.py
```
Or with no controller attached at all:
```powershell
python .\apps\autocal\run.py --demo
```
`run.py` sets `ORISYS_APP_ID=autocal` before importing anything from
`orisys_app_shared`, so this app's `config/`, `preference.json` and `outputs/`
resolve to `apps/autocal/` rather than the studio's.
### Controller library
The ZMotion runtime is vendored at `shared/resources/zmotion/x64/` (see the
README beside it for provenance). Point `ORISYS_ZMOTION_DLL_DIR` at another
directory to use a different build — on a motion controller, silently loading
the wrong runtime version is the kind of fault that looks like a working machine
until it moves, so the override is authoritative: if it does not contain
`zauxdll.dll`, the app refuses to connect instead of quietly falling back.
## Safety model
The guards live in `motion/service.py`, not in the UI, so they hold whatever
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.
- **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
from another machine.
- **Homing is refused on RTEX**, as the vendor example itself does.
- **Disconnect stops the axes first** (the vendor program closes the handle and
leaves the platform coasting).
- **SDO/RTEX are gated by the reported bus type**, so a write can never be aimed
at the wrong family.
- **A failed read is marked, not zeroed.** `ZmotionValue.value_or_none` returns
`None` on failure; nothing silently treats a failed read's zero as a position.
The displayed millimetre value is *derived* from the pulse equivalent
(bench-measured: `units=10` → 1000 user units = 2 mm). It is a display value,
not a calibrated length, and the UI says so.
## Layout
```text
apps/autocal/
run.py # source-tree launcher
config/
preferences.default.json # shipped defaults
locales/{zh_CN,en_US}.json
bas/{ECAT,RTEX}.bas # copies: the vendor needs a non-Chinese path
src/orisys_autocal/
main.py # logging, locale, theme, window
preferences.py # AutoCal's own preference schema
motion/ # Qt-free except poller.py (the Qt worker bridge)
models.py errors.py units.py service.py transport.py poller.py
ui/ # window.py + one module per panel
tools/ # panel_check.py, panel_snapshot.py
tests/
```
`motion/poller.py` is the only place a controller call can originate. It owns
the DLL handle on its own thread, so the vendor library's blocking calls (a ROM
write, a bus scan) never freeze the window and the ctypes handle is never shared
between threads. The UI talks to it through signals only.
## Verify
Both work with no display, no controller and no DLL:
```powershell
python .\apps\autocal\tools\panel_check.py # 120 layout/theme/locale/gating checks
python .\apps\autocal\tools\panel_snapshot.py --demo --theme dark --out shot.png
python .\apps\autocal\tools\panel_snapshot.py --compare --out tabs.png # one image per tab
python .\apps\autocal\tools\layout_mockup.py --out layout.png # proposed layout, drawn
python -m pytest apps\autocal\tests # offscreen suite
```
`layout_mockup.py` is a **wireframe, not the application**: it paints the
proposed single-page layout with the shared theme tokens and imports nothing
from the app, so the layout can be reviewed and marked up without building the
panels. It draws the full page plus previews of the other workspace tabs in one
canvas.
`panel_check.py` builds the real window in both themes at 1280×720, 1600×900 and
1920×1080, asserts that no widget escapes its parent, that motion is
unreachable before the bus is initialised, that the SDO/RTEX panels follow the
reported bus family, and then runs a full simulated session from `ECAT.bas`.
The suite is written for **PySide6**. Where PySide6 is missing, the test modules
are skipped rather than failed: a broken environment should not look like a
broken application. `tests/test_binding_purity.py` enforces the binding policy
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
other binding, and the controller interface must stay importable headless. The
studio app enforces the same rules for itself in
`apps/studio/tests/test_binding_purity.py`.
## Do not ship this app
AutoCal is an internal tool. It is deliberately **not** packaged by
`build/studio`, whose PyInstaller spec must not learn about the ZMotion runtime
DLLs, and it is not in the customer installer. The ZMotion SDK is vendor-supplied
hardware support software with its own terms; do not redistribute those DLLs
with a customer product.
+103
View File
@@ -0,0 +1,103 @@
'*******************************************************ECAT总线初始化
'''''''''
global CONST BUS_TYPE = 0 '总线类型。用于上位机区分当前模式
global CONST MAX_AXISNUM = 32 '最大轴数
global CONST Bus_Slot = 0 '槽位号0
global CONST Bus_AxisStart = 0 '总线轴起始轴号
global Bus_InitStatus '总线初始化完成状态
Bus_InitStatus = -1
global Bus_TotalAxisnum '检查扫描的总轴数
delay(3000) '延时3S等待驱动器上电
'**********************初始化ECAT总线
Ecat_Init()
end
global sub Ecat_Init()
for i=0 to MAX_AXISNUM - 1 '初始化还原轴类型
AXIS_ENABLE(i) = 0
atype(i)=0
next
Bus_InitStatus = -1
Bus_TotalAxisnum = 0
SLOT_STOP(Bus_Slot)
delay(200)
slot_scan(Bus_Slot) '开始扫描
if return then
?"总线扫描成功","连接设备数:"NODE_COUNT(Bus_Slot)
?
?"开始映射轴号"
for i=0 to NODE_COUNT(Bus_Slot)-1 '遍历总线下所有从站节点
if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then '判断当前节点是否有电机
for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1
AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 '映射轴号
ATYPE(Bus_AxisStart+i)=65 '设置控制模式 65-位置 66-速度 67-转矩
DRIVE_PROFILE(Bus_AxisStart+i)= 4 '设置PROFILE功能
disable_group(Bus_AxisStart+i) '每轴单独分组
DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 '映射驱动器上的IO状态
REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16
FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16
DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16
INVERT_IN(128 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(129 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(130 + (Bus_AxisStart+i)*16,ON)
Bus_TotalAxisnum=Bus_TotalAxisnum+1 '总轴数+1
next
endif
next
?"轴号映射完成","连接总轴数:"Bus_TotalAxisnum
wa 2000
SLOT_START(Bus_Slot) '启动总线
if return then
?"总线开启成功"
?"开始清除驱动器错误(根据驱动器数据字典设置)"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
DRIVE_CONTROLWORD(i)=128 '根据驱动器数据字典
wa 100
DRIVE_CONTROLWORD(i)=6
wa 100
DRIVE_CONTROLWORD(i)=15
wa 100
next
?"驱动器错误清除完成"
wa 100
?"清除控制器错误"
datum(0)
DRIVE_CLEAR(0)
?"控制器错误清除完成"
wa 100
?"轴使能准备"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
base(i)
AXIS_ENABLE=1
next
wdog=1 '使能总开关
Bus_InitStatus = 1
?"轴使能完成"
else
?"总线开启失败"
Bus_InitStatus = 0
endif
else
?"总线扫描失败"
Bus_InitStatus = 0
endif
end sub
+103
View File
@@ -0,0 +1,103 @@
'*******************************************************RTEX总线初始化
'''''''''
global CONST BUS_TYPE = 1 '总线类型。用于上位机区分当前模式
global CONST MAX_AXISNUM = 32 '最大轴数
global CONST Bus_Slot = 0 '槽位号0
global CONST Bus_AxisStart = 0 '总线轴起始轴号
global Bus_InitStatus '总线初始化完成状态
Bus_InitStatus = -1
global Bus_TotalAxisnum '检查扫描的总轴数
delay(3000) '延时3S等待驱动器上电
'**********************初始化RTEX总线
Rtex_Init()
end
global sub Rtex_Init()
for i=0 to MAX_AXISNUM - 1 '初始化还原轴类型
AXIS_ENABLE(i) = 0
atype(i)=0
next
Bus_InitStatus = -1
Bus_TotalAxisnum = 0
SLOT_STOP(Bus_Slot)
delay(200)
slot_scan(Bus_Slot) '开始扫描
if return then
?"总线扫描成功","连接设备数:"NODE_COUNT(Bus_Slot)
?
?"开始映射轴号"
for i=0 to NODE_COUNT(Bus_Slot)-1 '遍历总线下所有从站节点
if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then '判断当前节点是否有电机
for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1
AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 '映射轴号
ATYPE(Bus_AxisStart+i)=50 '设置控制模式 50-位置 51-速度 52-转矩
DRIVE_PROFILE(Bus_AxisStart+i)= 4 '设置PROFILE功能
disable_group(Bus_AxisStart+i) '每轴单独分组
DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 '映射驱动器上的IO状态
REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16
FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16
DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16
INVERT_IN(128 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(129 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(130 + (Bus_AxisStart+i)*16,ON)
Bus_TotalAxisnum=Bus_TotalAxisnum+1 '总轴数+1
next
endif
next
?"轴号映射完成","连接总轴数:"Bus_TotalAxisnum
wa 2000
SLOT_START(Bus_Slot) '启动总线
if return then
?"总线开启成功"
?"开始清除驱动器错误(根据驱动器数据字典设置)"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
DRIVE_CONTROLWORD(i)=128 '根据驱动器数据字典
wa 100
DRIVE_CONTROLWORD(i)=6
wa 100
DRIVE_CONTROLWORD(i)=15
wa 100
next
?"驱动器错误清除完成"
wa 100
?"清除控制器错误"
datum(0)
DRIVE_CLEAR(0)
?"控制器错误清除完成"
wa 100
?"轴使能准备"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
base(i)
AXIS_ENABLE=1
next
wdog=1 '使能总开关
Bus_InitStatus = 1
?"轴使能完成"
else
?"总线开启失败"
Bus_InitStatus = 0
endif
else
?"总线扫描失败"
Bus_InitStatus = 0
endif
end sub
+269
View File
@@ -0,0 +1,269 @@
{
"app": {
"title": "Orisys AutoCal Platform Control",
"about_title": "About Orisys AutoCal",
"about_body": "Orisys AutoCal — internal controller for the 5-axis calibration platform\nVersion: {app_version}\nShared library: {shared_version} (API {api_version})\n\nQt binding: {binding}\nController library: {dll_dir}\nPreferences: {prefs_path}\n\nInternal use only.",
"internal_notice": "Internal tool: not shipped in the customer installer."
},
"brand": {
"name": "ORISYS",
"detail": "Orisys Technology"
},
"menu": {
"file": "File",
"open_bas": "Download BAS init program…",
"reveal_config": "Show config in Explorer…",
"quit": "Quit",
"view": "View",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"language": "Language",
"lang_zh": "简体中文",
"lang_en": "English",
"show_diagnostics": "Diagnostics",
"tabs": "Workspace",
"outputs": "Outputs folder",
"help": "Help",
"about": "About AutoCal"
},
"connection": {
"title": "Controller connection",
"mode": "Connection",
"mode_eth": "Ethernet",
"mode_com": "Serial",
"mode_pci": "PCI card",
"mode_local": "Local / simulated",
"mode_demo": "Demo (no hardware)",
"ip": "IP",
"scan": "Scan IP",
"com": "COM port",
"card": "Card",
"local_string": "Connect string",
"open": "Connect",
"close": "Disconnect",
"status": "Status",
"state_open": "Connected",
"state_closed": "Not connected",
"state_connecting": "Connecting…",
"source": "Library",
"no_more": "The 『{mode}』 connection is not available yet."
},
"bus": {
"title": "Bus initialisation",
"bas_path": "BAS file",
"choose": "Choose BAS…",
"download": "Download and initialise",
"reinit": "Re-initialise",
"type": "Bus type",
"type_ecat": "EtherCAT",
"type_rtex": "RTEX",
"type_unknown": "Unknown",
"state": "Init state",
"state_pending": "Not started",
"state_running": "Initialising…",
"state_ok": "Initialised",
"state_failed": "Initialisation failed",
"state_timeout": "Initialisation timed out",
"nodes": "Nodes",
"axes": "Axes",
"downloaded": "File downloaded; the controller will scan the bus after its delay",
"dialog_title": "Choose a BAS file"
},
"axes": {
"title": "Axis motion",
"axis": "Axis",
"enable": "Enable",
"enable_on": "Enabled",
"enable_off": "Disabled",
"atype": "Type",
"units": "Units",
"speed": "Speed",
"accel": "Accel",
"dpos": "DPOS",
"mpos": "MPOS",
"dpos_mm": "DPOS (mm)",
"mpos_mm": "MPOS (mm)",
"status": "Status",
"motion": "Motion",
"motion_word": "Motion",
"idle": "Stopped",
"moving": "Moving",
"position_pair": "{value} ({mm} mm)",
"enable_all": "Enable all",
"disable_all": "Disable all",
"clear_alarm_all": "Clear all alarms",
"detail": "Axis {axis}: type {atype} · accel {accel} · feedback {mpos} ({mpos_mm} mm) · status {status} · {home}",
"detail_none": "Axis {axis}: no data yet (not connected or not initialised)",
"home_status": "Homing",
"home_ok": "Homed",
"home_pending": "Not homed",
"home_running": "Homing…",
"jog_fwd": "Fwd",
"jog_rev": "Rev",
"stop_all": "Stop all",
"stop": "Stop",
"target": "Target",
"move_abs": "Move abs",
"move_rel": "Move rel",
"home_mode": "Home mode",
"home_high": "Home speed",
"home_low": "Home creep",
"home_offset": "Home offset",
"clear_alarm": "Clear alarm",
"apply": "Apply",
"home": "Home",
"controls": "Actions",
"mm_derived_tip": "Millimetres are derived from the pulse equivalent: a display value, not a calibrated length.",
"enabled_count": "{on}/{total} axes enabled"
},
"busdata": {
"title": "Bus data exchange",
"group": "Bus data (SDO / RTEX)",
"ecat": "EtherCAT (SDO)",
"rtex": "RTEX",
"node": "Node",
"index": "Index",
"subindex": "Subindex",
"type": "Type",
"value": "Value",
"read": "Read",
"write": "Write",
"axis": "Axis",
"parameter": "Parameter",
"last_result": "Last: {text}",
"not_available": "The current bus does not support this",
"read_ok": "Read: {value}",
"write_ok": "Written"
},
"status": {
"motion": "Motion",
"ready": "Ready",
"poll_ok": "State refresh healthy",
"poll_paused": "State refresh paused",
"poll_stalled": "State refresh stalled",
"last_error": "Last error",
"none": "None",
"refused": "Refused",
"done": "Done",
"failed": "Failed",
"pause": "Pause refresh",
"resume": "Resume refresh",
"paused": "State refresh paused",
"alarm_short": "Alarm",
"axes_enabled": "{on}/{total} axes enabled",
"health_none": "Refresh —",
"health_age": "Read {ms} ms ago",
"health_paused": "Refresh paused",
"poll_every": "Refresh every {ms} ms"
},
"run": {
"title": "Run",
"phase_idle": "Idle",
"phase_dry_run": "Dry run",
"phase_running": "Running",
"phase_paused": "Paused",
"phase_done": "Done",
"phase_failed": "Failed",
"step_of": "Point {done}/{total}",
"no_steps": "No points generated",
"elapsed_only": "Elapsed {elapsed}",
"elapsed_remaining": "Elapsed {elapsed} · about {remaining} left",
"run": "Run",
"pause": "Pause",
"abort": "Abort",
"abort_stopped": "Run aborted and all axes stopped.",
"not_implemented": "The runner belongs to phase P7 and is not implemented yet; abort is always available."
},
"readout": {
"title": "Axis state"
},
"workspace": {
"tab_axes": "Axis debug",
"tab_sequence": "Test sequence",
"tab_sensor": "Sensor",
"tab_surface": "Surface map",
"tab_points": "Calibration points",
"not_built": "Not built yet",
"reserved_sensor": "Sensor connection, live image and force curves will appear here: device and source selection, preview, and a compact version of the Studio curve view.",
"reserved_surface": "Multi-point calibration sensitivity will be drawn as a colour map on the sensor's 3D surface here: each point's sensitivity interpolated and coloured, with a legend and range.",
"reserved_points": "The calibration point table and capture controls will appear here: platform coordinates, reference load, sampling statistics and the resulting sensitivity."
},
"sequence": {
"title": "Test sequence design",
"profile_title": "Point generation",
"preview_title": "Preview",
"validation_title": "Checks",
"points_title": "Calibration points",
"field_mode": "Surface mode",
"field_name": "Sequence name",
"field_note": "Note",
"field_pitch": "Pitch (mm)",
"field_margin": "Margin (mm)",
"field_radius": "Radius (mm)",
"field_clear_height": "Clear height (mm)",
"field_approach_speed": "Approach speed (mm/s)",
"field_press_speed": "Press speed (mm/s)",
"field_press_depth": "Press depth (mm)",
"field_dwell": "Dwell (s)",
"field_settle": "Settle window (s)",
"field_repeats": "Repeats",
"field_order": "Point order",
"mode_flat": "Flat",
"mode_curved": "Curved",
"order_row": "Row-wise",
"order_serpentine": "Serpentine",
"order_distance": "By distance",
"preview_placeholder": "3D preview: sensor surface · calibration points · visiting order\n(implemented in the simulation phase)",
"preview_hint": "After generation, check here that each point is on the surface, inside travel, and confirmable by contact.",
"validation_pending": "No points generated yet.",
"check_on_surface": "On the surface",
"check_within_travel": "Within travel",
"check_contact_confirmable": "Contact confirmable",
"btn_generate": "Generate points",
"btn_simulate": "Simulate",
"btn_dry_run": "Dry run (no contact)",
"btn_export": "Export sequence…",
"btn_import": "Import sequence…",
"import_export_tip": "Saved as a JSON document (.oryseq), reusable across machines; the format version is checked on import.",
"export_failed": "Export failed: {error}",
"import_failed": "Import failed: {error}",
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
"col_index": "#",
"col_x": "X (mm)",
"col_y": "Y (mm)",
"col_z": "Z (mm)",
"col_status": "Status",
"col_sensitivity": "Sensitivity (last)",
"col_samples": "Samples",
"col_actions": "Actions",
"not_generated": "—",
"status_pending": "Pending",
"status_skipped": "Skipped",
"summary": "{count} points ({enabled} enabled) · {spans} · about {duration} (estimate)",
"summary_empty": "No points yet: generate or import to see the count and travel span.",
"dialog_export": "Export sequence",
"dialog_import": "Import sequence",
"filter_sequence": "Calibration sequence (*.oryseq *.json)",
"exported": "Sequence exported: {name}",
"imported": "Sequence imported: {name} ({count} points)",
"import_warnings": "Import reported {count} warning(s)",
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
},
"action": {
"ok": "Done",
"refused": "Refused",
"failed": "Failed"
},
"diagnostics": {
"title": "Diagnostics",
"binding": "Qt binding",
"dll_dir": "Controller library",
"prefs": "Preferences",
"outputs": "Outputs",
"bas_loaded": "BAS loaded",
"yes": "Yes",
"no": "No"
}
}
+269
View File
@@ -0,0 +1,269 @@
{
"app": {
"title": "Orisys AutoCal 标定台控制",
"about_title": "关于 Orisys AutoCal",
"about_body": "Orisys AutoCal — 五轴标定台内部控制器\n版本: {app_version}\n共享库: {shared_version} (API {api_version})\n\nQt 绑定: {binding}\n控制器库: {dll_dir}\n偏好文件: {prefs_path}\n\n仅供内部使用。",
"internal_notice": "内部工具:不随客户安装包发布。"
},
"brand": {
"name": "ORISYS",
"detail": "叠动科技"
},
"menu": {
"file": "文件",
"open_bas": "下载 BAS 初始化程序…",
"reveal_config": "在资源管理器中显示配置…",
"quit": "退出",
"view": "视图",
"theme": "主题",
"theme_dark": "深色",
"theme_light": "浅色",
"language": "语言",
"lang_zh": "简体中文",
"lang_en": "English",
"show_diagnostics": "诊断信息",
"tabs": "工作区",
"outputs": "输出目录",
"help": "帮助",
"about": "关于 AutoCal"
},
"connection": {
"title": "连接控制器",
"mode": "连接方式",
"mode_eth": "以太网",
"mode_com": "串口",
"mode_pci": "PCI 卡",
"mode_local": "本地/仿真",
"mode_demo": "演示 (无硬件)",
"ip": "IP",
"scan": "IP 扫描",
"com": "串口号",
"card": "卡号",
"local_string": "连接参数",
"open": "连接",
"close": "断开连接",
"status": "状态",
"state_open": "已连接",
"state_closed": "未连接",
"state_connecting": "连接中…",
"source": "库路径",
"no_more": "连接方式『{mode}』暂未提供。"
},
"bus": {
"title": "总线初始化",
"bas_path": "BAS 文件",
"choose": "选择 BAS…",
"download": "下载并初始化",
"reinit": "重新初始化",
"type": "总线类型",
"type_ecat": "EtherCAT",
"type_rtex": "RTEX",
"type_unknown": "未知",
"state": "初始化状态",
"state_pending": "未完成",
"state_running": "正在初始化…",
"state_ok": "初始化成功",
"state_failed": "初始化失败",
"state_timeout": "初始化超时",
"nodes": "节点数量",
"axes": "轴数量",
"downloaded": "文件已下载,控制器将延时扫描总线",
"dialog_title": "选择 BAS 文件"
},
"axes": {
"title": "轴运动",
"axis": "轴号",
"enable": "使能",
"enable_on": "已使能",
"enable_off": "未使能",
"atype": "轴类型",
"units": "脉冲当量",
"speed": "速度",
"accel": "加速度",
"dpos": "命令位置",
"mpos": "反馈位置",
"dpos_mm": "命令位置 (mm)",
"mpos_mm": "反馈位置 (mm)",
"status": "轴状态",
"motion": "运动状态",
"motion_word": "运动",
"idle": "停止",
"moving": "运动中",
"position_pair": "{value} ({mm} mm)",
"enable_all": "全部使能",
"disable_all": "全部关闭使能",
"clear_alarm_all": "清除全部报警",
"detail": "选中轴 {axis}: 轴类型 {atype} · 加速度 {accel} · 反馈 {mpos} ({mpos_mm} mm) · 轴状态 {status} · {home}",
"detail_none": "选中轴 {axis}: 尚无数据(未连接或未初始化)",
"home_status": "回零状态",
"home_ok": "回零完成",
"home_pending": "回零未完成",
"home_running": "回零中",
"jog_fwd": "正转",
"jog_rev": "反转",
"stop_all": "全部停止",
"stop": "停止",
"target": "目标位置",
"move_abs": "绝对定位",
"move_rel": "相对运动",
"home_mode": "回零模式",
"home_high": "回零高速",
"home_low": "回零低速",
"home_offset": "回零偏移",
"clear_alarm": "清除报警",
"apply": "应用参数",
"home": "启动回零",
"controls": "操作",
"mm_derived_tip": "毫米值由脉冲当量换算得出,是显示值,不是标定长度。",
"enabled_count": "已使能 {on}/{total} 轴"
},
"busdata": {
"title": "总线数据交互",
"group": "总线数据 (SDO / RTEX)",
"ecat": "EtherCAT (SDO)",
"rtex": "RTEX",
"node": "节点号",
"index": "对象字典",
"subindex": "子编号",
"type": "数据类型",
"value": "数据",
"read": "读",
"write": "写",
"axis": "轴号",
"parameter": "参数地址",
"last_result": "最近一次: {text}",
"not_available": "当前总线不支持该操作",
"read_ok": "读取成功: {value}",
"write_ok": "写入成功"
},
"status": {
"motion": "运动控制",
"ready": "就绪",
"poll_ok": "状态刷新正常",
"poll_paused": "状态刷新已暂停",
"poll_stalled": "状态刷新中断",
"last_error": "最近错误",
"none": "无",
"refused": "操作被拒绝",
"done": "完成",
"failed": "失败",
"pause": "暂停刷新",
"resume": "继续刷新",
"paused": "已暂停状态刷新",
"alarm_short": "报警",
"axes_enabled": "已使能 {on}/{total} 轴",
"health_none": "刷新 —",
"health_age": "刷新 {ms} ms 前",
"health_paused": "刷新已暂停",
"poll_every": "刷新间隔 {ms} ms"
},
"run": {
"title": "运行",
"phase_idle": "空闲",
"phase_dry_run": "干跑中",
"phase_running": "运行中",
"phase_paused": "已暂停",
"phase_done": "已完成",
"phase_failed": "失败",
"step_of": "第 {done}/{total} 点",
"no_steps": "尚未生成标定点",
"elapsed_only": "已用 {elapsed}",
"elapsed_remaining": "已用 {elapsed} · 预计剩余 {remaining}",
"run": "运行",
"pause": "暂停",
"abort": "中止",
"abort_stopped": "已中止运行并停止全部轴。",
"not_implemented": "运行器属于 P7 阶段,尚未实现;中止随时可用。"
},
"readout": {
"title": "轴状态"
},
"workspace": {
"tab_axes": "轴调试",
"tab_sequence": "测试序列",
"tab_sensor": "传感器",
"tab_surface": "表面分布",
"tab_points": "标定点",
"not_built": "尚未实现",
"reserved_sensor": "传感器连接、实时图像与力曲线将在此显示:设备与信号源选择、预览、以及和 Studio 类似的曲线视图(占用空间更小)。",
"reserved_surface": "多点标定的灵敏度将在此以色图叠加在传感器三维表面上:每个标定点的灵敏度经插值着色,并给出色标与范围。",
"reserved_points": "标定点列表与采集控制将在此显示:平台坐标、参考载荷、采样统计与灵敏度结果。"
},
"sequence": {
"title": "测试序列设计",
"profile_title": "标定点生成",
"preview_title": "预览",
"validation_title": "校验",
"points_title": "标定点列表",
"field_mode": "曲面模式",
"field_name": "序列名称",
"field_note": "备注",
"field_pitch": "点距 (mm)",
"field_margin": "边界余量 (mm)",
"field_radius": "曲面半径 (mm)",
"field_clear_height": "安全高度 (mm)",
"field_approach_speed": "进给速度 (mm/s)",
"field_press_speed": "下压速度 (mm/s)",
"field_press_depth": "下压深度 (mm)",
"field_dwell": "保压 (s)",
"field_settle": "稳定窗口 (s)",
"field_repeats": "重复次数",
"field_order": "遍历顺序",
"mode_flat": "平面",
"mode_curved": "曲面",
"order_row": "按行",
"order_serpentine": "蛇形",
"order_distance": "按距离",
"preview_placeholder": "三维预览:传感器表面 · 标定点 · 遍历顺序\n(仿真阶段实现)",
"preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
"validation_pending": "尚未生成标定点。",
"check_on_surface": "在曲面范围内",
"check_within_travel": "在行程内",
"check_contact_confirmable": "接触可确认",
"btn_generate": "生成标定点",
"btn_simulate": "仿真",
"btn_dry_run": "干跑(不接触)",
"btn_export": "导出序列…",
"btn_import": "导入序列…",
"import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。",
"export_failed": "导出失败: {error}",
"import_failed": "导入失败: {error}",
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
"col_index": "#",
"col_x": "X (mm)",
"col_y": "Y (mm)",
"col_z": "Z (mm)",
"col_status": "状态",
"col_sensitivity": "灵敏度(上次)",
"col_samples": "样本",
"col_actions": "操作",
"not_generated": "—",
"status_pending": "待测",
"status_skipped": "已跳过",
"summary": "{count} 点({enabled} 启用) · {spans} · 预计 {duration}(估计值)",
"summary_empty": "尚无标定点:生成或导入后显示点数与行程范围。",
"dialog_export": "导出序列",
"dialog_import": "导入序列",
"filter_sequence": "标定序列 (*.oryseq *.json)",
"exported": "已导出序列: {name}",
"imported": "已导入序列: {name}({count} 点)",
"import_warnings": "导入有 {count} 项提示",
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
},
"action": {
"ok": "完成",
"refused": "已拒绝",
"failed": "失败"
},
"diagnostics": {
"title": "诊断信息",
"binding": "Qt 绑定",
"dll_dir": "控制器库",
"prefs": "偏好文件",
"outputs": "输出目录",
"bas_loaded": "BAS 已加载",
"yes": "是",
"no": "否"
}
}
@@ -0,0 +1,50 @@
{
"language": "zh_CN",
"theme": "light",
"window_geometry": null,
"connection": {
"mode": "eth",
"ip": "192.168.0.11",
"com_id": 0,
"card_num": 0,
"local_string": "0"
},
"bas_path": "",
"units_per_mm": 10.0,
"state_poll_ms": 100,
"sdo_last": { "node": 0, "index": 24704, "subindex": 0, "type": 0 },
"rtex_last": { "axis": 0, "parameter": 0 },
"show_diagnostics": false,
"safety_limits": {
"max_travel_mm": 100.0,
"max_descent_mm": 10.0,
"max_press_depth_mm": 1.0,
"follow_error_mm": 0.5,
"contact_search_mm": 3.0
},
"sensor_setup": {
"sensor_type": "box",
"config_ref": "",
"geometry_id": "",
"press_axis": 2,
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
"probe_tip_set": false,
"sensor_to_platform": [],
"transform_residual_mm": 0.0,
"transform_feature_count": 0
},
"sequence_profile": {
"mode": "flat",
"pitch_mm": 2.0,
"margin_mm": 1.0,
"radius_mm": 0.0,
"clear_height_mm": 5.0,
"approach_speed_mm_s": 2.0,
"press_speed_mm_s": 0.5,
"press_depth_mm": 0.2,
"dwell_s": 0.5,
"settle_window_s": 0.5,
"repeats": 3,
"order": "serpentine"
}
}
+167
View File
@@ -0,0 +1,167 @@
# Calibration sequence format (`.oryseq`)
A calibration sequence is a small JSON document: the **profile** that generated
the points, plus the **points** themselves. It is written by
`导出序列…` in the Sequence tab and read by `导入序列…`.
Why JSON and not a binary container: a sequence is at most a few hundred points
of text. It is read by a human, diffed in review, and emailed to whoever runs the
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
is a different problem — per-point time series are large and will need a sidecar;
that decision belongs to workflow P8, not here.)
## Conventions
The format follows this repository's existing documents rather than inventing a
convention. Where it matches `session.json` and `force_calibration.json`, that is
deliberate:
| Convention | Value |
|---|---|
| Version field | `"format_version": "1.0"` — a dotted **string**, compared as a tuple |
| Identity | `"kind": "autocal.sequence"` — checked on import |
| Extension | `.oryseq` by convention; import accepts any `.json` |
| Encoding | UTF-8, `indent=2`, `ensure_ascii=False`, trailing newline |
| Write | temp file then `Path.replace` — a half-written sequence that still parses looks like a design |
## The document
```json
{
"format_version": "1.0",
"kind": "autocal.sequence",
"created_at": "2026-09-20T10:31:00+00:00",
"name": "5 点灵敏度扫描",
"note": "",
"versions": { "app": "0.1.0", "shared": "0.1.0", "shared_api": "0.1" },
"device": {
"sensor_type": "box",
"config_ref": "…\\config\\sensors\\box\\sensor.json",
"geometry_id": "finger-1.0.0",
"probe_tip_set": true
},
"frame": { "targets": "platform", "length_unit": "mm", "units_per_mm": 10.0 },
"profile": {
"mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0,
"clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5,
"press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5,
"repeats": 3, "order": "serpentine"
},
"points": [
{
"id": 1,
"target_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"sensor_mm": { "x": 0.0, "y": 0.0, "z": 0.0 },
"clearance_mm": 5.0,
"enabled": true,
"note": ""
}
],
"summary": {
"count": 12, "enabled": 12,
"min_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"max_mm": { "x": 10.0, "y": 4.0, "z": 12.4 },
"span_mm": { "x": 10.0, "y": 4.0, "z": 0.0 },
"pitch_mm": 2.0,
"estimated_duration_s": 380.0
}
}
```
## Fields
### `frame` — required
| Field | Meaning |
|---|---|
| `targets` | the frame `target_mm` is expressed in; today always `"platform"` |
| `length_unit` | `"mm"`; anything else is refused |
| `units_per_mm` | the pulse equivalent the points were built with |
A sequence whose numbers do not say whether they are millimetres or user units is
a sequence that will eventually be read wrong. `units_per_mm` is recorded so a
file from a differently-configured axis is detectable rather than silently
believed.
### `profile` — required
The twelve fields the Sequence tab edits, and nothing else. A field absent from
the file is **kept at its current value and reported**, never silently defaulted
to another bench's number.
### `points` — required, may be empty
| Field | Meaning |
|---|---|
| `id` | 1-based, stable within the file; used for run progress and resume |
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
| `sensor_mm` | where that point lands on the sensor; optional but written |
| `clearance_mm` | the standoff for this point |
| `enabled` | false = designed but deliberately skipped |
| `note` | free text |
Both positions are stored on purpose. The platform target is what the run uses;
the sensor position is what makes the point checkable against the surface.
Deriving one from the other on every load would silently change what the points
mean the moment a sensor is re-fixtured — and a re-fixture is exactly when a
stored transform should be *noticed*, not recomputed from.
### `summary` — derived, not authoritative
Present so a human reading the file sees the point count and travel span without
counting rows, and so a diff is legible. It is **recomputed on load**; a mismatch
is reported as a warning and the recomputed values win. That is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
`estimated_duration_s` assumes a constant traverse rate
(`sequence_io.ASSUMED_TRAVERSE_MM_S`) and ignores acceleration. It is a planning
figure and is labelled an estimate wherever it is shown.
### `device` — a reference, not a requirement
Records which sensor the sequence was designed for. Importing a sequence for
another sensor is **allowed and warned about**, because re-fixturing
deliberately is a normal thing to do.
## Import rules
Every rejection names the specific problem — `invalid file` tells an operator
nothing.
| Condition | Behaviour |
|---|---|
| not JSON | refused, with line and column |
| top level not an object | refused |
| `kind` missing or different | refused, naming what was found |
| `format_version` missing or not a string | refused |
| `format_version` newer than this build | refused, naming both versions |
| `frame` missing, or `length_unit` not `mm` | refused |
| `profile` not an object | refused |
| a profile key absent | imported; the current value is kept and the key is reported |
| `points` absent | imported as profile-only, and reported |
| a point without `target_mm.x/y/z` | refused, naming the point and the axis |
| `summary` disagreeing with `points` | imported; recomputed, and reported |
| current design is unsaved | the operator is asked before it is replaced |
Version strings are compared as tuples, never as strings: `"1.10"` is newer than
`"1.9"`, and a string comparison would make a newer file look older and load it
as if it were supported.
## Compatibility rules
- **Adding an optional key** does not change `format_version`.
- **Adding a required key, or changing a field's meaning or unit**, does.
- A reader must refuse a document whose version it does not support rather than
interpreting the parts it recognises — a sequence half-applied is worse than
one rejected.
- `1.x` readers are expected to accept `1.y` for `y <= x` on the same major
version and report anything they did not use.
## Where the pieces live
| Concern | Module |
|---|---|
| The document, validation, the derived summary | `apps/autocal/src/orisys_autocal/sequence_io.py` |
| The design screen and its controls | `apps/autocal/src/orisys_autocal/ui/sequence_panel.py` |
| The file dialogs and the unsaved-work guard | `apps/autocal/src/orisys_autocal/ui/window.py` |
| Why a sequence is an artifact (P8) | `apps/autocal/docs/workflow.md` |
+335
View File
@@ -0,0 +1,335 @@
# AutoCal workflow — platform bring-up to calibration data
This is the operating workflow the application is built around. It is written to
be argued with: each phase names what it needs, what it produces, why it exists,
and what must be true before the next one starts.
A **gate** is a checkpoint. The app's job at a gate is to make the operator's
answer trustworthy, not to ask "are you sure?".
```text
P0 Session & safety baseline [gate]
P1 ECAT setup [gate]
P2 Homing + test motion [gate]
P3 Sensor & probe setup [gate]
P4 Coordinate setup: sensor <-> platform [gate]
P5 Sequence design [gate]
P6 Simulate & dry run [gate]
P7 Run + acquire [gate]
P8 Store raw
P9 Post-process [gate]
P10 Calibration export
P11 Verify [gate]
```
---
## P0 — Session & safety baseline
**Why:** the machine can move from P2 onward. Everything that limits that motion
should be decided while nothing is moving.
**Inputs:** operator identity, machine/bench identifier, units per axis,
`units_per_mm`.
**Safety limits, set here, enforced from P2:**
| Limit | Meaning |
|---|---|
| maximum travel per axis | never commanded past this, in user units |
| maximum Z descent per step | a single approach may not exceed this |
| press-depth ceiling | the press depth a sequence profile is allowed to request |
| no-contact abort | if contact is not detected within the descent budget, stop the run |
| following-error bound | how far a drive may lag before the move counts as stalled |
**Outputs:** a session record — who, when, which machine, and the limits in
force. Stamped onto everything the session produces.
**Gate:** every limit has a value; no limit is "unset" (an unset limit is an
unlimited machine).
**Currently missing from the workflow:** this phase did not exist. Without it,
step 1 went from bus setup straight to jogging five axes.
---
## P1 — ECAT setup
**Why:** nothing else can be trusted until the controller has actually found its
slaves. A missing drive discovered at P7 wastes the whole run.
**Inputs:** controller address, the matching `.bas` program
(`ECAT.bas` / `RTEX.bas`).
**Steps:** connect → download and run the BAS program → watch
`Bus_InitStatus` to completion or timeout → read `BUS_TYPE`,
`Bus_TotalAxisnum`, and the node count.
**Outputs:** bus type, node count, axis count, init status.
**Gate:** init status is success, **and** the node/axis counts match what this
bench is expected to have. A mismatch is a finding, not a warning.
**Existing code:** `MotionService.download_bas` / `poll` already implement the
state machine and the 25 s timeout.
---
## P2 — Homing + test motion
**Why:** a drive that homes and jogs cleanly at low speed is the precondition for
letting a sequence drive it.
**Inputs:** per-axis homing mode, speeds, offset; a test-motion profile.
**Steps:** enable → home each axis → low-speed jog each axis in both directions
→ confirm movement and return.
**Outputs:** per-axis pass/fail with the evidence (distance moved, following
error, alarm state).
**Gate — the test motion needs a criterion.** This was missing. Proposed:
- the axis moved at least a configured minimum distance;
- it returned within a tolerance of its start position;
- following error stayed under the bound from P0;
- no alarm before, during or after.
**Existing code:** the axis panel does the jog/homing; the criterion is new.
---
## P3 — Sensor & probe setup
**Why:** which sensor is under test, what touches it, and how far the touching
point is from the axis reference. Contact depth is meaningless without the tool.
**Inputs:**
- sensor type and config reference (the same `config/sensors/<type>/sensor.json`
the studio uses);
- the sensor's 3D scene package and its `geometry_id`;
- the **probe tip** (tool): which axis presses, and the tip offset from the
platform's reference point;
- the mounting pose: how the sensor is fixtured on the platform.
**Outputs:** a saved setup block.
**Gate:**
- the calibration's `geometry_id` matches the scene's, or the app says which is
which rather than colouring the wrong geometry;
- the probe tip offset is set (a zero offset that was never entered is not the
same as a probe at the origin).
---
## P4 — Coordinate setup: sensor ↔ platform
**Why this is the load-bearing phase.** "Which points on the sensor should be
tested" is a question in the *sensor's* frame. The platform moves in *platform*
coordinates. Without the transform between them:
- a generated grid cannot be checked against the sensor's surface;
- a measured point cannot be placed on that surface for the sensitivity map;
- a calibration cannot be re-applied to a re-fixtured sensor.
**Inputs:** the probe tip from P3, and a set of **at least four non-collinear
known features** on the sensor or its fixture, probed in platform coordinates.
**Steps:** probe each feature → solve for the rigid transform (rotation and
translation; scale if the units are not yet trusted) → report the residual.
**Outputs:** a 4×4 transform from sensor frame to platform frame, with its
residual and the feature set that produced it.
**Gate:** residual under a configured tolerance, and at least four non-collinear
points (four collinear points determine a rotation about that line, not a
transform — the app should refuse that set rather than return a number).
**Not built in this step:** the solver. Only the workflow step, the stored
transform, and the place its residual is displayed.
---
## P5 — Sequence design
**Why:** this is the decision the whole run exists to carry out.
**Inputs (the profile, persisted and reusable):**
| Parameter | Meaning |
|---|---|
| mode | flat surface or curved (a cylindrical/spherical sensor area) |
| grid pitch | spacing between points, in mm |
| margin / radius | how far in from the active edge the grid stays; radius for curved |
| clear height | standoff above the surface to travel at |
| approach speed | descent speed before contact |
| press speed | descent speed after contact |
| press depth | how far past contact to press |
| dwell | how long to hold the press |
| settle window | how long to wait before the measurement is considered settled |
| repeats | how many times each point is measured |
| point order | row-wise, serpentine, or by distance |
**Steps:** generate the grid → validate **every point** three ways:
1. **on the sensor** — its sensor-frame position falls on the modelled surface,
not off the edge and not inside a hole;
2. **within travel** — the platform can reach it inside the P0 limits;
3. **confirmable** — the sensor's sensing area is under the point, so a contact
there can actually be seen by the sensor.
**Outputs:** a point list with a sensor-frame position and a platform-frame
target for each, plus a validation verdict per point.
**Gate:** no point may be unusable without being marked as such. A run may
proceed with skipped points if the operator says so explicitly; it may not
proceed with unvalidated ones.
**The interface:** three zones in the Sequence tab — profile parameters, a 3D
preview of the surface with the points and their visiting order, and the
validation list. See `apps/autocal/README.md`.
---
## P6 — Simulate & dry run
**Why:** a wrong Z or a wrong grid is much cheaper to find now.
**Simulate:** play the full toolpath in the 3D view — a moving marker with a
phase label (standoff → descend → press → dwell → retract), the estimated
duration, and per-point reachability. No motion.
**Dry run:** execute the real XY path, descending only to the clear height,
never pressing. This proves the geometry, the ordering, and the timing.
**Outputs:** estimated duration, per-point reachability, and (dry run) the
measured time per point.
**Gate:** the operator has seen the path and a dry run has completed without a
guard violation.
---
## P7 — Run + acquire
**Why:** the only phase that touches the sensor.
**Per step, in order:**
1. move to the point's XY at clear height;
2. descend at approach speed;
3. **detect contact by position** — the axis stalls / following error rises
while descending;
4. **confirm with the sensor under test** — its reading must change when the
probe presses. This is the second opinion: position says "I stopped moving",
the sensor says "something real is happening";
5. press to the configured depth at press speed;
6. dwell, then acquire the settle window;
7. retract, move to the next point.
**Failure semantics (per point, because resume needs them):**
| Outcome | Run behaviour |
|---|---|
| contact confirmed | record the point as measured |
| contact detected, sensor shows no change | mark **suspect**, continue; a suspect point is visible, not silently averaged in |
| no contact within the descent budget | **abort** the run (P0 limit) and leave the point unfinished |
| guard violation | abort the run, name the step and the limit |
**Writes are incremental**: each point is written as it completes, with its
status, so an interrupted run resumes at the first unfinished point and partial
data stays valid.
**Existing code:** the motion layer has the guards and the poller; the step
machine, the contact detection and the acquisition binding are new.
---
## P8 — Store raw
**Why:** post-processing will change. The raw record must not.
**Contents:**
- the sequence profile and the instantiated point list (positions and order);
- per point: the position trace, the raw sensor series, the settle window used,
timings, the contact position, and the status;
- the setup: sensor identity, geometry id, probe tip, the P4 transform and its
residual;
- the session record and app/version stamps from P0.
Nothing derived is authoritative. The requirement that makes this reproducible:
**a dataset can be regenerated from the profile, the setup and the raw series**,
with no dependency on a previously computed result.
---
## P9 — Post-process
**Why:** turning samples into a number is a decision, and it has to be visible.
**Steps:** choose the settle window per point → stability check (is the point
actually settled, or drifting?) → sensitivity per point per axis → outlier
detection → residual per point → fit the surface across points.
**Outputs:** per-point sensitivity with a quality verdict, and a surface field
over the sensor's geometry ready for the 3D colour map.
**Gate:** every point is either valid or explicitly excluded, with a reason.
A point with too few samples or a drifting window is not a coefficient.
---
## P10 — Calibration export
**Per-point results and the fitted surface, explicitly linked.** They are one
calibration; exporting them as unrelated artefacts is how a map ends up in a
report that does not match the table beside it.
---
## P11 — Verify
**Why this exists:** P9 and P10 produce numbers. Nothing yet checks they are
right. "Post process → calibration data" had no acceptance test.
**Steps:** apply the calibration → re-measure a small subset of the points →
compare the measured sensitivity against what the calibration predicts →
report the discrepancy per point.
**Gate:** the discrepancy is within tolerance, or the verification is recorded
as failed. A calibration that was never verified is labelled as such wherever it
is used.
---
## Gaps this document closes
| # | Gap | Phase |
|---|---|---|
| 1 | no limits before motion | P0 |
| 2 | no session identity (who/when/which machine) | P0 |
| 3 | the sensor↔platform transform was absent | P4 |
| 4 | the probe tip (tool) was never defined | P3 |
| 5 | the test motion had no pass/fail criterion | P2 |
| 6 | grid validity was not checked per point | P5 |
| 7 | the settle window and stability criterion were undefined | P9 |
| 8 | a point's failure had no defined behaviour | P7 |
| 9 | the sequence was transient, not a saved, versioned artefact | P8 |
| 10 | no verification pass | P11 |
## Data flow
```text
P0 limits ─┬───────────────────────────────> enforced at P2, P6, P7
P1 bus ────┴─> axis availability ─────────> P2
P3 sensor setup ─┬─ geometry id ──────────> P5 validation, P10 surface
└─ probe tip ────────────> P4, P7 press depth
P4 transform ─────> sensor <-> platform ──> P5 point generation, P9 map
(residual) P11 re-fixture check
P5 profile + points ──> P6 simulate/dry run ──> P7 run
P7 raw series ─────> P8 dataset ──> P9 post-process ──> P10 export
└─> P11 verify
```
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-autocal"
version = "0.1.0"
description = "Orisys AutoCal — internal 5-axis calibration platform control"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
# PySide6 is the SQL-free, LGPLv3 Qt binding. It is deliberate, not a
# preference: this is an internal tool, and PyQt5's free option is GPLv3
# (see README.md and src/orisys_autocal/qtcompat.py).
"PySide6>=6.6",
"orisys-app-shared>=0.1.0",
]
[project.scripts]
orisys-autocal = "orisys_autocal.main:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Launch Orisys AutoCal from the source tree."""
from __future__ import annotations
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
SHARED_SRC = ROOT.parents[1] / "shared" / "src"
for path in (SRC, SHARED_SRC):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
# Resolve config/outputs to this app, and set the app id before any shared
# import can read it (see orisys_app_shared.paths).
os.environ.setdefault("ORISYS_APP_ID", "autocal")
os.chdir(ROOT)
from orisys_autocal.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,12 @@
"""Orisys AutoCal — internal 5-axis calibration platform control app."""
__version__ = "0.1.0"
REQUIRES_SHARED_API = "0.1"
#: Window / QApplication name.
APP_DISPLAY_NAME = "Orisys AutoCal"
#: The Qt binding this app uses. It is PySide6 and only PySide6: LGPLv3, so an
#: internal closed-source tool needs no licence purchase, and it is the
#: maintained Qt-for-Python binding (PySide2 is discontinued upstream).
QT_BINDING = "PySide6"
+126
View File
@@ -0,0 +1,126 @@
"""AutoCal entrypoint: logging, locale, theme, then the window."""
from __future__ import annotations
import argparse
import logging
import os
import sys
# The app id must be set before anything imports orisys_app_shared.paths, because
# the config/output roots are resolved on first use and cached by callers.
os.environ.setdefault("ORISYS_APP_ID", "autocal")
def _force_utf8_streams() -> None:
"""Make stdout/stderr UTF-8 where the platform lets us.
The controller's error strings and every panel label are Chinese, and a
Windows console defaults to a legacy code page that cannot encode them.
A failed reconfigure is not fatal — the log file is UTF-8 regardless.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError): # pragma: no cover - stream already detached
pass
def _pyside_version() -> str:
import PySide6
return getattr(PySide6, "__version__", "unknown")
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="orisys-autocal", description=__doc__)
parser.add_argument(
"--demo",
action="store_true",
help="start on the simulated platform (no hardware, no controller DLL)",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
help="console and file log level",
)
return parser.parse_args(argv)
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.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 .motion import ConnectionSpec, DemoTransport
from .preferences import load_preferences
from .ui.window import AutoCalMainWindow
init_logging("autocal", level=getattr(logging, args.log_level))
install_exception_hooks()
check_shared_compat(REQUIRES_SHARED_API)
logger = logging.getLogger(__name__)
prefs = load_preferences()
language = str(prefs.get("language") or "zh_CN")
theme = str(prefs.get("theme") or "dark")
set_locale(language)
app = QApplication(sys.argv[:1])
app.setApplicationName("Orisys AutoCal")
app.setApplicationVersion(app_version)
app.setStyleSheet(build_qss(theme))
transport = None
if args.demo:
transport = DemoTransport()
connection = dict(prefs.get("connection") or {})
connection["mode"] = "demo"
prefs["connection"] = connection
window = AutoCalMainWindow(prefs=prefs)
if transport is not None:
window.poller.service._transport = transport # noqa: SLF001 - deliberate swap
window.show()
logger.info(
"AutoCal started (version=%s shared=%s api=%s binding=PySide6 %s "
"theme=%s language=%s demo=%s)",
app_version,
shared_version,
API_VERSION,
_pyside_version(),
theme,
language,
args.demo,
)
if args.demo:
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
code = int(app.exec())
# Leave the process immediately. The ZMotion runtime keeps native threads of
# its own, so a normal interpreter shutdown can hang after the window closes;
# everything durable was already flushed by closeEvent and the log handlers.
logging.shutdown()
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except Exception: # noqa: BLE001 - a closed stream must not block exit
pass
os._exit(code)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,58 @@
"""Platform control: models, guards, polling, and the Qt worker bridge."""
from .models import (
INIT_FAILED,
INIT_OK,
INIT_RUNNING,
INIT_TIMEOUT_S,
AxisParams,
AxisState,
AxisStatusBits,
BusStatus,
BusType,
ConnectionMode,
ConnectionSpec,
MotionResult,
Snapshot,
)
from .poller import MotionPoller
from .service import PLATFORM_AXES, MotionService
from .transport import DemoTransport, NativeTransport, ZmcTransport
from .units import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
DEFAULT_UNITS,
PULSES_PER_MM,
mm_to_user_units,
safe_float,
user_units_to_mm,
)
__all__ = [
"DEFAULT_ACCEL",
"DEFAULT_SPEED",
"DEFAULT_UNITS",
"INIT_FAILED",
"INIT_OK",
"INIT_RUNNING",
"INIT_TIMEOUT_S",
"PLATFORM_AXES",
"PULSES_PER_MM",
"AxisParams",
"AxisState",
"AxisStatusBits",
"BusStatus",
"BusType",
"ConnectionMode",
"ConnectionSpec",
"DemoTransport",
"MotionPoller",
"MotionResult",
"MotionService",
"NativeTransport",
"Snapshot",
"ZmcTransport",
"mm_to_user_units",
"safe_float",
"user_units_to_mm",
]
@@ -0,0 +1,77 @@
"""Translate controller return codes into something an operator can act on.
Only the codes the vendor actually documents are given a meaning here. The
`sdk header <../../resources/zmotion/x64/zauxdll2.h>`_ defines the auxiliary
error block at offset 30000:
#define ERR_NOACK ERR_AUX_OFFSET //无应答
#define ERR_ACKERROR (ERR_AUX_OFFSET+1) //应答错误
#define ERR_AUX_PARAERR (ERR_AUX_OFFSET+2) //参数错误
#define ERR_AUX_NOTSUPPORT (ERR_AUX_OFFSET+3) //不支持
#define ERR_AUX_FILE_ERROR (ERR_AUX_OFFSET+4) //参数错误
#define ERR_AUX_OS_ERR (ERR_AUX_OFFSET+5) //
Everything else is the controller's own number. :func:`describe` deliberately
does **not** invent a meaning for those: it names the number and points at
ZDevelop, which is where the firmware's code table lives. A guessed message on
a motion controller is worse than "unknown".
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
#: Auxiliary (host-side) error block offset, from ``zauxdll2.h``.
ERR_AUX_OFFSET = 30000
#: Meaning per code, as ``(english, chinese)``.
MESSAGES: dict[int, tuple[str, str]] = {
ERR_AUX_OFFSET: ("No response from the controller", "控制器无应答"),
ERR_AUX_OFFSET + 1: ("The controller rejected the command", "应答错误"),
ERR_AUX_OFFSET + 2: ("Wrong parameter", "参数错误"),
ERR_AUX_OFFSET + 3: ("Not supported by this model or firmware", "不支持该功能"),
ERR_AUX_OFFSET + 4: ("File error", "文件错误"),
ERR_AUX_OFFSET + 5: ("Operating-system error in the library", "库内操作系统错误"),
}
#: The last-resort advice appended to every unknown code.
UNKNOWN_HINT = (
"Look this number up in ZDevelop's error table (控制器错误码表), "
"or run the same command from ZDevelop to see the decoded fault."
)
def describe(code: int, *, language: str = "zh_CN") -> str:
"""A human-readable message for a controller return code.
Args:
code: the value returned by a ``ZAux_*`` call.
language: ``zh_CN`` for Chinese, anything else for English.
Returns:
``"0"`` is reported as success; known codes get a translated message;
unknown codes keep their number visible rather than being guessed at.
"""
code = int(code)
if code == 0:
return "成功" if language == "zh_CN" else "OK"
index = 1 if language == "zh_CN" else 0
known = MESSAGES.get(code)
if known is None:
# An unknown code that still resolves to an auxiliary slot is more
# useful than a bare integer, but only when it is documented.
logger.debug("Undocumented ZMotion return code %s", code)
prefix = "错误码" if language == "zh_CN" else "Error code"
return f"{prefix} {code}"
return known[index]
def describe_with_hint(code: int, *, language: str = "zh_CN") -> str:
"""As :func:`describe`, plus the ZDevelop pointer for unknown codes."""
message = describe(code, language=language)
if int(code) == 0 or int(code) in MESSAGES:
return message
return f"{message} — {UNKNOWN_HINT}"
@@ -0,0 +1,180 @@
"""Motion-control domain model.
Plain frozen dataclasses and no Qt: the service layer and the tests both use
these, and neither needs a GUI to do it.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
#: Which physical transport the controller is reached over.
ConnectionMode = Literal["eth", "com", "pci", "local", "demo"]
#: Bus families the ``.bas`` programs report through ``BUS_TYPE``.
BusType = Literal["unknown", "ecat", "rtex"]
#: The vendor reference's window between "BAS downloaded" and a final status.
INIT_TIMEOUT_S = 25.0
#: ``Bus_InitStatus`` values, from ``ECAT.bas``.
INIT_RUNNING = -1
INIT_FAILED = 0
INIT_OK = 1
@dataclass(frozen=True)
class ConnectionSpec:
"""How to reach the controller."""
mode: ConnectionMode
ip: str = ""
com_id: int = 0
card_num: int = 0
local_string: str = "0"
def describe(self) -> str:
if self.mode == "eth":
return self.ip or "(no IP)"
if self.mode == "com":
return f"COM{self.com_id}"
if self.mode == "pci":
return f"PCI card {self.card_num}"
if self.mode == "local":
return f"local {self.local_string}"
return "demo (no hardware)"
@dataclass(frozen=True)
class AxisParams:
"""Per-axis parameters as entered by the operator."""
units: float = 10.0
speed: float = 1000.0
accel: float = 10000.0
creep: float = 10.0
home_mode: int = 0
home_high: float = 50.0
home_low: float = 10.0
home_offset: float = 0.0
@dataclass(frozen=True)
class AxisStatusBits:
"""Decoded ``ZAux_Direct_GetAxisStatus`` word.
Only the bits the AutoCal panels display are named. Bit 6 is the one the
vendor reference tests (``status & 64``) to decide that homing is running.
"""
raw: int = 0
@property
def home_in_progress(self) -> bool:
"""Bit 6 — set while the drive is executing a homing move."""
return bool(self.raw & 64)
@property
def moving(self) -> bool:
"""Bit 1 — the axis is producing motion."""
return bool(self.raw & 2)
@property
def alarm(self) -> bool:
"""Bit 9 — the drive is reporting a fault."""
return bool(self.raw & 512)
def active_bits(self) -> list[int]:
"""Every set bit position, for the status column's tooltip."""
return [bit for bit in range(32) if self.raw & (1 << bit)]
@dataclass(frozen=True)
class AxisState:
"""One axis as read back from the controller."""
axis: int
enabled: bool = False
atype: int = 0
units: float = 0.0
speed: float = 0.0
accel: float = 0.0
dpos: float = 0.0
mpos: float = 0.0
status: AxisStatusBits = field(default_factory=AxisStatusBits)
idle: bool = True
home_status: int = 0
read_ok: bool = True
error: str = ""
@dataclass(frozen=True)
class BusStatus:
"""Bus-level state, from the ``.bas`` program's globals."""
bus_type: BusType = "unknown"
init_status: int = INIT_RUNNING
node_count: int = 0
total_axes: int = 0
bas_path: str = ""
bas_loaded: bool = False
init_started_at: float | None = None
message: str = ""
@property
def initialized(self) -> bool:
return self.init_status == INIT_OK
@property
def supports_sdo(self) -> bool:
return self.bus_type == "ecat"
@property
def supports_rtex(self) -> bool:
return self.bus_type == "rtex"
def timed_out(self, now: float) -> bool:
"""True while a BAS run started long ago has still not reported."""
if self.init_status != INIT_RUNNING or self.init_started_at is None:
return False
return (now - self.init_started_at) > INIT_TIMEOUT_S
@dataclass(frozen=True)
class Snapshot:
"""Everything the UI needs for one refresh."""
connected: bool = False
spec: ConnectionSpec | None = None
bus: BusStatus = field(default_factory=BusStatus)
axes: tuple[AxisState, ...] = ()
error: str = ""
def axis(self, index: int) -> AxisState | None:
for state in self.axes:
if state.axis == index:
return state
return None
@dataclass(frozen=True)
class MotionResult:
"""Outcome of a command, with a reason when it was refused or failed."""
ok: bool
code: int = 0
message: str = ""
@classmethod
def success(cls, message: str = "") -> "MotionResult":
return cls(ok=True, code=0, message=message)
@classmethod
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":
return cls(ok=False, code=int(code), message=message)
@@ -0,0 +1,312 @@
"""Owns the controller connection on a worker thread.
Why a thread and not a ``QTimer`` in the GUI thread: ``ZAux_BasDown`` (a ROM
write) and a bus scan block for seconds, and the bus-init sequence blocks for
three; a synchronous call on the GUI thread freezes the window for that whole
time. The Qt signal boundary here is the *only* way the UI reaches the
controller, which also means there is exactly one thread making DLL calls —
the ZMotion handle is not safe to share.
Contract:
* Creating the poller touches no hardware and loads no DLL.
* :meth:`MotionPoller.submit` queues a service call; the result comes back on
``commandFinished``/``commandFailed``, and commands run in submission order.
* Only one service call is in flight at once, because the worker is a single
thread and each due step performs one control action.
* A call that raises is reported, not fatal: the timer keeps running and the
next snapshot still arrives.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Iterable
from PySide6.QtCore import (
QMetaObject,
QObject,
Qt,
QThread,
QTimer,
Signal,
Slot,
)
from .models import Snapshot
from .service import PLATFORM_AXES, MotionService
logger = logging.getLogger(__name__)
#: Default UI refresh interval, matching the reference program's 100 ms timer.
DEFAULT_INTERVAL_MS = 100
class _Worker(QObject):
"""Lives on the poller thread. Every controller call happens here."""
snapshotReady = Signal(object)
commandFinished = Signal(int, object)
commandFailed = Signal(int, str)
connected = Signal(object)
disconnected = Signal()
def __init__(self, service: MotionService, axes: Iterable[int] | None) -> None:
super().__init__()
self._service = service
self._axes = list(axes) if axes is not None else None
# Both are created in ``warm_up``, which runs on the worker thread: a
# QTimer must be created, started and stopped from the thread that owns
# it, and this object is constructed on the GUI thread.
self._timer: QTimer | None = None
self._timer_loop: QTimer | None = None
self._interval_ms = DEFAULT_INTERVAL_MS
self._pending: list[tuple[int, Callable[[], Any]]] = []
self._counter = 0
self._last_snapshot_at = 0.0
# ------------------------------------------------------------ thread setup
@Slot()
def warm_up(self) -> None:
"""Create the timers and start polling.
Invoked on the worker thread, once, at thread start-up — and taking no
arguments precisely so it can be, because marshalling an argument across
the meta-object system differs between the Qt bindings.
"""
if self._timer is None:
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
# A zero-interval timer that always stays active. Its only job is to
# keep the thread's event loop turning (``QThread.exec`` returns as
# soon as no events are left, even though the thread stays alive), so
# a queued ``submit`` is delivered even while polling is stopped.
self._timer_loop = QTimer(self)
self._timer_loop.timeout.connect(lambda: None)
self._timer_loop.start(0)
self._timer.setInterval(self._interval_ms)
if not self._timer.isActive():
self._timer.start()
def start_polling(self, interval_ms: int = DEFAULT_INTERVAL_MS) -> None:
"""Start (or retune) the poll timer. Must run on the worker thread."""
self._interval_ms = max(20, int(interval_ms))
if self._timer is None:
self.warm_up()
assert self._timer is not None
self._timer.setInterval(self._interval_ms)
if not self._timer.isActive():
self._timer.start()
@Slot()
def stop_polling(self) -> None:
if self._timer is not None:
self._timer.stop()
if self._timer_loop is not None:
self._timer_loop.stop()
@Slot()
def cleanup(self) -> None:
"""Destroy the timers on the thread that owns them.
A QTimer stopped or deleted from another thread warns ("Timers cannot be
stopped from another thread") and leaves the timestamp bookkeeping in an
undefined state. Since these are parented to the worker, they would
otherwise be destroyed with it, from whichever thread drops the last
reference to the worker.
"""
for timer in (self._timer, self._timer_loop):
if timer is not None:
timer.stop()
timer.deleteLater()
self._timer = None
self._timer_loop = None
def set_axes(self, axes: Iterable[int]) -> None:
self._axes = list(axes)
def set_language(self, language: str) -> None:
self._service.set_language(language)
# ------------------------------------------------------------------ queue
def submit(self, action: Callable[[MotionService], Any]) -> int:
"""Queue ``action``; returns the id its result will be reported under.
Queuing is thread-safe (list append under the GIL); the *work* is not
done here. ``invokeMethod`` with a queued connection is what runs
:meth:`_tick` on the worker thread, so no controller call ever happens
on the caller's thread — which matters twice over, because the DLL is
single-threaded and a blocking call would freeze the window.
"""
self._counter += 1
token = self._counter
self._pending.append((token, action))
QMetaObject.invokeMethod(self, "_tick", Qt.ConnectionType.QueuedConnection)
return token
@Slot()
def _tick(self) -> None:
"""One poll cycle: run at most one queued command, then publish state.
One control action per step, then a snapshot. Draining the whole queue
here would make a slow BAS download delay every later command with no
feedback in between.
Qt looks this slot up by name (see ``submit``), so it keeps both the
decorator and the leading underscore.
"""
if self._pending:
token, action = self._pending.pop(0)
try:
result = action(self._service)
except Exception as exc: # noqa: BLE001 - report, never kill the thread
logger.exception("Controller command %s failed", token)
self.commandFailed.emit(token, self._describe(exc))
else:
self.commandFinished.emit(token, result)
self._emit_snapshot()
def _emit_snapshot(self) -> None:
try:
snapshot = self._service.poll(self._axes)
except Exception as exc: # noqa: BLE001 - a poll must never stop the timer
logger.exception("Polling the controller failed")
snapshot = Snapshot(
connected=self._service.connected,
spec=self._service.spec,
bus=self._service.bus,
error=self._describe(exc),
)
self.snapshotReady.emit(snapshot)
def _describe(self, exc: Exception) -> str:
return self._service.describe_error(exc)
class MotionPoller(QObject):
"""GUI-thread facade over the worker."""
snapshotReady = Signal(object)
commandFinished = Signal(int, object)
commandFailed = Signal(int, str)
connectionChanged = Signal(bool)
def __init__(
self,
service: MotionService | None = None,
*,
axes: Iterable[int] | None = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self.service = service or MotionService()
self._axes = list(axes) if axes is not None else list(
range(self.service.axis_count or PLATFORM_AXES)
)
self._thread = QThread()
self._worker = _Worker(self.service, self._axes)
self._worker.moveToThread(self._thread)
self._worker.snapshotReady.connect(self.snapshotReady)
self._worker.commandFinished.connect(self.commandFinished)
self._worker.commandFailed.connect(self.commandFailed)
self._worker.connected.connect(lambda _spec: self.connectionChanged.emit(True))
self._worker.disconnected.connect(lambda: self.connectionChanged.emit(False))
# Kicks the worker's event loop into life on the new thread.
self._thread.started.connect(self._on_thread_started)
self._thread.start()
self._interval_ms = DEFAULT_INTERVAL_MS
def _on_thread_started(self) -> None:
"""Ask the worker to build its timers, on its own thread.
``QMetaObject.invokeMethod`` with a queued connection is used rather
than calling :meth:`_Worker.warm_up` here: this slot runs on the GUI
thread (the receiver is ``self``), and a QTimer created here would then
belong to the GUI thread while being started and stopped from the
worker — which Qt refuses at run time.
"""
QMetaObject.invokeMethod(
self._worker, "warm_up", Qt.ConnectionType.QueuedConnection
)
@property
def interval_ms(self) -> int:
return self._interval_ms
def set_interval_ms(self, value: int) -> None:
"""Change the refresh rate. Callable from the GUI thread."""
self._interval_ms = max(20, int(value))
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
def set_axes(self, axes: Iterable[int]) -> None:
self._axes = list(axes)
self._submit_void(lambda worker: worker.set_axes(self._axes))
def set_language(self, language: str) -> None:
self._submit_void(lambda worker: worker.set_language(language))
def pause(self) -> None:
"""Stop the periodic state poll.
The connection is untouched: pausing only stops asking. The event loop
keeps turning, so queued commands and shutdown still work while paused.
"""
self._paused = True
self._submit_void(lambda worker: worker.stop_polling())
def resume(self) -> None:
"""Resume the periodic state poll after :meth:`pause`."""
self._paused = False
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
@property
def paused(self) -> bool:
return self._paused
#: Whether the caller has paused the poll (see :meth:`pause`).
_paused = False
def _submit_void(self, action: Callable[[_Worker], None]) -> None:
"""Hand a fire-and-forget call to the worker through its event loop."""
self._worker.submit(lambda _service: action(self._worker))
def submit(self, action: Callable[[MotionService], Any]) -> int:
"""Queue a service call. Returns the id it will be reported under."""
return self._worker.submit(action)
def connect_controller(self, spec) -> int:
return self.submit(lambda service: service.connect(spec))
def disconnect_controller(self) -> int:
def _disconnect(service: MotionService):
# Stop the axes before closing the handle: the reference program
# closes immediately and leaves the platform coasting.
service.stop_all()
service.disconnect()
return None
return self.submit(_disconnect)
def shutdown(self, timeout_ms: int = 5000) -> None:
"""Stop polling and join the thread. Safe to call more than once."""
if not self._thread.isRunning():
return
# Both hops must run on the worker thread, because that is where the
# timers were created. ``cleanup`` is blocking on purpose: it has to
# complete *before* ``quit`` can take the event loop down, and waiting
# for it also proves the worker has stopped touching the controller.
QMetaObject.invokeMethod(
self._worker, "cleanup", Qt.ConnectionType.BlockingQueuedConnection
)
self._thread.quit()
if not self._thread.wait(timeout_ms):
logger.warning(
"Poller thread did not stop within %sms; it will be left to the "
"process exit path",
timeout_ms,
)
@@ -0,0 +1,670 @@
"""Platform control logic: connection, bus init, axis commands.
Pure Python and Qt-free, so the whole guard/state-machine layer is testable
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.
"""
from __future__ import annotations
import logging
import math
import time
import os
from typing import Callable, Iterable
from orisys_app_shared.zmotion import ZAuxError
from . import errors
from .models import (
INIT_FAILED,
INIT_RUNNING,
INIT_TIMEOUT_S,
AxisParams,
AxisState,
AxisStatusBits,
BusStatus,
BusType,
ConnectionSpec,
MotionResult,
Snapshot,
)
from .transport import DemoTransport, ZmcTransport, NativeTransport
from .units import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
DEFAULT_UNITS,
PULSES_PER_MM,
safe_float,
user_units_to_mm,
)
logger = logging.getLogger(__name__)
#: The platform has five axes; the bus may report fewer if a drive is missing.
PLATFORM_AXES = 5
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
class MotionService:
"""The controller session: one connection, five axes, one state machine."""
def __init__(
self,
transport: ZmcTransport | None = None,
*,
axis_count: int = PLATFORM_AXES,
units_per_mm: float = DEFAULT_UNITS,
pulses_per_mm: float = PULSES_PER_MM,
language: str = "zh_CN",
) -> None:
self._transport: ZmcTransport = transport or NativeTransport()
self.axis_count = int(axis_count)
self._units_per_mm = safe_float(units_per_mm, DEFAULT_UNITS)
self._pulses_per_mm = pulses_per_mm
self.language = language
self._spec: ConnectionSpec | None = None
self._bus = BusStatus()
self._params: dict[int, AxisParams] = {
axis: AxisParams() for axis in range(self.axis_count)
}
self._bas_loaded = False
self._init_started_at: float | None = None
self._init_timeout = INIT_TIMEOUT_S
self._home_started: dict[int, float] = {}
self._home_done: dict[int, bool] = {}
# ------------------------------------------------------------- properties
@property
def transport(self) -> ZmcTransport:
return self._transport
@property
def connected(self) -> bool:
return self._spec is not None
@property
def spec(self) -> ConnectionSpec | None:
return self._spec
@property
def bus(self) -> BusStatus:
return self._bus
@property
def bas_loaded(self) -> bool:
return self._bas_loaded
def params(self, axis: int) -> AxisParams:
return self._params.get(axis, AxisParams())
def set_language(self, language: str) -> None:
self.language = language
def _msg(self, code: int) -> str:
return errors.describe_with_hint(code, language=self.language)
def describe_error(self, exc: Exception) -> str:
"""Localised text for a failed call, for the UI and the poller.
A ``ZAuxError`` carries a controller code that :mod:`.errors` can
translate; anything else (a missing DLL, a socket error) is reported
as-is, which is where the "searched these paths" detail arrives.
"""
code = getattr(exc, "code", 0)
return self._msg(code) if code else str(exc)
# ------------------------------------------------------------ connect/init
def connect(self, spec: ConnectionSpec) -> MotionResult:
"""Open the controller.
A previous connection is closed first: switching transport type without
closing leaks the DLL handle, which the reference program does.
"""
if self._spec is not None:
self.disconnect()
try:
self._transport.open(spec)
except (ZAuxError, OSError, ValueError) as exc:
code = getattr(exc, "code", 0)
self._spec = None
message = self._msg(code) if code else str(exc)
logger.warning("Connect to %s failed: %s", spec.describe(), exc)
return MotionResult.failed(code, message)
self._spec = spec
logger.info("Connected: %s via %s", spec.describe(), self._transport.source)
# A controller may already have a BAS program running from a previous
# session; adopt it rather than claiming the bus is uninitialised.
self._adopt_existing_bas()
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
@property
def _is_zh(self) -> bool:
return self.language == "zh_CN"
def _adopt_existing_bas(self) -> None:
try:
bus_type = self._transport.user_var("BUS_TYPE")
except (ZAuxError, OSError):
self._bas_loaded = False
self._bus = BusStatus(bus_type="unknown", init_status=INIT_RUNNING)
return
self._bas_loaded = True
self._bus = BusStatus(
bus_type=_BUS_TYPE_NAMES.get(int(bus_type), "unknown"),
init_status=INIT_RUNNING,
bas_loaded=True,
)
def disconnect(self) -> None:
try:
self._transport.close()
except (ZAuxError, OSError): # pragma: no cover - close is best-effort
logger.exception("Closing the controller connection failed")
self._spec = None
self._bas_loaded = False
self._init_started_at = None
self._home_started.clear()
self._home_done.clear()
self._bus = BusStatus()
def download_bas(self, filename: str, run_mode: int = 1) -> MotionResult:
"""Download a ``.bas`` program to the controller and start its scan.
``run_mode=1`` writes to ROM, so the controller re-runs the program on
power-up as well; the vendor ``ECAT.bas`` delays 3 s, scans the slot,
then sets ``Bus_InitStatus``. The UI follows that through
:func:`poll`.
"""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not filename or not os.path.isfile(filename):
return MotionResult.refused(
self._text(f"文件不存在: {filename}", f"File not found: {filename}")
)
try:
self._transport.bas_download(filename, run_mode)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
self._bas_loaded = False
self._init_started_at = None
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._bas_loaded = True
self._init_started_at = time.monotonic()
self._bus = BusStatus(
bus_type=self._bus.bus_type,
init_status=INIT_RUNNING,
bas_path=filename,
bas_loaded=True,
init_started_at=self._init_started_at,
)
# Read BUS_TYPE so the SDO/RTEX panels enable for the right family.
try:
bus_type = self._transport.user_var("BUS_TYPE")
self._bus = _replace_bus_type(self._bus, _BUS_TYPE_NAMES.get(int(bus_type), "unknown"))
except (ZAuxError, OSError):
pass
return MotionResult.success(
self._text(
"BAS 已下载,控制器将延时扫描总线",
"BAS downloaded; the controller will scan the bus after its delay",
)
)
def reinit_bus(self) -> MotionResult:
"""Re-run the BAS program's init task without re-downloading it."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._bas_loaded or not self._bus.bas_path:
return MotionResult.refused(
self._text(
"Bas文件未加载,请先下载 ECAT.bas 或 RTEX.bas",
"No BAS program loaded; download ECAT.bas or RTEX.bas first",
)
)
if self._bus.init_status == INIT_RUNNING and not self._bus.timed_out(time.monotonic()):
return MotionResult.refused(
self._text("总线正在初始化,请稍候", "Bus initialisation is already running")
)
script = os.path.basename(self._bus.bas_path)
try:
self._transport.execute("STOPTASK 2")
self._transport.execute(f'RUN "{script}",4')
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._init_started_at = time.monotonic()
self._bus = BusStatus(
bus_type=self._bus.bus_type,
init_status=INIT_RUNNING,
bas_path=self._bus.bas_path,
bas_loaded=True,
init_started_at=self._init_started_at,
)
return MotionResult.success(
self._text("正在重新初始化总线", "Re-initialising the bus")
)
def _text(self, zh: str, en: str) -> str:
return zh if self._is_zh else en
# ----------------------------------------------------------------- polling
def poll(self, axes: Iterable[int] | None = None) -> Snapshot:
"""Read bus and axis state for one UI refresh.
Never raises: a controller that has gone away produces a snapshot with
``error`` set and the last known values, because a poll runs on a timer
and an exception there would either kill the timer or spam it.
"""
if not self.connected:
return Snapshot(connected=False, spec=self._spec, bus=self._bus)
wanted = list(axes) if axes is not None else list(range(self.axis_count))
error = ""
try:
self._refresh_bus()
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
error = self._msg(code) if code else str(exc)
logger.warning("Bus poll failed: %s", exc)
states: list[AxisState] = []
for axis in wanted:
try:
states.append(self._read_axis(axis))
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
states.append(
AxisState(
axis=axis,
read_ok=False,
error=self._msg(code) if code else str(exc),
)
)
return Snapshot(
connected=True,
spec=self._spec,
bus=self._bus,
axes=tuple(states),
error=error,
)
def _refresh_bus(self) -> None:
now = time.monotonic()
bus = self._bus
init_started = self._init_started_at
# BUS_TYPE is present as soon as a BAS program has run at all.
bus_type = bus.bus_type
try:
raw_type = self._transport.user_var("BUS_TYPE")
bus_type = _BUS_TYPE_NAMES.get(int(raw_type), "unknown")
self._bas_loaded = True
except (ZAuxError, OSError):
# Between "downloaded" and the program's first instruction the
# globals do not exist yet, and they may never appear if the
# program failed to start. That is the timeout case, not a reason
# to forget that a download happened.
if init_started is None:
self._bas_loaded = False
if not self._bas_loaded:
self._bus = BusStatus(
bus_type=bus_type, init_status=INIT_RUNNING, bas_loaded=False
)
return
init_status = bus.init_status
node_count = bus.node_count
total_axes = bus.total_axes
message = ""
try:
init_status = int(self._transport.user_var("Bus_InitStatus"))
except (ZAuxError, OSError) as exc:
if init_started is not None and (now - init_started) > self._init_timeout:
# The vendor's own 25 s window has elapsed without the program
# publishing its globals: report a timeout, with the diagnostic
# the reference program prints, rather than raising on a timer.
init_status = INIT_FAILED
message = self._text(
"初始化超时:读不到 BUS_TYPE/Bus_InitStatus,"
"请用 ZDevelop 查看 ?*ETHERCAT",
"Initialisation timed out: BUS_TYPE/Bus_InitStatus unreadable. "
"Check ?*ETHERCAT in ZDevelop.",
)
logger.warning("Bus init timed out after %.0fs: %s", self._init_timeout, exc)
self._init_started_at = None
self._bas_loaded = False
else:
self._bus = BusStatus(
bus_type=bus_type,
init_status=INIT_RUNNING,
bas_path=bus.bas_path,
bas_loaded=True,
init_started_at=init_started,
)
return
if init_status != INIT_RUNNING:
self._init_started_at = None
try:
node_count = self._transport.bus_node_count(0)
total_axes = int(round(self._transport.user_var("Bus_TotalAxisnum")))
except (ZAuxError, OSError):
pass
self._bus = BusStatus(
bus_type=bus_type,
init_status=init_status,
node_count=node_count,
total_axes=total_axes,
bas_path=bus.bas_path,
bas_loaded=True,
init_started_at=self._init_started_at,
message=message,
)
def _read_axis(self, axis: int) -> AxisState:
transport = self._transport
status_raw = transport.get_axis_status(axis)
idle = transport.get_if_idle(axis) == -1
status = AxisStatusBits(status_raw)
# Track the homing edge the same way the reference program does: bit 6
# set means homing is running, and the falling edge means ask the drive
# whether it actually completed. Between those two, report the last
# known answer rather than claiming homing either way.
home_status = 1 if self._home_done.get(axis, False) else 0
if status.home_in_progress:
self._home_started[axis] = self._home_started.get(axis, time.monotonic())
self._home_done[axis] = False
home_status = 0
elif axis in self._home_started:
del self._home_started[axis]
try:
home_status = int(transport.bus_home_status(axis))
except (ZAuxError, OSError):
home_status = 0
self._home_done[axis] = home_status == 1
params = self.params(axis)
return AxisState(
axis=axis,
enabled=transport.get_axis_enable(axis) != 0,
atype=transport.get_atype(axis),
units=transport.get_units(axis),
speed=transport.get_speed(axis),
accel=transport.get_accel(axis),
dpos=transport.get_dpos(axis),
mpos=transport.get_mpos(axis),
status=status,
idle=idle,
home_status=home_status,
read_ok=True,
)
# -------------------------------------------------------------- axis setup
def set_axis_params(self, axis: int, params: AxisParams) -> MotionResult:
"""Apply units/speed/accel to the controller and remember them."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.set_units(axis, params.units)
self._transport.set_speed(axis, params.speed)
self._transport.set_accel(axis, params.accel)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._params[axis] = params
return MotionResult.success()
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.set_axis_enable(axis, enabled)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
)
def clear_alarm(self, axis: int) -> MotionResult:
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.bus_drive_clear(axis, 0)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(self._text("已清除报警", "Alarm cleared"))
# ------------------------------------------------------------------ motion
def _motion_guard(self, axis: int) -> MotionResult | None:
"""Return the refusal for a motion command, or ``None`` if it may run."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
if not self._bus.initialized:
return MotionResult.refused(
self._text(
"总线未初始化完成,禁止运动",
"Bus is not initialised; motion is refused",
)
)
return None
def jog(self, axis: int, forward: bool) -> MotionResult:
refusal = self._motion_guard(axis)
if refusal is not None:
return refusal
try:
self._transport.jog(axis, forward)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
)
def move_abs(self, axis: int, target: float) -> MotionResult:
return self._move(axis, target, absolute=True)
def move_rel(self, axis: int, distance: float) -> MotionResult:
return self._move(axis, distance, absolute=False)
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
refusal = self._motion_guard(axis)
if refusal is not None:
return refusal
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
return MotionResult.refused(
self._text("目标位置请输入数字", "Enter a numeric position")
)
try:
if absolute:
self._transport.move_abs(axis, float(value))
else:
self._transport.move_rel(axis, float(value))
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success()
def stop(self, axis: int) -> MotionResult:
"""Stop one axis (decelerating stop, mode 2)."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.stop(axis, 2)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(self._text("已停止", "Stopped"))
def stop_all(self) -> list[MotionResult]:
"""Stop every axis. Used before disconnecting."""
return [self.stop(axis) for axis in range(self.axis_count)]
def home(self, axis: int) -> MotionResult:
"""Start driver homing (EtherCAT only, as the vendor example notes).
Speeds and offset are written before the mode: the controller latches
them when homing starts, so setting them afterwards has no effect on
the move already in progress.
"""
refusal = self._motion_guard(axis)
if refusal is not None:
return refusal
if not self._bus.supports_sdo:
return MotionResult.refused(
self._text("rtex不支持回零", "Homing is not supported over RTEX")
)
params = self.params(axis)
if params.home_mode <= 0:
return MotionResult.refused(
self._text(
f"轴{axis} 回零模式未设置(0 不是有效模式)",
f"Axis {axis} has no homing mode set (0 is not a valid mode)",
)
)
try:
self._transport.set_speed(axis, params.home_high)
self._transport.set_creep(axis, params.home_low)
self._transport.bus_set_datum_offset(axis, params.home_offset)
self._transport.bus_datum(axis, params.home_mode)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._home_started[axis] = time.monotonic()
self._home_done[axis] = False
return MotionResult.success(self._text("回零已启动", "Homing started"))
def _known_axis(self, axis: int) -> bool:
return 0 <= int(axis) < self.axis_count
def _axis_range_message(self, axis: int) -> str:
return self._text(
f"轴号超出范围 (0-{self.axis_count - 1})",
f"Axis out of range (0-{self.axis_count - 1})",
)
# ---------------------------------------------------------------- bus data
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int
) -> tuple[MotionResult, int]:
"""Read an EtherCAT object; returns the result and the value read."""
refusal = self._bus_data_guard("ecat")
if refusal is not None:
return refusal, 0
try:
value = self._transport.sdo_read(node, index, subindex, data_type)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0
return MotionResult.success(), int(value)
def sdo_write(
self, node: int, index: int, subindex: int, data_type: int, value: int
) -> MotionResult:
return self._bus_write(
"ecat",
lambda: self._transport.sdo_write(node, index, subindex, data_type, int(value)),
)
def rtex_read(self, axis: int, parameter: int) -> tuple[MotionResult, float]:
refusal = self._bus_data_guard("rtex")
if refusal is not None:
return refusal, 0.0
try:
value = self._transport.rtex_read(axis, parameter)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0.0
return MotionResult.success(), float(value)
def rtex_write(self, axis: int, parameter: int, value: float) -> MotionResult:
return self._bus_write(
"rtex", lambda: self._transport.rtex_write(axis, parameter, float(value))
)
def _bus_data_guard(self, family: BusType) -> MotionResult | None:
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if self._bus.bus_type == "unknown":
return MotionResult.refused(
self._text(
"总线类型未知,请先初始化总线",
"Bus type is unknown; initialise the bus first",
)
)
if self._bus.bus_type != family:
other = "ETHERCAT" if family == "rtex" else "RTEX"
return MotionResult.refused(
self._text(f"非{other}模块", f"This is not a {other} module")
)
return None
def _bus_write(self, family: BusType, action: Callable[[], None]) -> MotionResult:
refusal = self._bus_data_guard(family)
if refusal is not None:
return refusal
try:
action()
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success()
# ------------------------------------------------------------------ display
def to_mm(self, axis: int, user_units: float) -> float:
"""Displayed millimetres for a reading. Derived, never a measurement."""
units = self.params(axis).units or self._units_per_mm
return user_units_to_mm(
user_units, units=units, pulses_per_mm=self._pulses_per_mm
)
def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus:
return BusStatus(
bus_type=bus_type,
init_status=bus.init_status,
node_count=bus.node_count,
total_axes=bus.total_axes,
bas_path=bus.bas_path,
bas_loaded=bus.bas_loaded,
init_started_at=bus.init_started_at,
message=bus.message,
)
@@ -0,0 +1,467 @@
"""The seam between the platform-control logic and the controller DLL.
:class:`ZmcTransport` is what :mod:`~orisys_autocal.motion.service` talks to.
Contract: every method **raises** :class:`~orisys_app_shared.zmotion.ZAuxError`
when the controller returns non-zero, and returns plain Python values (never
ctypes objects) when it succeeds. That lets the service have one error path and
lets the tests inject a fake that fails on demand.
Three implementations:
* :class:`NativeTransport` — the real controller, via the vendored DLL.
* :class:`DemoTransport` — a self-consistent simulated platform, so the whole
UI can be exercised with no hardware and no DLL.
* ``FakeTransport`` in the app's test suite — the same protocol, driven by the
test rather than by a simulation.
"""
from __future__ import annotations
import math
import time
from typing import Protocol, runtime_checkable
from orisys_app_shared.zmotion import ZAuxError, ZmotionClient
from .errors import ERR_AUX_OFFSET
from .models import BusType, ConnectionSpec
@runtime_checkable
class ZmcTransport(Protocol):
"""What the service needs from a controller connection."""
#: Human-readable source, e.g. the DLL directory or ``"demo"``.
source: str
def open(self, spec: ConnectionSpec) -> None: ...
def close(self) -> None: ...
def search_eth(self) -> list[str]: ...
def bas_download(self, filename: str, run_mode: int = 1) -> None: ...
def execute(self, command: str) -> None: ...
def user_var(self, name: str) -> float: ...
def bus_node_count(self, slot: int = 0) -> int: ...
def bus_home_status(self, axis: int) -> int: ...
def bus_datum(self, axis: int, home_mode: int) -> None: ...
def bus_set_datum_offset(self, axis: int, value: float) -> None: ...
def bus_drive_clear(self, axis: int, mode: int = 0) -> None: ...
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
) -> int: ...
def sdo_write(
self,
node: int,
index: int,
subindex: int,
data_type: int,
value: int,
slot: int = 0,
) -> None: ...
def rtex_read(self, axis: int, parameter: int) -> float: ...
def rtex_write(self, axis: int, parameter: int, value: float) -> None: ...
def set_axis_enable(self, axis: int, enabled: bool) -> None: ...
def set_units(self, axis: int, value: float) -> None: ...
def get_units(self, axis: int) -> float: ...
def set_speed(self, axis: int, value: float) -> None: ...
def get_speed(self, axis: int) -> float: ...
def set_accel(self, axis: int, value: float) -> None: ...
def get_accel(self, axis: int) -> float: ...
def set_creep(self, axis: int, value: float) -> None: ...
def get_atype(self, axis: int) -> int: ...
def get_axis_enable(self, axis: int) -> int: ...
def get_dpos(self, axis: int) -> float: ...
def get_mpos(self, axis: int) -> float: ...
def get_axis_status(self, axis: int) -> int: ...
def get_if_idle(self, axis: int) -> int: ...
def jog(self, axis: int, forward: bool) -> None: ...
def move_abs(self, axis: int, position: float) -> None: ...
def move_rel(self, axis: int, distance: float) -> None: ...
def stop(self, axis: int, mode: int = 2) -> None: ...
class NativeTransport:
"""The real controller, through the vendored ZMotion runtime."""
def __init__(self, client: ZmotionClient | None = None) -> None:
self._client = client or ZmotionClient()
@property
def client(self) -> ZmotionClient:
return self._client
@property
def source(self) -> str:
return self._client.dll_dir or "zauxdll.dll (not loaded)"
def open(self, spec: ConnectionSpec) -> None:
if spec.mode == "eth":
result = self._client.open_eth(spec.ip)
elif spec.mode == "com":
result = self._client.open_com(spec.com_id)
elif spec.mode == "pci":
result = self._client.open_pci(spec.card_num)
elif spec.mode == "local":
result = self._client.fast_open(5, spec.local_string, 1000)
else:
raise ValueError(f"{spec.mode!r} is not a native connection mode")
result.raise_for_status(f"open {spec.describe()}")
def close(self) -> None:
self._client.close()
def search_eth(self) -> list[str]:
value = self._client.search_eth()
# A scan that finds nothing is not an error: the reference program
# lists the configured defaults and moves on.
if not value.ok:
return []
return [item for item in str(value.value).split() if item]
def bas_download(self, filename: str, run_mode: int = 1) -> None:
self._client.bas_download(filename, run_mode).raise_for_status("BAS download")
def execute(self, command: str) -> None:
self._client.execute(command).raise_for_status(command)
def user_var(self, name: str) -> float:
return float(self._client.get_user_var(name).raise_for_status(name).value)
def bus_node_count(self, slot: int = 0) -> int:
return int(self._client.bus_node_count(slot).raise_for_status().value)
def bus_home_status(self, axis: int) -> int:
return int(self._client.bus_home_status(axis).raise_for_status().value)
def bus_datum(self, axis: int, home_mode: int) -> None:
self._client.bus_datum(axis, home_mode).raise_for_status("datum")
def bus_set_datum_offset(self, axis: int, value: float) -> None:
self._client.bus_set_datum_offset(axis, value).raise_for_status()
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
self._client.bus_drive_clear(axis, mode).raise_for_status()
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
) -> int:
return int(
self._client.sdo_read(slot, node, index, subindex, data_type)
.raise_for_status("SDO read")
.value
)
def sdo_write(
self,
node: int,
index: int,
subindex: int,
data_type: int,
value: int,
slot: int = 0,
) -> None:
self._client.sdo_write(slot, node, index, subindex, data_type, value).raise_for_status(
"SDO write"
)
def rtex_read(self, axis: int, parameter: int) -> float:
return float(self._client.rtex_read(axis, parameter).raise_for_status().value)
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
self._client.rtex_write(axis, parameter, value).raise_for_status()
def set_axis_enable(self, axis: int, enabled: bool) -> None:
self._client.set_axis_enable(axis, 1 if enabled else 0).raise_for_status()
def set_units(self, axis: int, value: float) -> None:
self._client.set_units(axis, value).raise_for_status()
def get_units(self, axis: int) -> float:
return float(self._client.get_units(axis).raise_for_status().value)
def set_speed(self, axis: int, value: float) -> None:
self._client.set_speed(axis, value).raise_for_status()
def get_speed(self, axis: int) -> float:
return float(self._client.get_speed(axis).raise_for_status().value)
def set_accel(self, axis: int, value: float) -> None:
self._client.set_accel(axis, value).raise_for_status()
def get_accel(self, axis: int) -> float:
return float(self._client.get_accel(axis).raise_for_status().value)
def set_creep(self, axis: int, value: float) -> None:
self._client.set_creep(axis, value).raise_for_status()
def get_atype(self, axis: int) -> int:
return int(self._client.get_atype(axis).raise_for_status().value)
def get_axis_enable(self, axis: int) -> int:
return int(self._client.get_axis_enable(axis).raise_for_status().value)
def get_dpos(self, axis: int) -> float:
return float(self._client.get_dpos(axis).raise_for_status().value)
def get_mpos(self, axis: int) -> float:
return float(self._client.get_mpos(axis).raise_for_status().value)
def get_axis_status(self, axis: int) -> int:
return int(self._client.get_axis_status(axis).raise_for_status().value)
def get_if_idle(self, axis: int) -> int:
return int(self._client.get_if_idle(axis).raise_for_status().value)
def jog(self, axis: int, forward: bool) -> None:
self._client.single_vmove(axis, 1 if forward else -1).raise_for_status()
def move_abs(self, axis: int, position: float) -> None:
self._client.single_move_abs(axis, position).raise_for_status()
def move_rel(self, axis: int, distance: float) -> None:
self._client.single_move(axis, distance).raise_for_status()
def stop(self, axis: int, mode: int = 2) -> None:
self._client.single_cancel(axis, mode).raise_for_status()
class DemoTransport:
"""A self-consistent simulated 5-axis platform.
Not a mock: it keeps position, enable state and motion state so the UI's
polling, jogging and stop behaviour are all exercised without hardware.
``BUS_TYPE`` reports EtherCAT so the SDO panel is live too.
"""
#: Where jogging stops, in user units — keeps a demo jog bounded.
TRAVEL_LIMIT = 5000.0
def __init__(self, *, axes: int = 5) -> None:
self._axes = int(axes)
self._enabled = {axis: True for axis in range(self._axes)}
self._units = {axis: 10.0 for axis in range(self._axes)}
self._speed = {axis: 1000.0 for axis in range(self._axes)}
self._accel = {axis: 10000.0 for axis in range(self._axes)}
self._dpos = {axis: 0.0 for axis in range(self._axes)}
self._moving_until = {axis: 0.0 for axis in range(self._axes)}
self._homing_until = {axis: 0.0 for axis in range(self._axes)}
self._home_done = {axis: False for axis in range(self._axes)}
self._bas_loaded = False
self._init_started: float | None = None
self._opened = False
self._sdo: dict[tuple[int, int, int], int] = {}
@property
def source(self) -> str:
return "demo (simulated platform, no hardware)"
def open(self, spec: ConnectionSpec) -> None:
if spec.mode != "demo":
raise ZAuxError(ERR_DEMO_UNSUPPORTED, "DemoTransport only serves demo mode")
self._opened = True
def close(self) -> None:
self._opened = False
def search_eth(self) -> list[str]:
return ["127.0.0.1"]
def bas_download(self, filename: str, run_mode: int = 1) -> None:
if not self._opened:
raise ZAuxError(ERR_NOT_OPEN, "not connected")
self._bas_loaded = True
# ECAT.bas delays 3 s before scanning; mimic the delay so the UI's
# "initialising" state is visible rather than instantaneous.
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
def execute(self, command: str) -> None:
if not self._opened:
raise ZAuxError(ERR_NOT_OPEN, "not connected")
if "RUN" in command.upper() and self._bas_loaded:
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
def user_var(self, name: str) -> float:
if not self._bas_loaded:
# Mirrors the real controller: unknown/absent variable.
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
if name == "BUS_TYPE":
return 0.0
if name == "Bus_TotalAxisnum":
return float(self._axes)
if name == "Bus_InitStatus":
if self._init_started is not None and time.monotonic() >= self._init_started:
return 1.0
return -1.0
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
def _require_axis(self, axis: int) -> None:
if not (0 <= axis < self._axes):
raise ZAuxError(ERR_AUX_OFFSET + 2, f"axis {axis} does not exist")
def bus_node_count(self, slot: int = 0) -> int:
return self._axes
def bus_home_status(self, axis: int) -> int:
self._require_axis(axis)
return 1 if self._home_done[axis] else 0
def bus_datum(self, axis: int, home_mode: int) -> None:
self._require_axis(axis)
self._homing_until[axis] = time.monotonic() + DEMO_HOME_S
def bus_set_datum_offset(self, axis: int, value: float) -> None:
self._require_axis(axis)
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
self._require_axis(axis)
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
) -> int:
return self._sdo.get((node, index, subindex), 0)
def sdo_write(
self,
node: int,
index: int,
subindex: int,
data_type: int,
value: int,
slot: int = 0,
) -> None:
self._sdo[(node, index, subindex)] = int(value)
def rtex_read(self, axis: int, parameter: int) -> float:
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
def set_axis_enable(self, axis: int, enabled: bool) -> None:
self._require_axis(axis)
self._enabled[axis] = bool(enabled)
def set_units(self, axis: int, value: float) -> None:
self._require_axis(axis)
self._units[axis] = float(value)
def get_units(self, axis: int) -> float:
self._require_axis(axis)
return self._units[axis]
def set_speed(self, axis: int, value: float) -> None:
self._require_axis(axis)
self._speed[axis] = float(value)
def get_speed(self, axis: int) -> float:
self._require_axis(axis)
return self._speed[axis]
def set_accel(self, axis: int, value: float) -> None:
self._require_axis(axis)
self._accel[axis] = float(value)
def get_accel(self, axis: int) -> float:
self._require_axis(axis)
return self._accel[axis]
def set_creep(self, axis: int, value: float) -> None:
self._require_axis(axis)
def get_atype(self, axis: int) -> int:
self._require_axis(axis)
return 65 # position mode, as ECAT.bas configures
def get_axis_enable(self, axis: int) -> int:
self._require_axis(axis)
return 1 if self._enabled[axis] else 0
def get_dpos(self, axis: int) -> float:
self._require_axis(axis)
return self._dpos[axis]
def get_mpos(self, axis: int) -> float:
self._require_axis(axis)
# Follow the command position with a small lag while moving.
return self._dpos[axis] * 0.999
def get_axis_status(self, axis: int) -> int:
self._require_axis(axis)
status = 0
if self._homing_until[axis] > time.monotonic():
status |= 64 # homing in progress
if self._moving_until[axis] > time.monotonic():
status |= 2 # moving
return status
def get_if_idle(self, axis: int) -> int:
self._require_axis(axis)
moving = self._moving_until[axis] > time.monotonic()
homing = self._homing_until[axis] > time.monotonic()
return 0 if (moving or homing) else -1
def jog(self, axis: int, forward: bool) -> None:
self._require_axis(axis)
self._moving_until[axis] = time.monotonic() + DEMO_JOG_S
def move_abs(self, axis: int, position: float) -> None:
self._require_axis(axis)
if not math.isfinite(position):
raise ZAuxError(ERR_AUX_OFFSET + 2, "position is not finite")
self._dpos[axis] = float(position)
self._moving_until[axis] = time.monotonic() + DEMO_MOVE_S
def move_rel(self, axis: int, distance: float) -> None:
self._require_axis(axis)
self.move_abs(axis, self._dpos[axis] + float(distance))
def stop(self, axis: int, mode: int = 2) -> None:
self._require_axis(axis)
self._moving_until[axis] = 0.0
self._homing_until[axis] = 0.0
self._home_done[axis] = True
def bus_type(self) -> BusType:
return "ecat"
#: Demo timing constants (seconds), chosen so a human sees each state.
DEMO_INIT_DELAY_S = 1.5
DEMO_JOG_S = 1.0
DEMO_MOVE_S = 0.8
DEMO_HOME_S = 2.0
#: Demo-only failure codes, kept outside the documented vendor range on purpose.
ERR_NOT_OPEN = 30500
ERR_DEMO_UNSUPPORTED = 30501
@@ -0,0 +1,72 @@
"""User-unit to millimetre conversion.
The numbers come from bench measurement on the platform, transcribed from the
vendor reference program's comments::
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
# 当量5 → 1000单位=1mm,不是100单位=1mm
so ``PULSES_PER_MM = 5000`` is the calibration constant and ``UNITS = 10`` is
the pulse equivalent the platform is configured with. The displayed millimetre
value is therefore *derived*, not measured: the UI labels it as such, and no
code path may treat it as a calibrated length.
"""
from __future__ import annotations
import math
#: Measured: 5000 pulses per millimetre (see the module docstring).
PULSES_PER_MM = 5000.0
#: Pulse equivalent the platform is configured with by default.
DEFAULT_UNITS = 10.0
#: Move-parameter defaults, matching the reference program's UI initialisation.
DEFAULT_SPEED = 1000.0
DEFAULT_ACCEL = 10000.0
def safe_float(text: object, fallback: float) -> float:
"""Parse ``text`` as a finite float, falling back on anything unusable.
A blank, non-numeric, zero or non-finite entry must not silently become a
pulse equivalent of zero — on a motion controller that is a divide-by-zero
waiting to happen, or an unbounded move.
"""
try:
value = float(str(text).strip())
except (TypeError, ValueError):
return fallback
if not math.isfinite(value) or value == 0.0:
return fallback
return value
def user_units_to_mm(
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
) -> float:
"""Convert a controller user-unit value to millimetres.
Args:
value: a ``DPOS``/``MPOS`` reading or move distance in user units.
units: pulse equivalent in force on the axis.
pulses_per_mm: the platform's calibration constant.
Returns:
Millimetres. Not finite when the inputs are not usable, so a caller
that forgets to validate sees an obvious NaN rather than a plausible
wrong number.
"""
if pulses_per_mm <= 0:
return math.nan
return float(value) * float(units) / float(pulses_per_mm)
def mm_to_user_units(
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
) -> float:
"""Inverse of :func:`user_units_to_mm` (for an mm-entered move)."""
if units <= 0:
return math.nan
return float(value) * float(pulses_per_mm) / float(units)
@@ -0,0 +1,179 @@
"""AutoCal's preference schema and storage.
The shared store keys off whatever schema it is handed, so AutoCal gets its own
key set rather than inheriting the studio's camera/plot/3D keys. The values here
are the ones an operator would otherwise retype at every start-up.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from orisys_app_shared import prefs as _prefs
from orisys_app_shared.paths import preference_path
from .motion.service import PLATFORM_AXES
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
#: Connection modes, in the order the combo offers them.
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
#: Addresses the controller is normally reached on, tried first by a scan.
DEFAULT_CONTROLLER_IPS = ("192.168.0.11", "127.0.0.1")
#: Grid modes the sequence profile offers (workflow P5).
SURFACE_MODES = ("flat", "curved")
#: Point visiting orders (workflow P5).
POINT_ORDERS = ("row", "serpentine", "distance")
#: The design target is a well-lit bench, so light is the default; dark stays
#: selectable for a dim room.
DEFAULT_THEME = "light"
def _default_sequence_profile() -> dict[str, Any]:
"""The point-generation and run parameters of workflow P5.
One profile is global for now; named, saved sequences come with the runner.
The numbers are starting points an operator confirms at the bench, not
measured values — in particular ``home_mode``-style unknowns are left at 0 so
nothing is silently inherited from another machine.
"""
return {
# --- grid shape
"mode": "flat",
"pitch_mm": 2.0,
"margin_mm": 1.0,
"radius_mm": 0.0,
# --- motion
"clear_height_mm": 5.0,
"approach_speed_mm_s": 2.0,
"press_speed_mm_s": 0.5,
"press_depth_mm": 0.2,
"dwell_s": 0.5,
# --- measurement
"settle_window_s": 0.5,
"repeats": 3,
# --- order
"order": "serpentine",
}
def _default_safety_limits() -> dict[str, float]:
"""Workflow P0: the limits that must have a value before anything moves.
``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.
"""
return {
"max_travel_mm": 100.0,
"max_descent_mm": 10.0,
"max_press_depth_mm": 1.0,
"follow_error_mm": 0.5,
#: How far the axis may descend looking for contact before the run is
#: aborted (workflow P7, no-contact abort).
"contact_search_mm": 3.0,
}
def _default_sensor_setup() -> dict[str, Any]:
"""Workflow P3/P4: what is under test, what touches it, and the transform.
``sensor_to_platform`` stays empty until the transform is established by
probing known features; the residual travels with it so a re-fixtured sensor
cannot silently reuse a stale transform.
"""
return {
"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,
"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.
"sensor_to_platform": [],
"transform_residual_mm": 0.0,
"transform_feature_count": 0,
}
def _default_axis_params() -> dict[str, dict[str, float | int]]:
"""One parameter block per platform axis.
``home_mode`` starts at 0, which is **not** a valid CiA-402 homing mode:
the operator must choose one for the drive in front of them rather than
inherit a number from another machine. The reference program pre-filled 17,
which is a specific drive's mode, so it is deliberately not copied here.
"""
return {
str(axis): {
"units": DEFAULT_UNITS,
"speed": DEFAULT_SPEED,
"accel": DEFAULT_ACCEL,
"creep": 10.0,
"home_mode": 0,
"home_high": 50.0,
"home_low": 10.0,
"home_offset": 0.0,
}
for axis in range(PLATFORM_AXES)
}
def default_preferences() -> dict[str, Any]:
"""AutoCal's default preference document."""
return {
"language": "zh_CN",
"theme": DEFAULT_THEME,
"window_geometry": None,
"connection": {
"mode": "eth",
"ip": DEFAULT_CONTROLLER_IPS[0],
"com_id": 0,
"card_num": 0,
"local_string": "0",
},
# Remembered so "download the BAS program" is one click after the first
# time, instead of re-picking the file every session.
"bas_path": "",
#: Displayed millimetres are derived from this; it is a bench-measured
#: constant, not a calibrated length (see motion/units.py).
"units_per_mm": DEFAULT_UNITS,
"state_poll_ms": 100,
"axis_params": _default_axis_params(),
#: {node, index, subindex, type} of the last SDO read/written, so the
#: operator can repeat a drive-setting check quickly.
"sdo_last": {"node": 0, "index": 24704, "subindex": 0, "type": 0},
"rtex_last": {"axis": 0, "parameter": 0},
"show_diagnostics": False,
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
#: P0: limits enforced from P2 onward.
"safety_limits": _default_safety_limits(),
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
"sensor_setup": _default_sensor_setup(),
#: P5: grid generation and run parameters.
"sequence_profile": _default_sequence_profile(),
}
def load_preferences() -> dict[str, Any]:
"""Stored preferences with AutoCal's defaults filling the gaps."""
prefs = _prefs.load_preferences(default_preferences())
# 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():
axis_params.setdefault(axis, values)
return prefs
def save_preferences(prefs: dict[str, Any]) -> Path:
return _prefs.save_preferences(prefs, default_preferences())
def path() -> Path:
"""Where AutoCal's ``preference.json`` lives (for the About box)."""
return preference_path()
@@ -0,0 +1,476 @@
"""Import and export a calibration sequence (workflow P5/P8).
A sequence is a small, human-readable document — a profile plus the points it
generated — so it can be reviewed, diffed, emailed, and reproduced on another
bench. The format follows this repository's other documents rather than
inventing a convention: a dotted ``format_version`` string, sorted sections,
``indent=2 / ensure_ascii=False`` JSON, and an atomic temp-then-replace write
(cf. ``record/experiment.py``'s ``session.json`` and ``force_calibration.json``).
The full field-by-field spec is ``apps/autocal/docs/sequence-format.md``.
Two properties matter more than the field names:
* **``summary`` is derived.** It exists so a human reading the file sees the
point count and the travel span without counting rows. It is *recomputed* on
load, and a mismatch against the file is reported — that is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
* **The file states its frame.** A sequence whose numbers do not say whether they
are millimetres or user units is a sequence that will eventually be read wrong,
so ``frame`` records both the unit and the pulse equivalent it was built with.
Nothing here imports Qt, so the format can be exercised from a test process and
a future batch tool.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
#: The version this build writes, and the highest it will read.
FORMAT_VERSION = "1.0"
#: What a sequence document says it is. Checked on import: a force-calibration
#: file and a sequence file are both JSON, and applying one as the other must
#: fail loudly rather than half-work.
DOCUMENT_KIND = "autocal.sequence"
#: File extension by convention. Import accepts any ``.json``.
FILE_SUFFIX = ".oryseq"
#: The frame targets are expressed in.
TARGET_FRAME = "platform"
LENGTH_UNIT = "mm"
#: Platform traverse rate assumed when estimating a run's duration, in mm/s.
#: A first-pass planning figure, not a measurement: the axes' own speed setting
#: is in user units and the real rate depends on acceleration and the path.
#: Clearly labelled as an estimate wherever it is shown.
ASSUMED_TRAVERSE_MM_S = 20.0
#: The profile keys a document must carry, matching the panel's fields.
PROFILE_KEYS: tuple[str, ...] = (
"mode",
"pitch_mm",
"margin_mm",
"radius_mm",
"clear_height_mm",
"approach_speed_mm_s",
"press_speed_mm_s",
"press_depth_mm",
"dwell_s",
"settle_window_s",
"repeats",
"order",
)
class SequenceFormatError(ValueError):
"""A sequence document could not be read.
The message is what the operator sees, so it always names the specific
problem (which key, which version, which point) rather than "invalid file".
"""
@dataclass
class SequencePoint:
"""One generated calibration point.
``target_mm`` is where the axes go; ``sensor_mm`` is where that lands on the
sensor. Both are stored: the run needs the first, and the second is what makes
the point checkable against the surface. Re-deriving one from the other on
every load would silently change what the points mean when a sensor is
re-fixtured.
"""
id: int
target_mm: dict[str, float]
sensor_mm: dict[str, float] | None = None
clearance_mm: float = 0.0
enabled: bool = True
note: str = ""
def as_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": int(self.id),
"target_mm": {axis: float(self.target_mm.get(axis, 0.0)) for axis in "xyz"},
"clearance_mm": float(self.clearance_mm),
"enabled": bool(self.enabled),
"note": str(self.note),
}
if self.sensor_mm is not None:
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
return payload
@dataclass
class SequenceDocument:
"""A sequence as it travels between machines."""
profile: dict[str, Any] = field(default_factory=dict)
points: list[SequencePoint] = field(default_factory=list)
name: str = ""
note: str = ""
device: dict[str, Any] = field(default_factory=dict)
units_per_mm: float = 0.0
created_at: str = ""
summary: dict[str, Any] = field(default_factory=dict)
#: Problems found while reading that did not stop the import. Shown to the
#: operator: "it loaded" and "it loaded as written" are different claims.
warnings: list[str] = field(default_factory=list)
@property
def point_count(self) -> int:
return len(self.points)
@property
def enabled_count(self) -> int:
return sum(1 for point in self.points if point.enabled)
# --------------------------------------------------------------------- derive
def summarise(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> dict[str, Any]:
"""The derived block: count, travel span, and an estimated duration.
Recomputed everywhere rather than trusted from a file, so a document cannot
claim a range its points do not have.
"""
ordered = list(points)
span: dict[str, float] = {}
minimum: dict[str, float] = {}
maximum: dict[str, float] = {}
for axis in "xyz":
values = [float(point.target_mm.get(axis, 0.0)) for point in ordered]
if values:
minimum[axis] = min(values)
maximum[axis] = max(values)
span[axis] = maximum[axis] - minimum[axis]
return {
"count": len(ordered),
"enabled": sum(1 for point in ordered if point.enabled),
"min_mm": minimum,
"max_mm": maximum,
"span_mm": span,
"pitch_mm": _number(profile.get("pitch_mm"), 0.0),
"estimated_duration_s": estimate_duration(ordered, profile),
}
def estimate_duration(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> float:
"""A planning estimate for one full run, in seconds.
Per point: the descent and press, the dwell, and the settle window, repeated
``repeats`` times; plus a traverse between points at
:data:`ASSUMED_TRAVERSE_MM_S`. An estimate, labelled as one wherever it is
shown — it assumes a constant traverse rate and ignores acceleration.
"""
ordered = list(points)
if not ordered:
return 0.0
repeats = max(1, int(_number(profile.get("repeats"), 1)))
clear_height = max(0.0, _number(profile.get("clear_height_mm"), 0.0))
approach = max(1e-6, _number(profile.get("approach_speed_mm_s"), 1.0))
press_speed = max(1e-6, _number(profile.get("press_speed_mm_s"), 1.0))
press_depth = max(0.0, _number(profile.get("press_depth_mm"), 0.0))
dwell = max(0.0, _number(profile.get("dwell_s"), 0.0))
settle = max(0.0, _number(profile.get("settle_window_s"), 0.0))
descent = clear_height / approach
press = press_depth / press_speed
per_point = (descent + press + dwell + settle) * repeats
total = per_point * len(ordered)
pitch = max(0.0, _number(profile.get("pitch_mm"), 0.0))
if pitch > 0:
total += (len(ordered) - 1) * (pitch / ASSUMED_TRAVERSE_MM_S) * repeats
return float(total)
def format_span(low: float | None, high: float | None, *, places: int = 3) -> str:
"""``min–max (span)``, or a single number when the travel is nil.
A zero span means every point shares that coordinate; printing
``12.400–12.400 (0.000)`` would be noise in a line whose job is to be read at
a glance.
"""
if low is None or high is None:
return "—"
if abs(high - low) < 10 ** (-places) / 2:
return f"{low:.{places}f}"
return f"{low:.{places}f}–{high:.{places}f} ({high - low:.{places}f})"
def format_duration(seconds: float | None) -> str:
"""``m:ss``, or a dash when no estimate can be made."""
if seconds is None or not math.isfinite(float(seconds)) or seconds <= 0:
return "—"
total = int(round(float(seconds)))
minutes, rest = divmod(total, 60)
if minutes >= 60:
hours, minutes = divmod(minutes, 60)
return f"{hours:d}:{minutes:02d}:{rest:02d}"
return f"{minutes:d}:{rest:02d}"
# ----------------------------------------------------------------- serialize
def to_document(
*,
profile: Mapping[str, Any],
points: Iterable[SequencePoint] = (),
name: str = "",
note: str = "",
device: Mapping[str, Any] | None = None,
units_per_mm: float = 0.0,
app_version: str = "",
shared_version: str = "",
shared_api: str = "",
) -> dict[str, Any]:
"""Build the sequence document."""
ordered = list(points)
return {
"format_version": FORMAT_VERSION,
"kind": DOCUMENT_KIND,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"name": str(name),
"note": str(note),
"versions": {
"app": str(app_version),
"shared": str(shared_version),
"shared_api": str(shared_api),
},
"device": dict(device or {}),
"frame": {
"targets": TARGET_FRAME,
"length_unit": LENGTH_UNIT,
"units_per_mm": float(units_per_mm),
},
"profile": {
key: profile.get(key)
for key in PROFILE_KEYS
},
"points": [point.as_dict() for point in ordered],
"summary": summarise(ordered, profile),
}
def save(document: Mapping[str, Any], path: str | Path) -> Path:
"""Write ``document`` atomically, as UTF-8 JSON.
Temp-then-replace: a half-written sequence file that still parses is worse
than no file, because it looks like a design.
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
temporary.replace(target)
return target
# ------------------------------------------------------------------- parse
def load(path: str | Path, *, current_profile: Mapping[str, Any] | None = None) -> SequenceDocument:
"""Read a sequence document, validating it.
Args:
path: the file to read.
current_profile: the profile in force now. Missing profile keys are
taken from it and reported, rather than silently defaulting to
another bench's numbers.
Raises:
SequenceFormatError: with a message naming the specific problem.
"""
target = Path(path)
try:
text = target.read_text(encoding="utf-8")
except OSError as exc:
raise SequenceFormatError(f"cannot read {target.name}: {exc}") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise SequenceFormatError(
f"{target.name} is not valid JSON: line {exc.lineno}, column {exc.colno}"
) from exc
if not isinstance(data, dict):
raise SequenceFormatError(
f"{target.name}: the top level must be an object, found {type(data).__name__}"
)
return from_document(data, current_profile=current_profile, source=target.name)
def from_document(
data: Mapping[str, Any],
*,
current_profile: Mapping[str, Any] | None = None,
source: str = "document",
) -> SequenceDocument:
"""Validate and interpret an already-parsed sequence document."""
warnings: list[str] = []
kind = data.get("kind")
if kind != DOCUMENT_KIND:
raise SequenceFormatError(
f"{source}: not a sequence document "
f"(kind is {kind!r}, expected {DOCUMENT_KIND!r})"
)
raw_version = data.get("format_version")
if not isinstance(raw_version, str) or not raw_version.strip():
raise SequenceFormatError(f"{source}: missing or invalid format_version")
if _version_tuple(raw_version) > _version_tuple(FORMAT_VERSION):
raise SequenceFormatError(
f"{source}: written by a newer AutoCal "
f"(format {raw_version}, this build reads up to {FORMAT_VERSION})"
)
frame = data.get("frame")
if not isinstance(frame, dict):
raise SequenceFormatError(f"{source}: missing frame block")
if str(frame.get("length_unit") or "") != LENGTH_UNIT:
raise SequenceFormatError(
f"{source}: length_unit is {frame.get('length_unit')!r}, expected {LENGTH_UNIT!r}"
)
profile_in = data.get("profile")
if not isinstance(profile_in, dict):
raise SequenceFormatError(f"{source}: missing or invalid profile block")
profile: dict[str, Any] = dict(current_profile or {})
missing: list[str] = []
for key in PROFILE_KEYS:
if key in profile_in:
profile[key] = profile_in[key]
else:
missing.append(key)
if missing:
warnings.append(
"profile keys absent, kept the current value: " + ", ".join(sorted(missing))
)
points = _parse_points(data.get("points"), source=source, warnings=warnings)
stored_summary = data.get("summary")
recomputed = summarise(points, profile)
if isinstance(stored_summary, dict) and points:
differences = _summary_differences(stored_summary, recomputed)
if differences:
warnings.append(
"the stored summary disagrees with the points and was recomputed: "
+ "; ".join(differences)
)
device = data.get("device") if isinstance(data.get("device"), dict) else {}
return SequenceDocument(
profile=profile,
points=points,
name=str(data.get("name") or ""),
note=str(data.get("note") or ""),
device=dict(device),
units_per_mm=_number(frame.get("units_per_mm"), 0.0),
created_at=str(data.get("created_at") or ""),
summary=recomputed,
warnings=warnings,
)
def _parse_points(
raw: Any, *, source: str, warnings: list[str]
) -> list[SequencePoint]:
if raw is None:
warnings.append("no points in the file; only the profile was imported")
return []
if not isinstance(raw, list):
raise SequenceFormatError(
f"{source}: points must be a list, found {type(raw).__name__}"
)
points: list[SequencePoint] = []
for index, entry in enumerate(raw, start=1):
where = f"{source}: point {index}"
if not isinstance(entry, dict):
raise SequenceFormatError(f"{where} is not an object")
target = entry.get("target_mm")
if not isinstance(target, dict):
raise SequenceFormatError(f"{where} has no target_mm object")
for axis in "xyz":
if axis not in target:
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
sensor = entry.get("sensor_mm")
points.append(
SequencePoint(
id=int(_number(entry.get("id"), index)),
target_mm={axis: _number(target.get(axis), 0.0) for axis in "xyz"},
sensor_mm=(
{axis: _number(sensor.get(axis), 0.0) for axis in "xyz"}
if isinstance(sensor, dict)
else None
),
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
enabled=bool(entry.get("enabled", True)),
note=str(entry.get("note") or ""),
)
)
return points
def _summary_differences(stored: Mapping[str, Any], recomputed: Mapping[str, Any]) -> list[str]:
"""Where the file's own summary disagrees with its points."""
differences: list[str] = []
if int(_number(stored.get("count"), -1)) != recomputed["count"]:
differences.append(
f"count {stored.get('count')} != {recomputed['count']}"
)
stored_span = stored.get("span_mm")
if isinstance(stored_span, dict):
for axis in "xyz":
if axis in recomputed["span_mm"]:
if not _close(_number(stored_span.get(axis), 0.0), recomputed["span_mm"][axis]):
differences.append(
f"span {axis} {stored_span.get(axis)} != {recomputed['span_mm'][axis]:.3f}"
)
return differences
def _close(first: float, second: float, tolerance: float = 1e-6) -> bool:
return abs(float(first) - float(second)) <= tolerance
def _number(value: Any, fallback: float) -> float:
"""A finite float, or the fallback. Never NaN, never a string."""
try:
number = float(value)
except (TypeError, ValueError):
return float(fallback)
if not math.isfinite(number):
return float(fallback)
return number
def _version_tuple(text: str) -> tuple[int, ...]:
"""``"1.10"`` as ``(1, 10)``.
Compared as tuples rather than strings: ``"1.10" < "1.9"`` is a real trap that
would make a newer file look older and load it as if it were supported.
"""
parts: list[int] = []
for chunk in str(text).split("."):
digits = "".join(character for character in chunk if character.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
@@ -0,0 +1,17 @@
"""AutoCal's windowed user interface."""
from .axis_panel import AxisPanel
from .brand_bar import BrandBar
from .bus_data_panel import BusDataPanel
from .bus_panel import BusPanel
from .connection_panel import ConnectionPanel
from .window import AutoCalMainWindow
__all__ = [
"AutoCalMainWindow",
"AxisPanel",
"BrandBar",
"BusDataPanel",
"BusPanel",
"ConnectionPanel",
]
@@ -0,0 +1,611 @@
"""Axis parameters, live state, and motion commands for all five axes.
Two halves, both always visible because they answer different questions:
* the **parameter form** edits one axis at a time (units, speed, accel, homing)
and applies it to the controller;
* the **state table** shows every axis at once — enable, position in user units
and millimetres, motion, homing — with that axis's actions in the last column.
Keeping the whole platform in one table is a deliberate change from the vendor
program, which acts on whichever axis number happens to be typed in a box. On a
five-axis platform, acting on the wrong axis is the expensive mistake.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from ..motion import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
DEFAULT_UNITS,
AxisParams,
AxisState,
Snapshot,
safe_float,
user_units_to_mm,
)
#: Data columns in the state table, in display order.
#:
#: Seven, not thirteen. Matching the full frame of a drive here made the middle
#: columns illegible at a real window width, and a table nobody can read protects
#: nobody. What does not fit is shown for the *selected* axis on the detail line
#: below the table — see :meth:`AxisPanel._detail_text`.
COLUMNS = (
"axes.axis",
"axes.enable",
"axes.units",
"axes.speed",
"axes.dpos",
"axes.dpos_mm",
"axes.status",
)
#: The actions column follows the data columns.
ACTIONS_COLUMN = len(COLUMNS)
#: Row height in pixels. Must fit the per-row buttons: a QTableWidget does not
#: derive its row height from a cell widget, and the shared stylesheet gives a
#: button 28 px of minimum height plus 16 px of padding (44 px total) unless it
#: carries the ``rail`` property, which the theme defines as the compact variant.
ROW_HEIGHT = 36
#: How many axis rows the panel insists on showing without scrolling. Set to all
#: of them: on a five-axis platform a clipped fifth row reads as a missing drive.
#: This is also what makes the splitter give the panel the height it needs — Qt
#: distributes a splitter from the children's minimum sizes, so a smaller value
#: here means a scrollbar instead of five visible axes.
MIN_VISIBLE_ROWS = 5
#: Height of the small parameter fields. Pinned so the parameter form keeps to
#: two rows: the fields' natural height pushes that form to ~136 px, which costs
#: one and a half axis rows in the table below it.
FIELD_HEIGHT = 28
#: Column widths by index. With the project's thirteen columns the widths had to
#: shrink below the headers; seven can each be comfortable.
_COLUMN_WIDTHS = {
0: 46,
1: 96,
2: 100,
3: 100,
4: 130,
5: 130,
6: 130,
ACTIONS_COLUMN: 230,
}
def parse_optional_float(text: str, fallback: float) -> float:
"""A numeric field's value, or the fallback when it is blank/unusable."""
if str(text).strip() == "":
return fallback
return safe_float(text, fallback)
class AxisPanel(QGroupBox):
"""Parameters plus live state for the platform's axes."""
paramsApplied = Signal(int, object)
enableToggled = Signal(int, bool)
jogRequested = Signal(int, bool)
stopRequested = Signal(int)
stopAllRequested = Signal()
moveAbsRequested = Signal(int, float)
moveRelRequested = Signal(int, float)
homeRequested = Signal(int)
clearAlarmRequested = Signal(int)
def __init__(
self,
axis_count: int,
params: dict[int, AxisParams] | None = None,
*,
units_per_mm: float = DEFAULT_UNITS,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("groupBox")
self.axis_count = int(axis_count)
self._params = dict(params or {})
self._units_per_mm = units_per_mm
self._connected = False
self._bus_ready = False
self._available_axes = 0
self._selected_axis = 0
self._snapshot = Snapshot()
self._rows: list[dict] = []
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
outer.setSpacing(6)
outer.addWidget(self._build_params())
outer.addWidget(self._build_target_row())
# Stretch factor 1: the table is the only part of this panel that may
# grow, so every spare pixel goes to a further axis row rather than to
# padding around the parameter form.
outer.addWidget(self._build_table(), 1)
outer.addWidget(self._build_actions())
self.retranslate()
self._reread_params()
# Controls start disabled and a snapshot enables them. Qt's default for
# a new widget is enabled, and whether a poll happens to arrive before
# the window is shown is a race — so the safe state is set explicitly
# here rather than inferred from the first snapshot.
self._refresh_availability()
# ------------------------------------------------------------------ widgets
def _build_params(self) -> QWidget:
box = QWidget(self)
grid = QGridLayout(box)
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(4)
def label(key: str) -> QLabel:
widget = QLabel(box)
widget.setObjectName("hintLabel")
return widget
def edit(width: int) -> QLineEdit:
widget = QLineEdit(box)
widget.setFixedWidth(width)
widget.setFixedHeight(FIELD_HEIGHT)
return widget
self.axis_label = label("axes.axis")
self.axis_combo = QWidget(box)
self.axis_index_label = QLabel("0", box)
self.axis_index_label.setObjectName("statusValue")
self.units_label = label("axes.units")
self.units_edit = edit(80)
self.speed_label = label("axes.speed")
self.speed_edit = edit(80)
self.accel_label = label("axes.accel")
self.accel_edit = edit(90)
self.home_mode_label = label("axes.home_mode")
self.home_mode_edit = edit(60)
self.home_high_label = label("axes.home_high")
self.home_high_edit = edit(60)
self.home_low_label = label("axes.home_low")
self.home_low_edit = edit(60)
self.home_offset_label = label("axes.home_offset")
self.home_offset_edit = edit(60)
self.apply_button = QPushButton(box)
self.apply_button.setObjectName("primaryButton")
self.apply_button.setProperty("rail", True)
self.apply_button.setFixedHeight(FIELD_HEIGHT)
self.apply_button.clicked.connect(self._on_apply)
self.home_button = QPushButton(box)
self.home_button.setProperty("rail", True)
self.home_button.setFixedHeight(FIELD_HEIGHT)
self.home_button.clicked.connect(lambda: self.homeRequested.emit(self.selected_axis))
self.clear_alarm_button = QPushButton(box)
self.clear_alarm_button.setProperty("rail", True)
self.clear_alarm_button.setFixedHeight(FIELD_HEIGHT)
self.clear_alarm_button.clicked.connect(
lambda: self.clearAlarmRequested.emit(self.selected_axis)
)
grid.addWidget(self.axis_label, 0, 0)
grid.addWidget(self.axis_index_label, 0, 1)
grid.addWidget(self.units_label, 0, 2)
grid.addWidget(self.units_edit, 0, 3)
grid.addWidget(self.speed_label, 0, 4)
grid.addWidget(self.speed_edit, 0, 5)
grid.addWidget(self.accel_label, 0, 6)
grid.addWidget(self.accel_edit, 0, 7)
grid.addWidget(self.home_mode_label, 1, 0)
grid.addWidget(self.home_mode_edit, 1, 1)
grid.addWidget(self.home_high_label, 1, 2)
grid.addWidget(self.home_high_edit, 1, 3)
grid.addWidget(self.home_low_label, 1, 4)
grid.addWidget(self.home_low_edit, 1, 5)
grid.addWidget(self.home_offset_label, 1, 6)
grid.addWidget(self.home_offset_edit, 1, 7)
grid.addWidget(self.apply_button, 2, 0, 1, 2)
grid.addWidget(self.home_button, 2, 2, 1, 2)
grid.addWidget(self.clear_alarm_button, 2, 4, 1, 2)
return box
def _build_target_row(self) -> QWidget:
box = QWidget(self)
row = QHBoxLayout(box)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(8)
self.target_label = QLabel(box)
self.target_edit = QLineEdit(box)
self.target_edit.setFixedWidth(120)
self.move_abs_button = QPushButton(box)
self.move_abs_button.clicked.connect(self._on_move_abs)
self.move_rel_button = QPushButton(box)
self.move_rel_button.clicked.connect(self._on_move_rel)
self.mm_hint = QLabel(box)
self.mm_hint.setObjectName("hintLabel")
self.mm_hint.setWordWrap(True)
row.addWidget(self.target_label)
row.addWidget(self.target_edit)
row.addWidget(self.move_abs_button)
row.addWidget(self.move_rel_button)
row.addStretch(1)
row.addWidget(self.mm_hint)
return box
def _build_table(self) -> QWidget:
self.table = QTableWidget(self.axis_count, len(COLUMNS) + 1, self)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setAlternatingRowColors(True)
for column in range(len(COLUMNS)):
self.table.setColumnWidth(column, _COLUMN_WIDTHS.get(column, 84))
self.table.setColumnWidth(ACTIONS_COLUMN, _COLUMN_WIDTHS[ACTIONS_COLUMN])
# The per-row buttons live in a cell widget, and Qt does not size a row
# to its cell widgets. Without an explicit section height the row stays
# at the font height and the buttons are clipped.
self.table.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
# A minimum rather than the full five rows: demanding 5*ROW_HEIGHT plus a
# header forces the panel to be taller than its share of a 900 px window,
# and the splitter then clips the last drive — which reads as a missing
# axis. The table scrolls if it is ever squeezed below this.
self.table.setMinimumHeight(40 + ROW_HEIGHT * MIN_VISIBLE_ROWS)
self.table.cellClicked.connect(self._on_cell_clicked)
for row in range(self.axis_count):
self._rows.append(self._build_row(row))
return self.table
def _build_row(self, row: int) -> dict:
for column in range(len(COLUMNS)):
item = QTableWidgetItem("")
if column >= 2:
item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter)
self.table.setItem(row, column, item)
enable_button = QPushButton(t("axes.enable_off"), self.table)
enable_button.setCheckable(True)
enable_button.setProperty("rail", True)
enable_button.setMinimumWidth(84)
enable_button.setFixedHeight(28)
enable_button.clicked.connect(
lambda checked, axis=row: self.enableToggled.emit(axis, bool(checked))
)
self.table.setCellWidget(row, 1, enable_button)
controls = QWidget(self.table)
layout = QHBoxLayout(controls)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(4)
def make(key: str, slot) -> QPushButton:
button = QPushButton(t(key), controls)
# "rail" is the theme's compact button: without it the shared
# stylesheet forces 44 px, which no longer fits the row.
button.setProperty("rail", True)
button.setMinimumWidth(56)
button.setFixedHeight(28)
button.clicked.connect(lambda _checked=False, a=row: slot(a))
layout.addWidget(button)
return button
self.table.setCellWidget(row, ACTIONS_COLUMN, controls)
return {
"enable_button": enable_button,
"fwd_button": make("axes.jog_fwd", lambda axis: self.jogRequested.emit(axis, True)),
"rev_button": make("axes.jog_rev", lambda axis: self.jogRequested.emit(axis, False)),
"stop_button": make("axes.stop", self.stopRequested.emit),
}
def _build_actions(self) -> QWidget:
box = QWidget(self)
column = QVBoxLayout(box)
column.setContentsMargins(0, 0, 0, 0)
column.setSpacing(4)
self._detail_label = QLabel(t("axes.detail_none", axis=0), box)
self._detail_label.setObjectName("hintLabel")
self._detail_label.setWordWrap(True)
column.addWidget(self._detail_label)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(8)
self.stop_all_button = QPushButton(box)
self.stop_all_button.setObjectName("dangerButton")
self.stop_all_button.clicked.connect(self.stopAllRequested)
self.enabled_count_label = QLabel(box)
self.enabled_count_label.setObjectName("hintLabel")
row.addWidget(self.stop_all_button)
row.addStretch(1)
row.addWidget(self.enabled_count_label)
column.addLayout(row)
return box
# ------------------------------------------------------------------- state
@property
def selected_axis(self) -> int:
"""The axis the parameter form applies to."""
return self._selected_axis
def set_selected_axis(self, axis: int) -> None:
self._selected_axis = max(0, min(int(axis), self.axis_count - 1))
self.axis_index_label.setText(str(self._selected_axis))
self._reread_params()
if hasattr(self, "_detail_label"):
self._detail_label.setText(self._detail_text())
def set_connected(self, connected: bool) -> None:
self._connected = bool(connected)
self._refresh_availability()
def set_axis_count(self, count: int) -> None:
"""Dim rows for axes the bus did not report (a missing drive)."""
self._available_axes = max(1, min(int(count), self.axis_count))
self._refresh_availability()
self._recolour_rows()
def _axis_present(self, row: int) -> bool:
return not self._available_axes or row < self._available_axes
def _refresh_availability(self) -> None:
"""Recompute every control's enabled state from connection + bus."""
connected = self._connected
for widget in (
self.units_edit,
self.speed_edit,
self.accel_edit,
self.home_mode_edit,
self.home_high_edit,
self.home_low_edit,
self.home_offset_edit,
self.target_edit,
self.apply_button,
self.home_button,
self.clear_alarm_button,
self.move_abs_button,
self.move_rel_button,
self.stop_all_button,
):
widget.setEnabled(connected)
for row, widgets in enumerate(self._rows):
present = self._axis_present(row)
widgets["enable_button"].setEnabled(connected and present)
widgets["stop_button"].setEnabled(connected and present)
# Jogging additionally requires an initialised bus.
for key in ("fwd_button", "rev_button"):
widgets[key].setEnabled(connected and present and self._bus_ready)
def _recolour_rows(self) -> None:
"""Dim the data cells of axes the bus did not report."""
for row in range(self.axis_count):
present = self._axis_present(row)
for column in range(len(COLUMNS)):
item = self.table.item(row, column)
if item is not None:
item.setForeground(_row_brush(present))
def update_snapshot(self, snapshot: Snapshot) -> None:
"""Render one poll: values, motion state, and the enable buttons."""
self._connected = snapshot.connected
bus_axes = int(snapshot.bus.total_axes)
if bus_axes:
self._available_axes = max(1, min(bus_axes, self.axis_count))
self._bus_ready = snapshot.bus.initialized
for row in range(self.axis_count):
state = snapshot.axis(row)
if state is None:
continue
values = {
0: str(row),
1: t("axes.enable_on") if state.enabled else t("axes.enable_off"),
2: _number(state.units, 3),
3: _number(state.speed, 1),
4: _number(state.dpos, 3),
5: _millimetres(state.units, state.dpos),
6: _status_text(state),
}
for column, text in values.items():
item = self.table.item(row, column)
if item is not None:
item.setText(text)
button = self._rows[row]["enable_button"]
button.setChecked(bool(state.enabled))
button.setText(t("axes.enable_on") if state.enabled else t("axes.enable_off"))
if state.status.home_in_progress:
button.setToolTip(t("axes.home_running"))
elif state.status.alarm:
button.setToolTip(t("action.failed"))
else:
button.setToolTip("")
self._snapshot = snapshot
detail = self._detail_label
detail.setText(self._detail_text())
enabled = sum(
1
for row in range(self.axis_count)
if (snapshot.axis(row) is not None and snapshot.axis(row).enabled)
)
self.enabled_count_label.setText(
t("axes.enabled_count", on=enabled, total=self.axis_count)
)
self._refresh_availability()
self._recolour_rows()
def _detail_text(self) -> str:
"""Everything about the selected axis that the seven columns cannot hold.
The table is deliberately narrow enough to read; this line is where the
drive's full frame goes — axis type, acceleration, feedback position, the
raw status word, and homing.
"""
state = self._snapshot.axis(self._selected_axis)
if state is None:
return t("axes.detail_none", axis=self._selected_axis)
return t(
"axes.detail",
axis=self._selected_axis,
atype=state.atype,
accel=_number(state.accel, 1),
mpos=_number(state.mpos, 3),
mpos_mm=_millimetres(state.units, state.mpos),
status=state.status.raw,
home=_home_text(state),
)
# ---------------------------------------------------------------- actions
def _on_cell_clicked(self, row: int, _column: int) -> None:
self.set_selected_axis(row)
def _reread_params(self) -> None:
params = self._params.get(self._selected_axis, AxisParams())
self.units_edit.setText(_number(params.units, 3))
self.speed_edit.setText(_number(params.speed, 1))
self.accel_edit.setText(_number(params.accel, 1))
self.home_mode_edit.setText(str(int(params.home_mode)))
self.home_high_edit.setText(_number(params.home_high, 1))
self.home_low_edit.setText(_number(params.home_low, 1))
self.home_offset_edit.setText(_number(params.home_offset, 3))
def current_params(self) -> AxisParams:
"""The form's values, with unusable entries replaced by defaults."""
return AxisParams(
units=parse_optional_float(self.units_edit.text(), DEFAULT_UNITS),
speed=parse_optional_float(self.speed_edit.text(), DEFAULT_SPEED),
accel=parse_optional_float(self.accel_edit.text(), DEFAULT_ACCEL),
creep=parse_optional_float(self.home_low_edit.text(), 10.0),
home_mode=int(parse_optional_float(self.home_mode_edit.text(), 0)),
home_high=parse_optional_float(self.home_high_edit.text(), 50.0),
home_low=parse_optional_float(self.home_low_edit.text(), 10.0),
home_offset=parse_optional_float(self.home_offset_edit.text(), 0.0),
)
def stored_params(self) -> dict[int, AxisParams]:
"""Every axis's parameters, for persistence."""
return dict(self._params)
def _on_apply(self) -> None:
params = self.current_params()
self._params[self._selected_axis] = params
self.paramsApplied.emit(self._selected_axis, params)
def _on_move_abs(self) -> None:
value = self._target_value()
if value is not None:
self.moveAbsRequested.emit(self.selected_axis, value)
def _on_move_rel(self) -> None:
value = self._target_value()
if value is not None:
self.moveRelRequested.emit(self.selected_axis, value)
def _target_value(self) -> float | None:
text = self.target_edit.text().strip()
if text == "":
return None
try:
return float(text)
except ValueError:
return None
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setTitle(t("axes.title"))
self.axis_label.setText(t("axes.axis"))
self.units_label.setText(t("axes.units"))
self.speed_label.setText(t("axes.speed"))
self.accel_label.setText(t("axes.accel"))
self.home_mode_label.setText(t("axes.home_mode"))
self.home_high_label.setText(t("axes.home_high"))
self.home_low_label.setText(t("axes.home_low"))
self.home_offset_label.setText(t("axes.home_offset"))
self.apply_button.setText(t("axes.apply"))
self.home_button.setText(t("axes.home"))
self.clear_alarm_button.setText(t("axes.clear_alarm"))
self.target_label.setText(t("axes.target"))
self.move_abs_button.setText(t("axes.move_abs"))
self.move_rel_button.setText(t("axes.move_rel"))
self.stop_all_button.setText(t("axes.stop_all"))
self.mm_hint.setText(t("axes.mm_derived_tip"))
if hasattr(self, "_detail_label"):
self._detail_label.setText(self._detail_text())
self.table.setHorizontalHeaderLabels(
[t(key) for key in COLUMNS] + [t("axes.controls")]
)
for row in range(self.axis_count):
button = self._rows[row]["enable_button"]
button.setText(t("axes.enable_on") if button.isChecked() else t("axes.enable_off"))
self._rows[row]["fwd_button"].setText(t("axes.jog_fwd"))
self._rows[row]["rev_button"].setText(t("axes.jog_rev"))
self._rows[row]["stop_button"].setText(t("axes.stop"))
def _number(value: float, places: int) -> str:
try:
return f"{float(value):.{places}f}"
except (TypeError, ValueError):
return "—"
def _millimetres(units: float, user_units: float) -> str:
"""The derived millimetre reading, or a dash when it cannot be derived."""
if not units:
return "—"
millimetres = user_units_to_mm(user_units, units=units)
if millimetres != millimetres: # NaN
return "—"
return f"{millimetres:.3f}"
def _home_text(state: AxisState) -> str:
if state.status.home_in_progress:
return t("axes.home_running")
return t("axes.home_ok") if state.home_status == 1 else t("axes.home_pending")
def _status_text(state: AxisState) -> str:
"""One column that says what an operator needs to know at a glance.
Alarm outranks motion outranks homing, because that is the order in which
they matter when you are looking at the row for a second.
"""
if state.status.alarm:
return t("status.alarm_short")
if not state.idle:
return t("axes.moving")
return _home_text(state)
def _row_brush(present: bool) -> QBrush:
"""Dim a row whose drive the bus did not report."""
return QBrush(QColor("#d0d0d0" if present else "#8a8a8a"))
@@ -0,0 +1,170 @@
"""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.
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.
"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
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.
_COLUMNS = 3
#: Tone -> colour token, as elsewhere.
_TONE_TOKENS = {"ok": "accent", "warn": "warn", "error": "danger", "muted": "text_muted"}
class AxisReadoutPanel(QGroupBox):
"""Five axis rows plus the platform-wide actions."""
enableAllRequested = Signal(bool)
stopAllRequested = Signal()
clearAllRequested = Signal()
def __init__(self, axis_count: int, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("groupBox")
self.axis_count = int(axis_count)
self._theme = theme
self._connected = False
self._snapshot = Snapshot()
self._rows: list[dict] = []
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
outer.setSpacing(6)
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(4)
for row in range(self.axis_count):
grid.addWidget(self._build_row(row), row, 0)
outer.addLayout(grid)
outer.addStretch(1)
self.enable_all_button = QPushButton(self)
self.enable_all_button.clicked.connect(self._on_enable_all)
self.stop_all_button = QPushButton(self)
self.stop_all_button.setObjectName("dangerButton")
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):
button.setProperty("rail", True)
outer.addWidget(button)
self.retranslate()
self.set_connected(False)
def _build_row(self, axis: int) -> QWidget:
box = QWidget(self)
row = QHBoxLayout(box)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(6)
index = QLabel(str(axis), box)
index.setObjectName("axisIndex")
index.setFixedWidth(16)
state = QLabel("", box)
state.setObjectName("axisState")
state.setFixedWidth(46)
position = QLabel("", box)
position.setObjectName("axisPosition")
row.addWidget(index)
row.addWidget(state)
row.addWidget(position, 1)
self._rows.append({"state": state, "position": position})
return box
# ------------------------------------------------------------------- state
def set_theme(self, theme: str) -> None:
self._theme = theme
self.update_snapshot(self._snapshot)
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):
button.setEnabled(self._connected)
def update_snapshot(self, snapshot: Snapshot) -> None:
self._snapshot = snapshot
self.set_connected(snapshot.connected)
for axis in range(self.axis_count):
state = snapshot.axis(axis)
widgets = self._rows[axis]
if state is None:
widgets["state"].setText(t("axes.enable_off"))
widgets["position"].setText("—")
widgets["state"].setStyleSheet(self._colour("muted"))
continue
if state.status.alarm:
widgets["state"].setText(t("status.alarm_short"))
widgets["state"].setStyleSheet(self._colour("error"))
elif state.enabled:
widgets["state"].setText(t("axes.enable_on"))
widgets["state"].setStyleSheet(self._colour("ok"))
else:
widgets["state"].setText(t("axes.enable_off"))
widgets["state"].setStyleSheet(self._colour("muted"))
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:
units_text = t("axes.position_pair", value=units_text, mm=f"{millimetres:.3f}")
widgets["position"].setText(units_text)
widgets["position"].setToolTip(
t("axes.motion_word") + ": " + (t("axes.moving") if not state.idle else t("axes.idle"))
+ (" · " + t("axes.home_ok") if state.home_status == 1 else "")
)
enabled_now = sum(1 for s in snapshot.axes if s.enabled)
self.enable_all_button.setText(
t("axes.disable_all") if enabled_now == self.axis_count else t("axes.enable_all")
)
def _colour(self, tone: str) -> str:
colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888")
return f"color: {colour};"
def _on_enable_all(self) -> None:
enabled_now = sum(1 for s in self._snapshot.axes if s.enabled)
# Toggle: if every drive is already on, this turns them all off.
self.enableAllRequested.emit(enabled_now != self.axis_count)
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setTitle(t("readout.title"))
self.stop_all_button.setText(t("axes.stop_all"))
self.clear_all_button.setText(t("axes.clear_alarm_all"))
# The toggle's label depends on current state, so re-derive it.
self.update_snapshot(self._snapshot)
@@ -0,0 +1,103 @@
"""Top identity strip: brand, product name, and the connection state chip.
Mirrors the studio's brand bar so the two products look like one family, but
carries only what this tool needs: who made it, what it is, and whether the
controller is reachable.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QWidget
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_app_shared.theme import load_colors
#: Chip tone -> colour token name.
_TONE_TOKENS = {
"ok": "accent",
"warn": "warn",
"error": "danger",
"muted": "text_muted",
}
class BrandBar(QFrame):
"""The identity strip, with a state chip that reports the connection."""
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("brandBar")
self.setFixedHeight(40)
self._theme = theme
self._connected = False
# The chip's current content, kept so a theme or locale change can
# re-render the same state without the caller re-stating it.
self._chip_text = ""
self._chip_tone = "muted"
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 4, 12, 4)
layout.setSpacing(10)
self.logo = QLabel(self)
self.logo.setObjectName("brandLogo")
pixmap = QPixmap(str(logo_topbar_path()))
if not pixmap.isNull():
self.logo.setPixmap(
pixmap.scaledToHeight(24, Qt.TransformationMode.SmoothTransformation)
)
layout.addWidget(self.logo)
self.name = QLabel(self)
self.name.setObjectName("brandName")
layout.addWidget(self.name)
self.detail = QLabel(self)
self.detail.setObjectName("brandDetail")
layout.addWidget(self.detail)
layout.addStretch(1)
self.product = QLabel(self)
self.product.setObjectName("brandProduct")
layout.addWidget(self.product)
self.state_chip = QLabel(self)
self.state_chip.setObjectName("stateChip")
layout.addWidget(self.state_chip)
self.retranslate()
# ----------------------------------------------------------------- content
def retranslate(self) -> None:
"""Refresh every label from the active locale."""
self.name.setText(t("brand.name"))
self.detail.setText(t("brand.detail"))
self.product.setText(t("app.title"))
self.set_connected(self._connected)
def set_theme(self, theme: str) -> None:
self._theme = theme
self.set_state_text(self._chip_text, self._chip_tone)
def set_connected(self, connected: bool) -> None:
self._connected = bool(connected)
self.set_state_text(
t("connection.state_open") if connected else t("connection.state_closed"),
"ok" if connected else "muted",
)
def set_state_text(self, text: str, tone: str = "muted") -> None:
self._chip_text = text
self._chip_tone = tone
self.state_chip.setText(text)
token = _TONE_TOKENS.get(tone, "text_muted")
color = load_colors(self._theme).get(token, "#888888")
self.state_chip.setStyleSheet(
f"color: {color}; border: 1px solid {color}; "
"border-radius: 9px; padding: 1px 8px;"
)
@@ -0,0 +1,305 @@
"""Raw bus exchange: EtherCAT object dictionary (SDO) and RTEX parameters.
This is the drive-level panel. It is what a commissioning engineer reaches for
when a node behaves oddly: read an object, see what the drive actually has, write
one back. The controls are enabled strictly by the bus family the ``.bas``
program reported, so an SDO write can never be aimed at an RTEX system (the
vendor reference guards this too, by checking ``BUS_TYPE`` before every call).
"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QComboBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
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.
FIELD_HEIGHT = 28
#: Width of the read/write buttons. A grid stretches a widget across the column
#: it spans, and these share a row with unconstrained columns, so without a cap
#: "读" renders as a 300 px slab.
ACTION_WIDTH = 96
#: Data types accepted by ``ZAux_BusCmd_SDORead``/``SDOWrite``, in combo order.
#: The DLL argument is the index + 1 (see ``sdo_data_type``).
SDO_TYPES = (
("int8", 1),
("int16", 2),
("int32", 3),
("uint8", 4),
("uint16", 5),
("uint32", 6),
)
def sdo_data_type(index: int) -> int:
"""Map a combo index to the DLL's data-type argument."""
if 0 <= index < len(SDO_TYPES):
return SDO_TYPES[index][1]
return SDO_TYPES[0][1]
class BusDataPanel(QGroupBox):
"""SDO and RTEX read/write."""
sdoReadRequested = Signal(int, int, int, int)
sdoWriteRequested = Signal(int, int, int, int, int)
rtexReadRequested = Signal(int, int)
rtexWriteRequested = Signal(int, int, float)
def __init__(
self,
*,
sdo_last: dict | None = None,
rtex_last: dict | None = None,
axis_count: int = 5,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("groupBox")
self._axis_count = int(axis_count)
sdo_last = sdo_last or {}
rtex_last = rtex_last or {}
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
outer.setSpacing(8)
outer.addWidget(self._build_sdo(sdo_last))
outer.addWidget(self._build_rtex(rtex_last))
self.result_label = QLabel(self)
self.result_label.setObjectName("hintLabel")
self.result_label.setWordWrap(True)
outer.addWidget(self.result_label)
self.retranslate()
# Disabled until a snapshot says otherwise: a read or write control that
# is briefly live on a disconnected panel invites a click that cannot
# work, and Qt's default for a new widget is enabled.
self.set_bus_type("unknown")
self._refresh_readonly()
# ------------------------------------------------------------------ widgets
def _build_sdo(self, last: dict) -> QWidget:
self.ecat_box = QGroupBox(self)
grid = QGridLayout(self.ecat_box)
grid.setContentsMargins(10, 6, 10, 6)
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(4)
def field(text: str, width: int) -> QLineEdit:
widget = QLineEdit(str(text), self.ecat_box)
widget.setFixedWidth(width)
widget.setFixedHeight(FIELD_HEIGHT)
return widget
def button(text: str) -> QPushButton:
widget = QPushButton(text, self.ecat_box)
widget.setProperty("rail", True)
widget.setFixedHeight(FIELD_HEIGHT)
widget.setMaximumWidth(ACTION_WIDTH)
return widget
self.node_label = QLabel(self.ecat_box)
self.node_edit = field(last.get("node", 0), 60)
self.index_label = QLabel(self.ecat_box)
self.index_edit = field(last.get("index", 24704), 80)
self.subindex_label = QLabel(self.ecat_box)
self.subindex_edit = field(last.get("subindex", 0), 50)
self.sdo_type_label = QLabel(self.ecat_box)
self.sdo_type_combo = QComboBox(self.ecat_box)
self.sdo_type_combo.setFixedHeight(FIELD_HEIGHT)
for name, _value in SDO_TYPES:
self.sdo_type_combo.addItem(name)
self.sdo_type_combo.setCurrentIndex(int(last.get("type", 0)) % len(SDO_TYPES))
self.sdo_value_label = QLabel(self.ecat_box)
self.sdo_value_edit = field(0, 90)
self.btn_sdo_read = button("")
self.btn_sdo_read.clicked.connect(self._emit_sdo_read)
self.btn_sdo_write = button("")
self.btn_sdo_write.clicked.connect(self._emit_sdo_write)
grid.addWidget(self.node_label, 0, 0)
grid.addWidget(self.node_edit, 0, 1)
grid.addWidget(self.index_label, 0, 2)
grid.addWidget(self.index_edit, 0, 3)
grid.addWidget(self.subindex_label, 0, 4)
grid.addWidget(self.subindex_edit, 0, 5)
grid.addWidget(self.sdo_type_label, 0, 6)
grid.addWidget(self.sdo_type_combo, 0, 7)
grid.addWidget(self.sdo_value_label, 0, 8)
grid.addWidget(self.sdo_value_edit, 0, 9)
grid.addWidget(self.btn_sdo_read, 1, 7)
grid.addWidget(self.btn_sdo_write, 1, 9)
grid.setColumnStretch(10, 1)
return self.ecat_box
def _build_rtex(self, last: dict) -> QWidget:
self.rtex_box = QGroupBox(self)
grid = QGridLayout(self.rtex_box)
grid.setContentsMargins(10, 6, 10, 6)
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(4)
def field(text: str, width: int) -> QLineEdit:
widget = QLineEdit(str(text), self.rtex_box)
widget.setFixedWidth(width)
widget.setFixedHeight(FIELD_HEIGHT)
return widget
def button(text: str) -> QPushButton:
widget = QPushButton(text, self.rtex_box)
widget.setProperty("rail", True)
widget.setFixedHeight(FIELD_HEIGHT)
return widget
self.rtex_axis_label = QLabel(self.rtex_box)
self.rtex_axis_edit = field(last.get("axis", 0), 50)
self.rtex_parameter_label = QLabel(self.rtex_box)
self.rtex_parameter_edit = field(last.get("parameter", 0), 90)
self.rtex_value_label = QLabel(self.rtex_box)
self.rtex_value_edit = field(0, 90)
self.btn_rtex_read = button("")
self.btn_rtex_read.clicked.connect(self._emit_rtex_read)
self.btn_rtex_write = button("")
self.btn_rtex_write.clicked.connect(self._emit_rtex_write)
grid.addWidget(self.rtex_axis_label, 0, 0)
grid.addWidget(self.rtex_axis_edit, 0, 1)
grid.addWidget(self.rtex_parameter_label, 0, 2)
grid.addWidget(self.rtex_parameter_edit, 0, 3)
grid.addWidget(self.rtex_value_label, 0, 4)
grid.addWidget(self.rtex_value_edit, 0, 5)
grid.addWidget(self.btn_rtex_read, 0, 6)
grid.addWidget(self.btn_rtex_write, 0, 7)
grid.setColumnStretch(8, 1)
return self.rtex_box
# ------------------------------------------------------------------- state
def set_bus_type(self, bus_type: str) -> None:
"""Enable only the panel matching the bus the controller reported."""
self._bus_type = bus_type
supports_sdo = bus_type == "ecat"
supports_rtex = bus_type == "rtex"
self.ecat_box.setEnabled(supports_sdo)
self.rtex_box.setEnabled(supports_rtex)
self._refresh_readonly()
_bus_type = "unknown"
def set_connected(self, connected: bool) -> None:
self._connected = bool(connected)
self._refresh_readonly()
_connected = False
def _refresh_readonly(self) -> None:
# Re-applying the bus type is what actually gates the boxes; a
# disconnected session disables both regardless of family.
if not self._connected:
self.ecat_box.setEnabled(False)
self.rtex_box.setEnabled(False)
return
self.ecat_box.setEnabled(self._bus_type == "ecat")
self.rtex_box.setEnabled(self._bus_type == "rtex")
def set_result(self, text: str) -> None:
self.result_label.setText(text)
def sdo_request(self) -> tuple[int, int, int, int]:
"""The SDO fields as the service expects them."""
return (
self._int(self.node_edit.text()),
self._int(self.index_edit.text()),
self._int(self.subindex_edit.text()),
sdo_data_type(self.sdo_type_combo.currentIndex()),
)
def rtex_request(self) -> tuple[int, int]:
return (
self._int(self.rtex_axis_edit.text()),
self._int(self.rtex_parameter_edit.text()),
)
@staticmethod
def _int(text: str) -> int:
try:
return int(str(text).strip() or 0, 0)
except ValueError:
return 0
def remember_state(self) -> dict[str, dict]:
"""The field values the preference file should keep."""
node, index, subindex, data_type = self.sdo_request()
axis, parameter = self.rtex_request()
type_index = next(
(i for i, (_name, value) in enumerate(SDO_TYPES) if value == data_type), 0
)
return {
"sdo_last": {
"node": node,
"index": index,
"subindex": subindex,
"type": type_index,
},
"rtex_last": {"axis": axis, "parameter": parameter},
}
# ----------------------------------------------------------------- actions
def _emit_sdo_read(self) -> None:
self.sdoReadRequested.emit(*self.sdo_request())
def _emit_sdo_write(self) -> None:
node, index, subindex, data_type = self.sdo_request()
self.sdoWriteRequested.emit(
node, index, subindex, data_type, self._int(self.sdo_value_edit.text())
)
def _emit_rtex_read(self) -> None:
self.rtexReadRequested.emit(*self.rtex_request())
def _emit_rtex_write(self) -> None:
axis, parameter = self.rtex_request()
try:
value = float(self.rtex_value_edit.text())
except ValueError:
self.set_result(t("busdata.not_available"))
return
self.rtexWriteRequested.emit(axis, parameter, value)
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setTitle(t("busdata.title"))
self.ecat_box.setTitle(t("busdata.ecat"))
self.rtex_box.setTitle(t("busdata.rtex"))
self.node_label.setText(t("busdata.node"))
self.index_label.setText(t("busdata.index"))
self.subindex_label.setText(t("busdata.subindex"))
self.sdo_type_label.setText(t("busdata.type"))
self.sdo_value_label.setText(t("busdata.value"))
self.btn_sdo_read.setText(t("busdata.read"))
self.btn_sdo_write.setText(t("busdata.write"))
self.rtex_axis_label.setText(t("busdata.axis"))
self.rtex_parameter_label.setText(t("busdata.parameter"))
self.rtex_value_label.setText(t("busdata.value"))
self.btn_rtex_read.setText(t("busdata.read"))
self.btn_rtex_write.setText(t("busdata.write"))
@@ -0,0 +1,174 @@
"""Bus initialisation: pick a BAS program, run it, and read the result.
The sequence is the vendor's, not ours: ``BasDown`` writes the program to ROM
and the controller runs it; the program delays, scans the slot, maps axes, and
finally publishes ``BUS_TYPE``, ``Bus_InitStatus``, ``Bus_TotalAxisnum`` and the
node count. This panel is a view onto that state machine plus the two buttons
that (re)start it.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
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 = {
INIT_RUNNING: "bus.state_running",
INIT_OK: "bus.state_ok",
INIT_FAILED: "bus.state_failed",
}
_STATE_TONES = {
"bus.state_running": "warn",
"bus.state_ok": "ok",
"bus.state_failed": "error",
"bus.state_timeout": "error",
}
#: The vendor requires a non-Chinese path for a BAS project; default the picker
#: at the shipped copies so the operator starts somewhere legal.
DEFAULT_BAS_DIR_NAME = "bas"
def default_bas_dir() -> Path:
return app_root() / "config" / DEFAULT_BAS_DIR_NAME
class BusPanel(QGroupBox):
"""BAS download, re-initialisation, and decoded bus state."""
chooseRequested = Signal()
downloadRequested = Signal()
reinitRequested = Signal()
def __init__(self, bas_path: str = "", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("groupBox")
self._bas_path = bas_path
self._state_text = t("bus.state_pending")
self._state_tone = "muted"
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
outer.setSpacing(8)
grid = QGridLayout()
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(6)
self.path_label = QLabel(self)
self.path_edit = QLineEdit(self)
self.path_edit.setReadOnly(True)
self.path_edit.setText(bas_path)
self.choose_button = QPushButton(self)
self.choose_button.clicked.connect(self.chooseRequested)
grid.addWidget(self.path_label, 0, 0)
grid.addWidget(self.path_edit, 0, 1, 1, 2)
grid.addWidget(self.choose_button, 0, 3)
self.state_label = QLabel(self)
self.state_value = QLabel(self)
self.state_value.setObjectName("statusValue")
grid.addWidget(self.state_label, 1, 0)
grid.addWidget(self.state_value, 1, 1, 1, 3)
self.type_label = QLabel(self)
self.type_value = QLabel(self)
self.type_value.setObjectName("statusValue")
grid.addWidget(self.type_label, 2, 0)
grid.addWidget(self.type_value, 2, 1, 1, 3)
self.nodes_label = QLabel(self)
self.nodes_value = QLabel(self)
self.nodes_value.setObjectName("statusValue")
self.axes_label = QLabel(self)
self.axes_value = QLabel(self)
self.axes_value.setObjectName("statusValue")
grid.addWidget(self.nodes_label, 3, 0)
grid.addWidget(self.nodes_value, 3, 1)
grid.addWidget(self.axes_label, 3, 2)
grid.addWidget(self.axes_value, 3, 3)
outer.addLayout(grid)
buttons = QHBoxLayout()
self.download_button = QPushButton(self)
self.download_button.setObjectName("primaryButton")
self.download_button.clicked.connect(self.downloadRequested)
self.reinit_button = QPushButton(self)
self.reinit_button.clicked.connect(self.reinitRequested)
buttons.addWidget(self.download_button)
buttons.addWidget(self.reinit_button)
buttons.addStretch(1)
outer.addLayout(buttons)
self.message_label = QLabel(self)
self.message_label.setObjectName("hintLabel")
self.message_label.setWordWrap(True)
outer.addWidget(self.message_label)
self.retranslate()
# ------------------------------------------------------------------- state
@property
def bas_path(self) -> str:
return self._bas_path
def set_bas_path(self, path: str) -> None:
self._bas_path = path
self.path_edit.setText(path)
def set_connected(self, connected: bool) -> None:
self.download_button.setEnabled(connected)
self.reinit_button.setEnabled(connected)
self.choose_button.setEnabled(True)
def set_status(self, bus: BusStatus, *, now: float | None = None) -> None:
"""Render the bus state, including the vendor's timeout case."""
key = _STATE_KEYS.get(bus.init_status, "bus.state_pending")
if bus.init_status == INIT_FAILED and bus.message and "超时" in bus.message:
key = "bus.state_timeout"
self._state_text = t(key)
self._state_tone = _STATE_TONES.get(key, "muted")
self.state_value.setText(self._state_text)
type_keys = {
"ecat": "bus.type_ecat",
"rtex": "bus.type_rtex",
"unknown": "bus.type_unknown",
}
self.type_value.setText(t(type_keys.get(bus.bus_type, "bus.type_unknown")))
self.nodes_value.setText(str(bus.node_count))
self.axes_value.setText(str(bus.total_axes))
self.message_label.setText(bus.message)
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setTitle(t("bus.title"))
self.path_label.setText(t("bus.bas_path"))
self.state_label.setText(t("bus.state"))
self.type_label.setText(t("bus.type"))
self.nodes_label.setText(t("bus.nodes"))
self.axes_label.setText(t("bus.axes"))
self.choose_button.setText(t("bus.choose"))
self.download_button.setText(t("bus.download"))
self.reinit_button.setText(t("bus.reinit"))
self.state_value.setText(self._state_text)
@@ -0,0 +1,218 @@
"""Connection controls: mode, address, scan, connect, and connection state."""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QComboBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from ..motion import ConnectionSpec
from ..preferences import CONNECTION_MODES, DEFAULT_CONTROLLER_IPS
#: Modes that are wired up. The rest report "not available yet" rather than
#: pretending to connect: a serial/PCI path that silently does nothing is worse
#: than one that says it is not there.
_IMPLEMENTED_MODES = ("eth", "local", "demo")
_MODE_LABEL_KEYS = {
"eth": "connection.mode_eth",
"com": "connection.mode_com",
"pci": "connection.mode_pci",
"local": "connection.mode_local",
"demo": "connection.mode_demo",
}
class ConnectionPanel(QGroupBox):
"""Everything needed to reach (or leave) the controller."""
connectRequested = Signal(object)
disconnectRequested = Signal()
scanRequested = Signal()
def __init__(self, spec: ConnectionSpec, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("groupBox")
self._spec = spec
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
outer.setSpacing(8)
grid = QGridLayout()
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(6)
self.mode_label = QLabel(self)
self.mode_combo = QComboBox(self)
for mode in CONNECTION_MODES:
self.mode_combo.addItem("", mode)
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
grid.addWidget(self.mode_label, 0, 0)
grid.addWidget(self.mode_combo, 0, 1, 1, 2)
self.address_label = QLabel(self)
self.address_edit = QLineEdit(self)
self.address_edit.setMinimumWidth(150)
self.scan_button = QPushButton(self)
self.scan_button.clicked.connect(self.scanRequested)
grid.addWidget(self.address_label, 1, 0)
grid.addWidget(self.address_edit, 1, 1)
grid.addWidget(self.scan_button, 1, 2)
self.ip_hint = QLabel(self)
self.ip_hint.setObjectName("hintLabel")
grid.addWidget(self.ip_hint, 2, 1, 1, 2)
outer.addLayout(grid)
buttons = QHBoxLayout()
self.open_button = QPushButton(self)
self.open_button.setObjectName("primaryButton")
self.open_button.clicked.connect(self._emit_connect)
self.close_button = QPushButton(self)
self.close_button.clicked.connect(self.disconnectRequested)
self.close_button.setEnabled(False)
buttons.addWidget(self.open_button)
buttons.addWidget(self.close_button)
buttons.addStretch(1)
outer.addLayout(buttons)
self.status_label = QLabel(self)
self.status_label.setObjectName("statusValue")
self.status_label.setWordWrap(True)
outer.addWidget(self.status_label)
self.source_label = QLabel(self)
self.source_label.setObjectName("hintLabel")
self.source_label.setWordWrap(True)
outer.addWidget(self.source_label)
self.retranslate()
self.apply_spec(spec)
# ------------------------------------------------------------------- state
@property
def spec(self) -> ConnectionSpec:
"""The spec the fields currently describe."""
mode = self.mode_combo.currentData() or "eth"
if mode == "eth":
return ConnectionSpec(mode="eth", ip=self.address_edit.text().strip())
if mode == "local":
return ConnectionSpec(mode="local", local_string=self.address_edit.text().strip() or "0")
if mode == "com":
return ConnectionSpec(mode="com", com_id=self._as_int(self.address_edit.text()))
if mode == "pci":
return ConnectionSpec(mode="pci", card_num=self._as_int(self.address_edit.text()))
return ConnectionSpec(mode="demo")
@staticmethod
def _as_int(text: str) -> int:
try:
return int(str(text).strip() or 0)
except ValueError:
return 0
def apply_spec(self, spec: ConnectionSpec) -> None:
self._spec = spec
index = self.mode_combo.findData(spec.mode)
if index >= 0:
self.mode_combo.setCurrentIndex(index)
self._fill_address(spec)
self._on_mode_changed()
def _fill_address(self, spec: ConnectionSpec) -> None:
if spec.mode == "eth":
self.address_edit.setText(spec.ip or DEFAULT_CONTROLLER_IPS[0])
elif spec.mode == "com":
self.address_edit.setText(str(spec.com_id))
elif spec.mode == "pci":
self.address_edit.setText(str(spec.card_num))
elif spec.mode == "local":
self.address_edit.setText(spec.local_string or "0")
else:
self.address_edit.setText("")
def set_ips(self, ips: list[str]) -> None:
"""Offer scanned addresses for the Ethernet mode."""
if self.mode_combo.currentData() != "eth" or not ips:
return
current = self.address_edit.text().strip()
if current and current not in ips:
return
self.ip_hint.setText(" ".join(ips[:4]))
def set_connected(self, connected: bool, source: str = "") -> None:
self.open_button.setEnabled(not connected)
self.close_button.setEnabled(connected)
self.mode_combo.setEnabled(not connected)
self.address_edit.setEnabled(not connected)
self.status_label.setText(
t("connection.state_open") if connected else t("connection.state_closed")
)
self.status_label.setObjectName("statusValueOk" if connected else "statusValue")
self.source_label.setText(source)
# Re-apply the stylesheet so the object-name change takes effect.
self.status_label.style().unpolish(self.status_label)
self.status_label.style().polish(self.status_label)
def set_status(self, text: str) -> None:
self.status_label.setText(text)
# ----------------------------------------------------------------- actions
def _emit_connect(self) -> None:
mode = self.mode_combo.currentData() or "eth"
if mode not in _IMPLEMENTED_MODES:
self.set_status(t("connection.no_more", mode=t(_MODE_LABEL_KEYS[mode])))
return
self.connectRequested.emit(self.spec)
def _on_mode_changed(self) -> None:
mode = self.mode_combo.currentData() or "eth"
keys = {
"eth": ("connection.ip", True),
"com": ("connection.com", True),
"pci": ("connection.card", True),
"local": ("connection.local_string", True),
"demo": ("connection.mode", False),
}
label_key, editable = keys.get(mode, ("connection.ip", True))
self.address_label.setText(t(label_key))
self.address_edit.setEnabled(editable)
self.scan_button.setEnabled(mode == "eth")
self.ip_hint.setText("")
if mode == "com":
self.address_edit.setText(str(self._spec.com_id))
elif mode == "pci":
self.address_edit.setText(str(self._spec.card_num))
elif mode == "local":
self.address_edit.setText(self._spec.local_string or "0")
elif mode == "eth":
self.address_edit.setText(self._spec.ip or DEFAULT_CONTROLLER_IPS[0])
else:
self.address_edit.setText("")
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setTitle(t("connection.title"))
self.mode_label.setText(t("connection.mode"))
for index, mode in enumerate(CONNECTION_MODES):
self.mode_combo.setItemText(index, t(_MODE_LABEL_KEYS[mode]))
self.scan_button.setText(t("connection.scan"))
self.open_button.setText(t("connection.open"))
self.close_button.setText(t("connection.close"))
self._on_mode_changed()
@@ -0,0 +1,655 @@
"""Test-sequence design: what points on the sensor get tested (workflow P5).
Three zones, because point generation is three questions that must be answered
together:
* **profile** — the shape and the motion: grid mode, pitch and margin, standoff
heights, approach and press speeds, press depth, dwell, settle window,
repeats, visiting order. Persisted, so a bench profile is entered once.
Laid out as two columns of six fields: one column of twelve wasted the vertical
space the point list needs, and the list is the part that grows.
* **preview** — the sensor surface with the generated points and the order they
will be visited. Reserved space for the 3D view; today it says so rather than
pretending.
* **validation + point table** — every generated point is checked three ways
(on the surface, inside travel, contact confirmable) and carries a run status,
so the design and the run progress live in the same list. The list is a
``QTableWidget`` because it has to hold hundreds of points and scroll; the
previous fixed grid could only ever show three rows.
Nothing here moves the platform yet. The controls that would generate a point
set are present and bound to the profile, but generation, simulation and the run
are later phases — and :meth:`SequencePanel.planned_steps` still returns an empty
list so a runner cannot start from a table that was never validated.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from ..preferences import POINT_ORDERS, SURFACE_MODES
from ..sequence_io import (
SequencePoint,
format_duration,
format_span,
summarise,
)
#: Profile fields, in form order: ``(key, locale_key, kind)``.
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
("mode", "sequence.field_mode", "choice"),
("pitch_mm", "sequence.field_pitch", "number"),
("margin_mm", "sequence.field_margin", "number"),
("radius_mm", "sequence.field_radius", "number"),
("clear_height_mm", "sequence.field_clear_height", "number"),
("approach_speed_mm_s", "sequence.field_approach_speed", "number"),
("press_speed_mm_s", "sequence.field_press_speed", "number"),
("press_depth_mm", "sequence.field_press_depth", "number"),
("dwell_s", "sequence.field_dwell", "number"),
("settle_window_s", "sequence.field_settle", "number"),
("repeats", "sequence.field_repeats", "number"),
("order", "sequence.field_order", "choice"),
)
#: How many rows the two-column profile form has.
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
#: Point-table columns: ``(key, locale_key)``.
#:
#: No actions column. It was rendered empty on every row, and an empty action
#: cell reads as a broken control — the per-point actions (re-measure, skip)
#: belong to the run, not to the design screen.
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("index", "sequence.col_index"),
("x", "sequence.col_x"),
("y", "sequence.col_y"),
("z", "sequence.col_z"),
("status", "sequence.col_status"),
("sensitivity", "sequence.col_sensitivity"),
("samples", "sequence.col_samples"),
)
#: Editability of the point table. Positions are *generated*, not typed: a typed
#: coordinate is one that nothing has checked against the sensor.
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
#: Column widths of the point table, by column index.
_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
#: Reserved controls and the phase that will fill each one.
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
#: Which phase each pending control belongs to (see docs/workflow.md).
PENDING_PHASES: dict[str, str] = {
"generate": "P5",
"simulate": "P6",
"dry_run": "P6",
}
#: Controls that work now: a sequence is a document, and P8 says it is a
#: versioned artifact. Import and export are the first half of that.
LIVE_CONTROLS: tuple[str, ...] = ("export", "import")
#: The validation checks, with the locale key naming what each one proves.
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
("on_surface", "sequence.check_surface"),
("within_travel", "sequence.check_travel"),
("contact_confirmable", "sequence.check_contact"),
)
#: Placeholder rows, so the flow shows its shape. Marked as unmeasured: a row
#: with no status is a design, not a measurement.
_EXAMPLE_POINTS: tuple[tuple[float, float, float], ...] = (
(0.0, 0.0, 12.4),
(2.0, 0.0, 12.4),
(4.0, 0.0, 12.4),
)
@dataclass(frozen=True)
class SequenceStep:
"""One planned step, as the future runner will receive it.
``x``/``y``/``z`` are platform-frame user units (millimetres are shown beside
them), ``repeat`` is a count, and ``force_n`` is ``None`` until FT feedback
exists. ``sensor_xyz`` is the point's position in the sensor frame, computed
from the P4 transform — the thing that makes it checkable against the surface.
"""
index: int
targets: dict[str, float]
sensor_xyz: tuple[float, float, float] | None = None
repeat: int = 1
force_n: float | None = None
dwell_s: float = 0.0
note: str = ""
class SequencePanel(QGroupBox):
"""Workflow P5: profile, preview, validation and the point list."""
generateRequested = Signal()
simulateRequested = Signal()
dryRunRequested = Signal()
exportRequested = Signal()
importRequested = Signal()
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("groupBox")
self._profile_fields: dict[str, QWidget] = {}
self._profile_labels: list[QLabel] = []
self._choices: dict[str, QComboBox] = {}
self._pending_buttons: dict[str, QPushButton] = {}
self._check_rows: dict[str, QLabel] = {}
self._summary: dict[str, Any] = {}
self._dirty = False
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
outer.setSpacing(8)
zones = QHBoxLayout()
zones.setSpacing(10)
zones.addWidget(self._build_profile(), 0)
zones.addWidget(self._build_preview(), 1)
zones.addWidget(self._build_validation(), 0)
outer.addLayout(zones, 0)
# The point list is the part that grows, so it takes the vertical stretch
# and the zones above it get what they need.
outer.addWidget(self._build_point_table(), 1)
self.set_profile(profile or {})
self.retranslate()
# A profile edit changes the estimate, so the line follows the form.
for widget in self._profile_fields.values():
if isinstance(widget, QComboBox):
widget.currentIndexChanged.connect(self._mark_dirty)
else:
widget.textChanged.connect(self._mark_dirty)
self.refresh_summary()
# ------------------------------------------------------------------ profile
def _build_profile(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
outer = QVBoxLayout(box)
outer.setContentsMargins(10, 8, 10, 8)
outer.setSpacing(4)
# The name is what the document is called on disk and in the export, so
# it lives at the top of the block that defines the sequence rather than
# in a dialog.
name_row = QHBoxLayout()
name_row.setSpacing(8)
self._name_label = QLabel(box)
self._name_label.setObjectName("hintLabel")
self.name_edit = QLineEdit(box)
self.name_edit.setFixedHeight(28)
name_row.addWidget(self._name_label)
name_row.addWidget(self.name_edit, 1)
outer.addLayout(name_row)
self._note_label = QLabel(box)
self._note_label.setObjectName("hintLabel")
self.note_edit = QLineEdit(box)
self.note_edit.setFixedHeight(28)
outer.addWidget(self._note_label)
outer.addWidget(self.note_edit)
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(4)
outer.addLayout(grid)
# Two fields per row: label, field, label, field. Seeing the whole profile
# at once is what lets an operator sanity-check a pitch against a depth.
for index, (key, _locale_key, kind) in enumerate(PROFILE_FIELDS):
row, side = divmod(index, 2)
column = side * 2
label = QLabel(box)
label.setObjectName("hintLabel")
grid.addWidget(label, row, column)
self._profile_labels.append(label)
if kind == "choice":
field: QWidget = QComboBox(box)
field.setFixedHeight(28)
self._choices[key] = field # type: ignore[assignment]
else:
field = QLineEdit(box)
field.setFixedHeight(28)
field.setProperty("fieldKey", key)
grid.addWidget(field, row, column + 1)
self._profile_fields[key] = field
grid.setColumnStretch(1, 1)
grid.setColumnStretch(3, 1)
self._profile_box = box
return box
# ------------------------------------------------------------------ preview
def _build_preview(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
column = QVBoxLayout(box)
column.setContentsMargins(10, 8, 10, 8)
column.setSpacing(6)
self.preview_label = QLabel(box)
self.preview_label.setObjectName("hintLabel")
self.preview_label.setWordWrap(True)
self.preview_label.setMinimumHeight(120)
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
column.addWidget(self.preview_label, 1)
self.preview_hint = QLabel(box)
self.preview_hint.setObjectName("hintLabel")
self.preview_hint.setWordWrap(True)
column.addWidget(self.preview_hint)
self._preview_box = box
return box
# --------------------------------------------------------------- validation
def _build_validation(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
column = QVBoxLayout(box)
column.setContentsMargins(10, 8, 10, 8)
column.setSpacing(4)
for key, _locale_key in VALIDATION_CHECKS:
row = QLabel(box)
row.setObjectName("statusValue")
row.setWordWrap(True)
column.addWidget(row)
self._check_rows[key] = row
self.summary_label = QLabel(box)
self.summary_label.setObjectName("hintLabel")
self.summary_label.setWordWrap(True)
column.addWidget(self.summary_label)
column.addStretch(1)
for key in PENDING_CONTROLS:
button = QPushButton(box)
button.setProperty("rail", True)
button.setFixedHeight(28)
column.addWidget(button)
self._pending_buttons[key] = button
# Import/export work today: a sequence is a portable document (P8).
self._live_buttons: dict[str, QPushButton] = {}
for key in LIVE_CONTROLS:
button = QPushButton(box)
button.setProperty("rail", True)
button.setFixedHeight(28)
column.addWidget(button)
self._live_buttons[key] = button
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
self._live_buttons["export"].clicked.connect(self.exportRequested)
self._live_buttons["import"].clicked.connect(self.importRequested)
# Generation, simulation and the runner belong to later phases (P5-P7).
for button in self._pending_buttons.values():
button.setEnabled(False)
self._validation_box = box
return box
# -------------------------------------------------------------- point table
def _mark_dirty(self, *_args: Any) -> None:
self._dirty = True
self.refresh_summary()
def _build_point_table(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
column = QVBoxLayout(box)
column.setContentsMargins(10, 8, 10, 8)
column.setSpacing(6)
self.table = QTableWidget(len(_EXAMPLE_POINTS), len(POINT_COLUMNS), box)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionMode(QTableWidget.SelectionMode.NoSelection)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.table.setAlternatingRowColors(True)
for index, width in enumerate(_POINT_COLUMN_WIDTHS):
self.table.setColumnWidth(index, width)
self.table.horizontalHeader().setSectionResizeMode(
len(POINT_COLUMNS) - 1, QHeaderView.ResizeMode.Stretch
)
# The list scrolls rather than clipping: a grid of fixed labels could
# only ever show the rows it was built with.
self.table.setMinimumHeight(120)
for row, (x, y, z) in enumerate(_EXAMPLE_POINTS):
values = {
0: str(row + 1),
1: f"{x:.3f}",
2: f"{y:.3f}",
3: f"{z:.3f}",
4: t("sequence.status_pending"),
5: "—",
6: "—",
}
for column_index, text in values.items():
item = QTableWidgetItem(text)
if column_index >= 4:
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column_index, item)
# The count and span line: the two numbers an operator reasons with, and
# the only place a wrong pitch or margin shows up before a seven-minute
# run does it for them.
self.summary_label = QLabel(box)
self.summary_label.setObjectName("statusValue")
self.summary_label.setWordWrap(True)
column.addWidget(self.table, 1)
column.addWidget(self.summary_label)
self.point_hint = QLabel(box)
self.point_hint.setObjectName("hintLabel")
self.point_hint.setWordWrap(True)
column.addWidget(self.point_hint)
self._table_box = box
return box
# -------------------------------------------------------------------- model
def profile(self) -> dict[str, Any]:
"""The profile as typed, with numbers parsed where they are numbers."""
values: dict[str, Any] = {}
for key, _locale_key, kind in PROFILE_FIELDS:
widget = self._profile_fields[key]
if kind == "choice":
values[key] = widget.currentData() or widget.currentText()
else:
values[key] = _as_number(widget.text(), 0.0)
values["repeats"] = int(max(1, values.get("repeats", 1)))
return values
def set_profile(self, profile: dict[str, Any]) -> None:
"""Load a stored profile; unknown or missing keys keep their default."""
for key, _locale_key, kind in PROFILE_FIELDS:
if key not in profile:
continue
widget = self._profile_fields[key]
value = profile[key]
if kind == "choice":
index = widget.findData(value)
if index >= 0:
widget.setCurrentIndex(index)
else:
widget.setText(_format_number(value))
def profile_keys(self) -> list[str]:
return [key for key, _locale_key, _kind in PROFILE_FIELDS]
def field(self, key: str) -> QWidget:
return self._profile_fields[key]
def choice(self, key: str) -> QComboBox:
return self._choices[key]
def pending_controls(self) -> list[str]:
return list(PENDING_CONTROLS)
def pending_phases(self) -> dict[str, str]:
return dict(PENDING_PHASES)
def validation_checks(self) -> list[str]:
return [key for key, _locale_key in VALIDATION_CHECKS]
def check_row(self, key: str) -> QLabel:
return self._check_rows[key]
def set_check(self, key: str, text: str, ok: bool = True) -> None:
"""Report one validation result (used once generation exists)."""
row = self._check_rows[key]
row.setText(text)
row.setProperty("checkOk", ok)
def column_keys(self) -> list[str]:
return [key for key, _locale_key in POINT_COLUMNS]
def point_row(self, index: int) -> dict[str, str]:
return {
key: (self.table.item(index, column).text() if self.table.item(index, column) else "")
for column, (key, _locale_key) in enumerate(POINT_COLUMNS)
}
def point_row_count(self) -> int:
return self.table.rowCount()
def append_point(self, x: float, y: float, z: float, status: str = "") -> int:
"""Add a generated point. Used by the generator once P5 exists."""
row = self.table.rowCount()
self.table.insertRow(row)
values = {
0: str(row + 1),
1: f"{x:.3f}",
2: f"{y:.3f}",
3: f"{z:.3f}",
4: status or t("sequence.status_pending"),
5: "—",
6: "—",
}
for column, text in values.items():
item = QTableWidgetItem(text)
if column >= 4:
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column, item)
return row
# ----------------------------------------------------------- points & state
def points(self) -> list[SequencePoint]:
"""The list as it stands, for export and for a future runner."""
points: list[SequencePoint] = []
for row in range(self.table.rowCount()):
cells = self.point_row(row)
try:
target = {axis: float(cells[axis]) for axis in "xyz"}
except (TypeError, ValueError):
continue # a placeholder row that was never generated
points.append(
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
)
return points
def set_points(self, points: list[SequencePoint]) -> None:
"""Replace the list. Used by import and by the future generator."""
self.table.setRowCount(0)
for point in points:
row = self.table.rowCount()
self.table.insertRow(row)
values = {
0: str(point.id),
1: f"{point.target_mm.get('x', 0.0):.3f}",
2: f"{point.target_mm.get('y', 0.0):.3f}",
3: f"{point.target_mm.get('z', 0.0):.3f}",
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
5: "—",
6: "—",
}
for column, text in values.items():
item = QTableWidgetItem(text)
if column >= 4:
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column, item)
self._dirty = True
self.refresh_summary()
@property
def dirty(self) -> bool:
"""Whether the design has changed since it was last saved or loaded.
Import replaces the design outright, and there is no autosave; discarding
a designed sequence without asking would be worse than one extra prompt.
"""
return self._dirty
def mark_clean(self) -> None:
self._dirty = False
def name(self) -> str:
return self.name_edit.text().strip()
def set_name(self, value: str) -> None:
self.name_edit.setText(str(value or ""))
def note(self) -> str:
return self.note_edit.text().strip()
def set_note(self, value: str) -> None:
self.note_edit.setText(str(value or ""))
def refresh_summary(self) -> None:
"""Recompute the count, span and duration line from what is on screen."""
profile = self.profile()
points = self.points()
summary = summarise(points, profile)
self._summary = summary
self.summary_label.setText(_summary_text(summary, profile))
def planned_steps(self) -> list[SequenceStep]:
"""The steps the table describes, for the future runner.
Empty while the tab is a design surface: generation has not run, nothing
has been validated, and a runner must not start from a table that was
never checked — which is what the P5 gate exists to prevent.
"""
return []
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.setTitle(t("sequence.title"))
self._profile_box.setTitle(t("sequence.profile_title"))
self._preview_box.setTitle(t("sequence.preview_title"))
self._validation_box.setTitle(t("sequence.validation_title"))
self._table_box.setTitle(t("sequence.points_title"))
for label, (_key, locale_key, _kind) in zip(self._profile_labels, PROFILE_FIELDS):
label.setText(t(locale_key))
for key, combo in self._choices.items():
_fill_choice(combo, key)
self._name_label.setText(t("sequence.field_name"))
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
self._note_label.setText(t("sequence.field_note"))
self.preview_label.setText(t("sequence.preview_placeholder"))
self.preview_hint.setText(t("sequence.preview_hint"))
for key, _locale_key in VALIDATION_CHECKS:
self._check_rows[key].setText(t(f"sequence.check_{key}") + ": —")
self.summary_label.setText(t("sequence.validation_pending"))
for key in PENDING_CONTROLS:
self._pending_buttons[key].setText(t(f"sequence.btn_{key}"))
self._pending_buttons[key].setToolTip(
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
)
for key in LIVE_CONTROLS:
self._live_buttons[key].setText(t(f"sequence.btn_{key}"))
self._live_buttons[key].setToolTip(t("sequence.import_export_tip"))
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
for row in range(self.table.rowCount()):
item = self.table.item(row, 4)
if item is not None and item.text() in ("", t("sequence.status_pending")):
item.setText(t("sequence.status_pending"))
self.point_hint.setText(t("sequence.points_hint"))
self.refresh_summary()
def _summary_text(summary: dict[str, Any], profile: dict[str, Any]) -> str:
"""The count / span / duration line under the point list.
Spans are ``min–max (span)`` with a nil span printed once; the duration is
labelled an estimate because it is one.
"""
count = int(summary.get("count") or 0)
enabled = int(summary.get("enabled") or count)
if not count:
return t("sequence.summary_empty")
spans = " · ".join(
f"{axis.upper()} {format_span(summary['min_mm'].get(axis), summary['max_mm'].get(axis))}"
for axis in "xyz"
if axis in summary.get("min_mm", {})
)
duration = format_duration(summary.get("estimated_duration_s"))
return t(
"sequence.summary",
count=count,
enabled=enabled,
spans=spans,
duration=duration,
)
def _fill_choice(combo: QComboBox, key: str) -> None:
"""Populate a choice field from its option list, keeping the selection."""
current = combo.currentData()
combo.blockSignals(True)
combo.clear()
if key == "mode":
for value in SURFACE_MODES:
combo.addItem(t(f"sequence.mode_{value}"), value)
elif key == "order":
for value in POINT_ORDERS:
combo.addItem(t(f"sequence.order_{value}"), value)
combo.blockSignals(False)
if current is not None:
index = combo.findData(current)
if index >= 0:
combo.setCurrentIndex(index)
def _as_number(text: str, fallback: float) -> float:
try:
value = float(str(text).strip())
except (TypeError, ValueError):
return fallback
return value if value == value else fallback # reject NaN
def _format_number(value: Any) -> str:
try:
number = float(value)
except (TypeError, ValueError):
return str(value)
if number == int(number) and abs(number) < 1e6:
return str(int(number))
return f"{number:g}"
@@ -0,0 +1,438 @@
"""The permanent motion status strip, and the run bar beneath it.
Two rows on purpose:
* :class:`StatusStrip` — connection, bus and last-action state. This is what must
not move: the operator needs to know whether the platform is live while
working in any tab, so it sits **above** the workspace tabs and switching tabs
cannot change what it says. It also carries the poll-health chip, because a
controller that stopped answering looks identical to one that is answering
normally unless something shows the age of the last reading.
* :class:`RunBar` — a run's phase, progress and controls. Separate from the strip
so a run cannot hide the platform state, and because the abort control must be
reachable from every tab rather than from inside the tab that started the run.
Neither widget issues a controller call. The window wires the signals, so there
is still one place a command can originate.
"""
from __future__ import annotations
import time
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QProgressBar,
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.
_TONE_TOKENS = {
"ok": "accent",
"warn": "warn",
"error": "danger",
"muted": "text_muted",
}
#: Bus type -> locale key.
_BUS_KEYS = {
"ecat": "bus.type_ecat",
"rtex": "bus.type_rtex",
"unknown": "bus.type_unknown",
}
#: Run phases, and the locale key that names each.
RUN_PHASES = {
"idle": "run.phase_idle",
"dry_run": "run.phase_dry_run",
"running": "run.phase_running",
"paused": "run.phase_paused",
"done": "run.phase_done",
"failed": "run.phase_failed",
}
#: A snapshot older than this many poll intervals is stale, and then unhealthy.
_STALE_INTERVALS = 3.0
_FAILED_INTERVALS = 10.0
class _Chip(QLabel):
"""A small toned label: the strip is read at a glance, not studied."""
def __init__(self, theme: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("statusChip")
self._theme = theme
self._tone = "muted"
self.set_tone("muted")
def set_theme(self, theme: str) -> None:
self._theme = theme
self.set_tone(self._tone)
def set_tone(self, tone: str) -> None:
self._tone = tone
colour = load_colors(self._theme).get(_TONE_TOKENS.get(tone, "text_muted"), "#888888")
self.setStyleSheet(
f"color: {colour}; border: 1px solid {colour}; "
"border-radius: 9px; padding: 1px 9px;"
)
class StatusStrip(QFrame):
"""Connection, bus and last-action state, always on screen."""
pauseRequested = Signal()
stopAllRequested = Signal()
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("statusStrip")
self.setFixedHeight(40)
self._theme = theme
self._snapshot = Snapshot()
self._last_action = ""
self._last_action_ok = True
self._last_snapshot_at: float | None = None
self._poll_interval_ms = 100
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 4, 10, 4)
layout.setSpacing(8)
self.title = QLabel(self)
self.title.setObjectName("stripTitle")
layout.addWidget(self.title)
self.chip_connection = _Chip(theme, self)
self.chip_bus = _Chip(theme, self)
self.chip_init = _Chip(theme, self)
self.chip_axes = _Chip(theme, self)
self.chip_health = _Chip(theme, self)
for chip in (
self.chip_connection,
self.chip_bus,
self.chip_init,
self.chip_axes,
self.chip_health,
):
layout.addWidget(chip)
layout.addStretch(1)
self.action_label = QLabel(self)
self.action_label.setObjectName("stripAction")
layout.addWidget(self.action_label)
self.pause_button = QPushButton(self)
self.pause_button.setProperty("rail", True)
self.pause_button.setCheckable(True)
self.pause_button.clicked.connect(self.pauseRequested)
layout.addWidget(self.pause_button)
self.stop_button = QPushButton(self)
self.stop_button.setObjectName("dangerButton")
self.stop_button.setProperty("rail", True)
self.stop_button.clicked.connect(self.stopAllRequested)
layout.addWidget(self.stop_button)
self.retranslate()
self.set_snapshot(Snapshot())
# ------------------------------------------------------------------- state
def set_theme(self, theme: str) -> None:
self._theme = theme
for chip in (
self.chip_connection,
self.chip_bus,
self.chip_init,
self.chip_axes,
self.chip_health,
):
chip.set_theme(theme)
self.chip_health.set_tone(self.chip_health._tone)
def set_poll_interval_ms(self, interval_ms: int) -> None:
self._poll_interval_ms = max(1, int(interval_ms))
def set_snapshot(self, snapshot: Snapshot) -> None:
"""Render one poll. Never raises: this runs on a timer."""
self._snapshot = snapshot
self._last_snapshot_at = time.monotonic()
connected = snapshot.connected
self.chip_connection.setText(
t("connection.state_open") if connected else t("connection.state_closed")
)
self.chip_connection.set_tone("ok" if connected else "muted")
self.chip_bus.setText(t(_BUS_KEYS.get(snapshot.bus.bus_type, "bus.type_unknown")))
self.chip_bus.set_tone("ok" if snapshot.bus.bus_type != "unknown" else "muted")
if not connected:
self.chip_init.setText(t("bus.state_pending"))
self.chip_init.set_tone("muted")
elif snapshot.bus.initialized:
self.chip_init.setText(t("bus.state_ok"))
self.chip_init.set_tone("ok")
elif snapshot.bus.init_status == 0:
text = (
"bus.state_timeout"
if "超时" in snapshot.bus.message or "timed out" in snapshot.bus.message
else "bus.state_failed"
)
self.chip_init.setText(t(text))
self.chip_init.set_tone("error")
else:
self.chip_init.setText(t("bus.state_running"))
self.chip_init.set_tone("warn")
enabled = sum(1 for state in snapshot.axes if state.enabled)
total = len(snapshot.axes) or self._axis_total
self.chip_axes.setText(t("status.axes_enabled", on=enabled, total=total))
self.chip_axes.set_tone("ok" if enabled else "muted")
self.refresh_health()
self._refresh_action()
#: Fallback axis total before the first snapshot describes the bus.
_axis_total = 5
def set_axis_total(self, total: int) -> None:
self._axis_total = max(1, int(total))
def refresh_health(self) -> None:
"""Show how old the last reading is.
A stalled poll is otherwise invisible: every other chip keeps displaying
its last value, so a controller that stopped answering looks exactly like
one that is answering normally. Called on each snapshot *and* on the
window's own timer, so the chip ages even when no snapshot arrives —
which is the case it exists to reveal.
"""
if self._last_snapshot_at is None:
self.chip_health.setText(t("status.health_none"))
self.chip_health.set_tone("muted")
return
if self.paused:
self.chip_health.setText(t("status.health_paused"))
self.chip_health.set_tone("muted")
return
age_ms = (time.monotonic() - self._last_snapshot_at) * 1000.0
intervals = age_ms / float(self._poll_interval_ms)
self.chip_health.setText(t("status.health_age", ms=int(age_ms)))
if intervals > _FAILED_INTERVALS:
self.chip_health.set_tone("error")
elif intervals > _STALE_INTERVALS:
self.chip_health.set_tone("warn")
else:
self.chip_health.set_tone("ok")
def set_action_result(self, text: str, ok: bool = True) -> None:
"""Record the outcome of the last command, and show it here.
The status bar carries the same text; the strip is what stays visible
when a tab is switched, so the last refusal cannot scroll away unseen.
"""
self._last_action = str(text)
self._last_action_ok = bool(ok)
self._refresh_action()
def _refresh_action(self) -> None:
self.action_label.setText(self._last_action or t("status.none"))
tone = "ok" if self._last_action_ok else "error"
colour = load_colors(self._theme).get(_TONE_TOKENS[tone], "#888888")
self.action_label.setStyleSheet(f"color: {colour};")
@property
def paused(self) -> bool:
return self.pause_button.isChecked()
def set_paused(self, paused: bool) -> None:
self.pause_button.setChecked(bool(paused))
self._refresh_pause_text()
self.refresh_health()
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.title.setText(t("status.motion"))
self._refresh_pause_text()
self.stop_button.setText(t("axes.stop_all"))
self.set_snapshot(self._snapshot)
self.refresh_health()
def _refresh_pause_text(self) -> None:
self.pause_button.setText(
t("status.resume") if self.pause_button.isChecked() else t("status.pause")
)
class RunBar(QFrame):
"""A run's phase, progress and controls, visible from every tab."""
runRequested = Signal()
pauseRequested = Signal()
abortRequested = Signal()
def __init__(self, theme: str = "dark", parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("runBar")
self.setFixedHeight(40)
self._theme = theme
self._phase = "idle"
self._total = 0
self._completed = 0
self._has_run = False
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 4, 10, 4)
layout.setSpacing(8)
self.title = QLabel(self)
self.title.setObjectName("stripTitle")
layout.addWidget(self.title)
self.phase_label = QLabel(self)
self.phase_label.setObjectName("statusValue")
self.phase_label.setMinimumWidth(90)
layout.addWidget(self.phase_label)
self.step_label = QLabel(self)
self.step_label.setObjectName("stripAction")
self.step_label.setMinimumWidth(140)
layout.addWidget(self.step_label)
# Shown only while idle, in place of the step/progress/timing trio.
self._idle_hint = QLabel(self)
self._idle_hint.setObjectName("hintLabel")
layout.addWidget(self._idle_hint)
self.progress = QProgressBar(self)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(12)
self.progress.setMinimumWidth(160)
layout.addWidget(self.progress, 1)
self.time_label = QLabel(self)
self.time_label.setObjectName("stripAction")
self.time_label.setMinimumWidth(180)
layout.addWidget(self.time_label)
self.run_button = QPushButton(self)
self.run_button.setObjectName("primaryButton")
self.pause_button = QPushButton(self)
self.abort_button = QPushButton(self)
self.abort_button.setObjectName("dangerButton")
for button in (self.run_button, self.pause_button, self.abort_button):
button.setProperty("rail", True)
button.setFixedHeight(26)
layout.addWidget(button)
self.run_button.clicked.connect(self.runRequested)
self.pause_button.clicked.connect(self.pauseRequested)
self.abort_button.clicked.connect(self.abortRequested)
self.retranslate()
self.set_run(False)
# ------------------------------------------------------------------- state
def set_theme(self, theme: str) -> None:
self._theme = theme
def set_phase(self, phase: str) -> None:
if phase not in RUN_PHASES:
phase = "idle"
self._phase = phase
self.phase_label.setText(t(RUN_PHASES[phase]))
busy = phase in ("running", "paused")
tone = {
"running": "accent",
"paused": "warn",
"failed": "danger",
"done": "accent",
}.get(phase, "text_muted")
colour = load_colors(self._theme).get(tone, "#888888")
self.phase_label.setStyleSheet(f"color: {colour};")
self._refresh_buttons(busy)
def set_progress(self, completed: int, total: int) -> None:
self._completed = max(0, int(completed))
self._total = max(0, int(total))
self.progress.setRange(0, self._total or 1)
self.progress.setValue(min(self._completed, self._total or 1))
if self._total:
self.step_label.setText(
t("run.step_of", done=self._completed, total=self._total)
)
else:
self.step_label.setText(t("run.no_steps"))
def set_timing(self, elapsed_s: float, remaining_s: float | None) -> None:
"""Elapsed, and the estimate. ``None`` means "not known yet"."""
if remaining_s is None:
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(elapsed_s)))
else:
self.time_label.setText(
t("run.elapsed_remaining", elapsed=_duration(elapsed_s), remaining=_duration(remaining_s))
)
def set_run(self, has_run: bool) -> None:
"""Whether a run exists at all — i.e. whether abort must be reachable."""
self._has_run = bool(has_run)
self._refresh_buttons(self._phase in ("running", "paused"))
def _refresh_buttons(self, busy: bool) -> None:
# Abort is enabled as soon as a run exists: it is the one control that
# must never require finding a tab first.
self.abort_button.setEnabled(self._has_run)
self.pause_button.setEnabled(self._has_run and busy)
self.run_button.setEnabled(not busy)
# With no run, "point 0/0" and "elapsed 0:00" are noise on a strip whose
# job is to be read at a glance. Idle shows the phase and why instead.
live = self._has_run or busy
self.step_label.setVisible(live)
self.progress.setVisible(live)
self.time_label.setVisible(live)
if not live:
self._idle_hint.setText(t("run.no_steps"))
@property
def phase(self) -> str:
return self._phase
# ------------------------------------------------------------- translations
def retranslate(self) -> None:
self.title.setText(t("run.title"))
self.phase_label.setText(t(RUN_PHASES[self._phase]))
self.run_button.setText(t("run.run"))
self.pause_button.setText(t("run.pause"))
self.abort_button.setText(t("run.abort"))
if self._total:
self.step_label.setText(
t("run.step_of", done=self._completed, total=self._total)
)
else:
self.step_label.setText(t("run.no_steps"))
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
self._refresh_buttons(self._phase in ("running", "paused"))
def _duration(seconds: float) -> str:
"""``m:ss`` for a duration, which is how an operator reads a bench timer."""
seconds = max(0.0, float(seconds))
minutes, rest = divmod(int(seconds), 60)
return f"{minutes:d}:{rest:02d}"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
"""The single-page workspace.
One tab per task the operator does on the platform, with the motion status strip
living *outside* this widget so a tab change cannot hide the state that decides
whether it is safe to act.
Pages that are not built yet are present as labelled placeholders rather than
missing: the layout is being reviewed for operator use, and a tab that appears
only later would change the layout under review. Each placeholder names what
will fill it, so nobody mistakes an empty page for a broken one.
"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QLabel,
QTabWidget,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from .axis_panel import AxisPanel
from .sequence_panel import SequencePanel
#: Tab identity -> locale key for its label. Order is the display order, and it
#: follows the work: commission the axes, describe the sequence, watch the
#: sensor, look at the surface, then record calibration points.
TABS: tuple[tuple[str, str], ...] = (
("axes", "workspace.tab_axes"),
("sequence", "workspace.tab_sequence"),
("sensor", "workspace.tab_sensor"),
("surface", "workspace.tab_surface"),
("points", "workspace.tab_points"),
)
#: Tabs whose content does not exist yet, with the locale key that explains what
#: will appear there and when.
_PLACEHOLDERS: dict[str, str] = {
"sensor": "workspace.reserved_sensor",
"surface": "workspace.reserved_surface",
"points": "workspace.reserved_points",
}
class PlaceholderPage(QWidget):
"""An empty page that says what it is waiting for."""
def __init__(self, locale_key: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._locale_key = locale_key
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
self.headline = QLabel(self)
self.headline.setObjectName("placeholderTitle")
self.body = QLabel(self)
self.body.setObjectName("hintLabel")
self.body.setWordWrap(True)
layout.addWidget(self.headline)
layout.addWidget(self.body)
layout.addStretch(1)
self.retranslate()
def retranslate(self) -> None:
self.headline.setText(t("workspace.not_built"))
self.body.setText(t(self._locale_key))
class WorkspaceTabs(QTabWidget):
"""The task workspace, with a stable tab set."""
pageChanged = Signal(str)
def __init__(
self,
axis_panel: AxisPanel,
sequence_panel: SequencePanel,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("workspaceTabs")
self._pages: dict[str, QWidget] = {}
self._placeholder_keys: dict[str, str] = {}
self._pages["axes"] = axis_panel
self._pages["sequence"] = sequence_panel
for key, locale_key in _PLACEHOLDERS.items():
page = PlaceholderPage(locale_key, self)
self._pages[key] = page
self._placeholder_keys[key] = locale_key
for key, _locale_key in TABS:
self.addTab(self._pages[key], "")
self.currentChanged.connect(self._on_current_changed)
self.retranslate()
# -------------------------------------------------------------------- state
def page_keys(self) -> list[str]:
return [key for key, _locale_key in TABS]
def page(self, key: str) -> QWidget:
return self._pages[key]
def current_key(self) -> str:
index = self.currentIndex()
keys = self.page_keys()
return keys[index] if 0 <= index < len(keys) else keys[0]
def set_current_key(self, key: str) -> None:
keys = self.page_keys()
if key in keys:
self.setCurrentIndex(keys.index(key))
def retranslate(self) -> None:
for index, (key, locale_key) in enumerate(TABS):
self.setTabText(index, t(locale_key))
for key, locale_key in self._placeholder_keys.items():
page = self._pages[key]
if isinstance(page, PlaceholderPage):
page.retranslate()
def _on_current_changed(self, index: int) -> None:
keys = self.page_keys()
if 0 <= index < len(keys):
self.pageChanged.emit(keys[index])
+152
View File
@@ -0,0 +1,152 @@
"""Fixtures for the AutoCal tests.
Everything here runs offscreen with no controller and no hardware. Two
environment facts are set before Qt is imported:
* ``QT_QPA_PLATFORM=offscreen`` so a window can be built without a display;
* ``QT_QPA_FONTDIR`` because the offscreen platform finds no fonts on Windows
otherwise, and every label would measure as zero-width.
The suite requires PySide6 — the app's only Qt binding. Where it is missing
(an older interpreter, or a machine with no way to install it) the tests are
reported as skipped rather than failing, so a broken environment cannot look
like a broken application.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
os.environ.setdefault("ORISYS_APP_ID", "autocal")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
def _have_pyside6() -> tuple[bool, str]:
try:
import PySide6
except ImportError as exc:
return False, str(exc)
return True, getattr(PySide6, "__version__", "unknown")
HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6()
if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine
collect_ignore_glob = ["test_*.py"]
@pytest.fixture(scope="session")
def qt_app():
"""One ``QApplication`` per session (Qt forbids more than one)."""
if not HAVE_PYSIDE6: # pragma: no cover
pytest.skip("PySide6 is not installed")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
SCRATCH_ROOT = Path(__file__).resolve().parents[1] / "outputs" / "_test_scratch"
def _scratch_dir() -> Path:
"""A fresh scratch directory under the app's own (gitignored) outputs."""
import time
path = SCRATCH_ROOT / f"{os.getpid()}-{int(time.time() * 1000) % 1_000_000}"
path.mkdir(parents=True, exist_ok=True)
return path
@pytest.fixture
def tmp_dir():
"""A workspace-local scratch directory, cleared per test.
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
shell, and ``tmp_path`` depends on a writable process temp root that such an
environment may not provide.
"""
import shutil
path = _scratch_dir()
yield path
shutil.rmtree(path, ignore_errors=True)
@pytest.fixture
def make_window(qt_app, monkeypatch):
"""Build a window whose preference writes stay inside a scratch directory.
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
shell, and ``tmp_path`` depends on a writable process temp root that such an
environment may not provide. Using the app's own ``outputs/`` (already
gitignored) removes that dependency entirely.
"""
import shutil
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
scratch = _scratch_dir()
prefs_file = scratch / "preference.json"
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
monkeypatch.setattr(prefs_mod, "preference_path", lambda: prefs_file)
created = []
def _make(**overrides):
prefs = prefs_mod.default_preferences()
prefs.update(overrides)
# The translator is process-global, so a window built after another test
# switched language would otherwise keep that language and then be
# labelled with the wrong locale.
from orisys_app_shared.i18n import set_locale
set_locale(str(prefs.get("language") or "zh_CN"))
window = AutoCalMainWindow(prefs=prefs)
# Swap in the simulated platform: no test in this suite touches a DLL.
window.poller.service._transport = DemoTransport() # noqa: SLF001
created.append(window)
return window
yield _make
for window in created:
try:
window.poller.shutdown()
window.close()
except Exception: # noqa: BLE001 - teardown must not mask a failure
pass
shutil.rmtree(scratch, ignore_errors=True)
def pump(app, seconds: float = 0.2) -> None:
"""Run the event loop for a short, bounded time."""
import time
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
app.processEvents()
time.sleep(0.005)
def visible_descendants(widget):
"""Yield every visible descendant of ``widget``."""
for child in widget.findChildren(object):
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
continue
if child.isVisible():
yield child
+216
View File
@@ -0,0 +1,216 @@
"""Binding policy for the Orisys apps.
Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that:
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
PySide6 and nothing else, so such an import is a hard start-up failure;
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
must not pull in a second binding, and the studio's own guard
(``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its
side. (Until the studio moved to PySide6 this file forbade *any* binding in
``shared/``; that rule existed for a two-binding workspace, which no longer
exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and
under the old rule those imports were failing silently instead.)
* the controller layer must stay importable from a headless process.
The reason the binding matters at all is licensing, not taste: PyQt5's free
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
needs no licence purchase. See ``README.md``.
"""
from __future__ import annotations
import ast
from pathlib import Path
APP_SRC = Path(__file__).resolve().parents[1] / "src"
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
#: Bindings no module may import.
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed, and where it may be named.
ALLOWED_BINDING = "PySide6"
#: Scoped enum holders we care about, with the value names actually used.
#:
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list
#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check),
#: which is the better rule; this map is kept because AutoCal cannot import the binding
#: in a headless test run.
_SCOPED_ENUMS = {
"Qt": (
"AlignmentFlag",
"ItemDataRole",
"Orientation",
"ConnectionType",
"TextInteractionFlag",
"TransformationMode",
"ToolButtonStyle",
"FocusPolicy",
"ScrollBarPolicy",
"CursorShape",
"MouseButton",
"Key",
"PenStyle",
"GlobalColor",
"ItemFlag",
),
"QTableWidget": ("SelectionMode", "EditTrigger"),
"QSizePolicy": ("Policy",),
"QHeaderView": ("ResizeMode",),
"QMessageBox": ("StandardButton", "Icon"),
"QDialog": ("DialogCode",),
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
"QDialogButtonBox": ("StandardButton",),
"QPainter": ("RenderHint", "CompositionMode"),
"QImage": ("Format",),
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
"QFrame": ("Shape",),
}
def _python_files(root: Path) -> list[Path]:
return sorted(path for path in root.rglob("*.py") if "__pycache__" not in path.parts)
def _imported_packages(path: Path) -> set[str]:
"""Every top-level package name imported by ``path``."""
tree = ast.parse(path.read_text(encoding="utf-8"))
found: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
found.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
found.add(node.module.split(".")[0])
return found
def _unscoped_enum_references(path: Path) -> list[str]:
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
Parsed rather than pattern-matched for two reasons: a regex also hits prose
in a comment or docstring (which is exactly where the *unscoped* spelling gets
explained, so a rewrite would destroy the explanation — this file quotes it
three times, which is why the migration scripts skip it), and
``QMessageBox.information(...)`` looks like an enumerator but is a static
method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is
an inner attribute on an allow-listed enum class rather than a plain name, so it
is never a candidate.
"""
tree = ast.parse(path.read_text(encoding="utf-8"))
called = {
id(node.func)
for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
}
found: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
continue
if id(node) in called:
continue # a method or function call, not an enumerator
holder = node.value.id
enum_names = _SCOPED_ENUMS.get(holder)
if not enum_names or node.attr in enum_names:
continue
found.add(f"{holder}.{node.attr}")
return sorted(found)
def test_no_module_imports_a_forbidden_binding():
offenders = []
for path in _python_files(APP_SRC):
bad = _imported_packages(path) & FORBIDDEN_BINDINGS
if bad:
offenders.append(f"{path.name}: {sorted(bad)}")
assert not offenders, (
f"AutoCal is {ALLOWED_BINDING}-only; these modules import another "
f"binding: {offenders}"
)
def test_every_ui_module_imports_the_allowed_binding():
"""A UI file that names no binding would be using one it does not declare."""
offenders = []
for path in _python_files(APP_SRC / "orisys_autocal" / "ui"):
if path.name == "__init__.py":
continue # a re-export barrel, not a widget module
if ALLOWED_BINDING not in _imported_packages(path):
offenders.append(path.name)
assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}"
def test_no_module_uses_an_unscoped_enum():
offenders = []
for path in _python_files(APP_SRC):
references = _unscoped_enum_references(path)
if references:
offenders.append(f"{path.name}: {references}")
assert not offenders, (
"unscoped Qt enumerators were removed in PySide6; use the scoped "
f"spelling: {offenders}"
)
def test_the_controller_interface_is_qt_free():
"""The controller and its guards stay importable from a headless process.
``poller`` is deliberately absent from this check: it *is* the Qt worker
bridge (``QThread``, signals), and it lives beside the service precisely so
that the service, the transports, the models and the error map do not need
a GUI to be exercised.
"""
qt_free = ("models.py", "errors.py", "units.py", "service.py", "transport.py")
offenders = []
for path in _python_files(APP_SRC / "orisys_autocal" / "motion"):
if path.name not in qt_free:
continue
qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"}
if qt_imports:
offenders.append(f"{path.name}: {sorted(qt_imports)}")
assert not offenders, offenders
def test_shared_imports_no_forbidden_binding():
"""Shared code serves both products, so it must not pull in a second binding.
PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with
it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to
import in a PySide6-only environment or — worse — work in one app's environment and not
the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and
the PyQt5 import in ``qss.py`` was failing silently as a result.
"""
offenders = []
for path in _python_files(SHARED_SRC):
qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
if qt_imports:
offenders.append(f"{path.name}: {sorted(qt_imports)}")
assert not offenders, (
"shared code may import PySide6 only; these files import another binding: "
f"{offenders}"
)
def test_zmotion_client_imports_no_qt_binding():
zmotion = SHARED_SRC / "orisys_app_shared" / "zmotion"
offenders = []
for path in _python_files(zmotion):
qt_imports = _imported_packages(path) & {"PySide2", "PySide6", "PyQt5", "PyQt6"}
if qt_imports:
offenders.append(f"{path.name}: {sorted(qt_imports)}")
assert not offenders, offenders
def test_the_app_declares_its_binding_dependency():
"""The packaging must state PySide6, not leave it to chance."""
pyproject = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(
encoding="utf-8"
)
assert ALLOWED_BINDING in pyproject
for forbidden in FORBIDDEN_BINDINGS:
assert f'"{forbidden}' not in pyproject, forbidden
+54
View File
@@ -0,0 +1,54 @@
"""Controller return-code translation.
The vendor SDK header defines the auxiliary error block at 30000; everything
else is the firmware's own number. The point of these tests is that AutoCal
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)
def test_zero_is_success_in_both_languages():
assert errors.describe(0) == "成功"
assert errors.describe(0, language="en_US") == "OK"
def test_the_documented_block_is_the_vendor_offset():
assert errors.ERR_AUX_OFFSET == 30000
assert DOCUMENTED == [30000, 30001, 30002, 30003, 30004, 30005]
@pytest.mark.parametrize("code", DOCUMENTED)
def test_every_documented_code_has_both_languages(code):
zh = errors.describe(code)
en = errors.describe(code, language="en_US")
assert zh and en and zh != en
@pytest.mark.parametrize("code", DOCUMENTED)
def test_no_documented_message_is_empty(code):
assert errors.describe(code).strip()
assert errors.describe(code, language="en_US").strip()
def test_an_unknown_code_keeps_its_number():
"""A guessed meaning on a motion controller is worse than 'unknown'."""
message = errors.describe(12345)
assert "12345" in message
assert message == "错误码 12345"
def test_an_unknown_code_points_at_zdevelop():
message = errors.describe_with_hint(-1)
assert "-1" in message
assert errors.UNKNOWN_HINT in message
def test_a_documented_code_gets_no_hint_appended():
assert errors.describe_with_hint(30000) == errors.describe(30000)
+586
View File
@@ -0,0 +1,586 @@
"""The single-page shell, from an operator's point of view.
These assertions are about the layout contract rather than any one panel:
* the motion status strip is outside the tabs, so it survives every tab change;
* all five tabs exist, are labelled, and their pages fit;
* the right-hand readout rail shows every drive and issues no per-axis command;
* the window refuses to shrink below the size at which the page would clip.
The overflow check lives in ``test_panel_smoke.py`` because it needs the colour
tables; what is here is the structure.
"""
from __future__ import annotations
import pytest
from conftest import pump
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
from orisys_autocal.ui.workspace import TABS
def _show(window, size=(1600, 900)):
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def test_the_tab_set_is_the_documented_one(make_window):
window = _show(make_window())
assert window.workspace.page_keys() == [key for key, _ in TABS]
def test_every_tab_has_a_localised_label(make_window):
from orisys_app_shared.i18n import t
window = _show(make_window())
for index, (_key, locale_key) in enumerate(TABS):
label = window.workspace.tabText(index)
assert label, f"tab {index} has no label"
assert label == t(locale_key)
assert label != locale_key, f"tab {index} shows its raw key"
def test_the_status_strip_survives_every_tab_change(make_window):
"""The whole point of putting the strip above the tabs."""
window = _show(make_window())
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
assert window.status_strip.isVisible(), f"the strip hid on tab {key}"
assert window.status_strip.height() <= 48
def test_the_strip_reports_connection_and_bus_state(make_window):
from orisys_app_shared.i18n import t
from orisys_autocal.motion import BusStatus, Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_snapshot(Snapshot(connected=False))
assert strip.chip_connection.text() == t("connection.state_closed")
assert strip.chip_init.text() == t("bus.state_pending")
strip.set_snapshot(
Snapshot(connected=True, bus=BusStatus(bus_type="ecat", init_status=1, total_axes=5))
)
assert strip.chip_connection.text() == t("connection.state_open")
assert strip.chip_bus.text() == t("bus.type_ecat")
assert strip.chip_init.text() == t("bus.state_ok")
def test_a_refusal_stays_visible_on_the_strip(make_window):
"""A command refused while the operator was on another tab must not vanish."""
window = _show(make_window())
window.workspace.set_current_key("sensor")
window.status_strip.set_action_result("轴0 回零模式未设置", False)
assert "回零模式" in window.status_strip.action_label.text()
def test_the_readout_rail_covers_every_axis(make_window):
window = _show(make_window())
assert len(window.axis_readout._rows) == window.axis_panel.axis_count == 5
def test_the_readout_rail_shows_position_and_state(make_window):
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
snapshot = Snapshot(
connected=True,
axes=(
AxisState(axis=0, enabled=True, units=10.0, dpos=1000.0, idle=True),
AxisState(axis=1, enabled=False, units=10.0, dpos=0.0, idle=True),
),
)
window.axis_readout.update_snapshot(snapshot)
# 1000 user units at units=10 is the documented 2.000 mm, and the readout
# shows both so an operator never has to convert in their head.
text = window.axis_readout._rows[0]["position"].text()
assert "1000.000" in text and "2.000 mm" in text, text
assert window.axis_readout._rows[1]["state"].text()
def test_the_readout_rail_issues_no_per_axis_motion(make_window):
"""Per-axis commands live in one place only: the Axis-debug tab."""
window = _show(make_window())
rail = window.axis_readout
assert not hasattr(rail, "jogRequested")
assert not hasattr(rail, "moveAbsRequested")
assert hasattr(rail, "stopAllRequested")
assert hasattr(rail, "enableAllRequested")
def test_enable_all_toggles_with_the_current_state(make_window):
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
requested: list[bool] = []
window.axis_readout.enableAllRequested.connect(requested.append)
window.axis_readout.update_snapshot(
Snapshot(connected=True, axes=tuple(AxisState(axis=a, enabled=True) for a in range(5)))
)
window.axis_readout.enable_all_button.click()
assert requested == [False], "enable-all did not become disable-all"
window.axis_readout.update_snapshot(Snapshot(connected=True, axes=()))
window.axis_readout.enable_all_button.click()
assert requested == [False, True]
def test_the_window_refuses_to_shrink_below_the_page(make_window):
window = _show(make_window())
window.resize(800, 600)
assert window.width() >= window.minimumWidth()
assert window.height() >= window.minimumHeight()
assert window.minimumWidth() >= 1300
def test_the_bus_data_group_starts_collapsed(make_window):
"""It is commissioning-only; expanded, it cost the page ~270 px."""
window = _show(make_window())
assert not window.bus_data_group.isChecked()
assert not window.bus_data_panel.isVisible()
def test_expanding_bus_data_does_not_resize_the_window(make_window):
window = _show(make_window())
before = (window.width(), window.height())
window.bus_data_group.setChecked(True)
pump(_app(), 0.1)
assert (window.width(), window.height()) == before
def _app():
from PySide6.QtWidgets import QApplication
return QApplication.instance()
def test_the_sequence_tab_shows_the_documented_profile_fields(make_window):
from orisys_autocal.ui.sequence_panel import PROFILE_FIELDS
window = _show(make_window())
panel = window.sequence_panel
assert panel.profile_keys() == [key for key, _locale, _kind in PROFILE_FIELDS]
def test_the_sequence_panel_exposes_its_three_zones(make_window):
window = _show(make_window())
panel = window.sequence_panel
assert panel.validation_checks() == [
"on_surface",
"within_travel",
"contact_confirmable",
]
assert panel.point_row_count() > 0, "the point list is empty"
assert panel.summary_label.text()
def test_every_pending_control_names_the_phase_that_owns_it(make_window):
"""A disabled control must say why, or it reads as a broken button."""
window = _show(make_window())
panel = window.sequence_panel
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
for name, phase in panel.pending_phases().items():
button = panel._pending_buttons[name]
assert not button.isEnabled(), f"{name} is usable but not implemented"
assert phase in button.toolTip(), f"{name} does not name its phase"
assert button.text(), f"{name} has no label"
def test_import_and_export_are_live_controls(make_window):
"""A sequence is a document, and P8 says it is a versioned artifact."""
window = _show(make_window())
panel = window.sequence_panel
for name in ("export", "import"):
button = panel._live_buttons[name]
assert button.isEnabled(), f"{name} is not usable"
assert button.text(), f"{name} has no label"
assert button.toolTip()
def test_the_point_list_has_no_empty_action_column(make_window):
"""An empty action cell reads as a broken control."""
window = _show(make_window())
panel = window.sequence_panel
assert "actions" not in panel.column_keys()
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
"""A runner must not be able to start from points nothing has validated."""
window = _show(make_window())
assert window.sequence_panel.planned_steps() == []
def test_point_coordinates_are_generated_not_typed(make_window):
"""A typed coordinate is one nothing has checked against the sensor."""
from orisys_autocal.ui.sequence_panel import _EDITABLE_POINT_COLUMNS
assert _EDITABLE_POINT_COLUMNS == ()
window = _show(make_window())
panel = window.sequence_panel
# The point list is a real table, so it scrolls instead of clipping rows.
assert panel.table.rowCount() == panel.point_row_count()
assert panel.point_row(0)["x"] == "0.000"
# Every cell is read-only: the table's edit triggers are off, so a generated
# coordinate cannot be typed over.
from PySide6.QtWidgets import QTableWidget
assert panel.table.editTriggers() == QTableWidget.EditTrigger.NoEditTriggers
def test_the_profile_round_trips_through_preferences(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("3.5")
panel.field("repeats").setText("4")
window._persist_prefs()
stored = window.prefs["sequence_profile"]
assert stored["pitch_mm"] == 3.5
assert stored["repeats"] == 4
def test_an_unusable_profile_number_falls_back_rather_than_raising(make_window):
from orisys_autocal.preferences import default_preferences
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("not a number")
assert panel.profile()["pitch_mm"] == 0.0
# A blank repeats field must not become zero repeats.
panel.field("repeats").setText("")
assert panel.profile()["repeats"] >= 1
assert "pitch_mm" in default_preferences()["sequence_profile"]
def test_the_stored_profile_is_loaded_into_the_form(make_window):
window = _show(make_window(sequence_profile={"pitch_mm": 7.5, "order": "row"}))
panel = window.sequence_panel
assert panel.profile()["pitch_mm"] == 7.5
assert panel.profile()["order"] == "row"
def test_the_axis_table_shows_seven_columns(make_window):
from orisys_autocal.ui.axis_panel import COLUMNS
window = _show(make_window())
assert len(COLUMNS) == 7
assert window.axis_panel.table.columnCount() == 8 # seven data + actions
def test_the_axis_detail_line_carries_what_the_table_dropped(make_window):
"""Axis type, accel, feedback and the status word must stay reachable."""
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
window.axis_panel.set_selected_axis(3)
window.axis_panel.update_snapshot(
Snapshot(
connected=True,
axes=(AxisState(axis=3, enabled=True, atype=65, accel=1234.0, mpos=5.0),),
)
)
detail = window.axis_panel._detail_label.text()
assert "65" in detail and "1234" in detail
assert "3" in detail
def test_the_axis_detail_follows_the_selected_axis(make_window):
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
window.axis_panel.update_snapshot(
Snapshot(
connected=True,
axes=(AxisState(axis=0, atype=65), AxisState(axis=1, atype=67)),
)
)
window.axis_panel.set_selected_axis(0)
assert "65" in window.axis_panel._detail_label.text()
window.axis_panel.set_selected_axis(1)
assert "67" in window.axis_panel._detail_label.text()
def test_the_window_defaults_to_the_design_resolution(make_window):
from orisys_autocal.ui.window import DEFAULT_WINDOW_SIZE, MINIMUM_WINDOW_SIZE
assert DEFAULT_WINDOW_SIZE == (1920, 1080)
assert MINIMUM_WINDOW_SIZE[0] >= 1440
def test_the_default_theme_is_light(make_window):
from orisys_autocal.preferences import DEFAULT_THEME
assert DEFAULT_THEME == "light"
window = _show(make_window())
assert window._theme == "light"
def test_pausing_the_strip_pauses_the_poller(make_window):
window = _show(make_window())
window.status_strip.pause_button.setChecked(True)
window._on_pause_polling()
assert window.poller.paused
window.status_strip.pause_button.setChecked(False)
window._on_pause_polling()
assert not window.poller.paused
def test_the_diagnostics_footer_does_not_move_the_layout(make_window):
window = _show(make_window())
splitters = window.main_splitter.sizes()
window._set_diagnostics(True)
window._set_diagnostics(False)
assert window.main_splitter.sizes() == splitters
def test_splitter_sizes_round_trip_through_preferences(make_window):
window = _show(make_window())
window.main_splitter.setSizes([340, 1500])
window._persist_prefs()
stored = window.prefs["main_splitter_sizes"]
assert len(stored) == 2
assert stored[1] > stored[0]
def test_the_rail_is_a_single_scrollable_column(make_window):
"""Two thin rails bracketing the workspace cost it 240 px at every size."""
from orisys_autocal.ui.window import MAIN_SPLITTER_SIZES
assert len(MAIN_SPLITTER_SIZES) == 2, "the layout is back to three columns"
window = _show(make_window())
assert window.main_splitter.count() == 2
assert window.left_rail.widget() is window.rail_content
# The readout lives in the rail now, not in a rail of its own.
assert window.rail_content.findChild(type(window.axis_readout)) is window.axis_readout
def test_the_rail_scrolls_rather_than_clipping_its_controls(make_window):
"""At 900 px high the three rail groups do not fit; the rail must scroll."""
window = _show(make_window(), (1440, 900))
viewport = window.left_rail.viewport().height()
content = window.rail_content.sizeHint().height()
scroll = window.left_rail.verticalScrollBar()
assert content <= viewport or scroll.maximum() > 0, (
f"rail content is {content}px in a {viewport}px viewport and cannot scroll"
)
# --------------------------------------------------------------- run controls
def test_the_run_bar_is_visible_from_every_tab(make_window):
"""Abort must never require finding the tab that started the run."""
window = _show(make_window())
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
assert window.run_bar.isVisible(), f"the run bar hid on tab {key}"
def test_run_is_wired_and_refuses_by_naming_its_phase(make_window):
window = _show(make_window())
window.run_bar.run_button.click()
assert "P7" in window.status_strip.action_label.text()
def test_abort_stops_the_axes_and_is_enabled_once_a_run_exists(make_window):
window = _show(make_window())
assert not window.run_bar.abort_button.isEnabled()
requested = []
window.run_bar.abortRequested.connect(lambda: requested.append(True))
window.run_bar.set_run(True)
assert window.run_bar.abort_button.isEnabled()
window.run_bar.abort_button.click()
assert requested == [True]
assert window.run_bar.phase == "failed"
def test_the_run_bar_shows_progress_and_timing(make_window):
window = _show(make_window())
window.run_bar.set_progress(7, 40)
assert "7" in window.run_bar.step_label.text()
assert "40" in window.run_bar.step_label.text()
assert window.run_bar.progress.maximum() == 40
assert window.run_bar.progress.value() == 7
window.run_bar.set_timing(125.0, 300.0)
assert "2:05" in window.run_bar.time_label.text()
# An unknown estimate must say so rather than showing a wrong number.
window.run_bar.set_timing(125.0, None)
assert "2:05" in window.run_bar.time_label.text()
# ---------------------------------------------------------------- poll health
def test_the_health_chip_reports_the_age_of_the_last_reading(make_window):
from orisys_autocal.motion import Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_snapshot(Snapshot(connected=True))
assert strip.chip_health.text()
assert strip.chip_health._tone == "ok"
def test_the_health_chip_ages_when_the_poll_stops(make_window):
"""A stalled poll must not keep looking healthy."""
from orisys_autocal.motion import Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_poll_interval_ms(100)
strip.set_snapshot(Snapshot(connected=True))
strip._last_snapshot_at -= 1.5 # fifteen intervals ago
strip.refresh_health()
assert strip.chip_health._tone == "error", strip.chip_health.text()
def test_the_health_chip_says_paused_rather_than_ageing(make_window):
from orisys_autocal.motion import Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_snapshot(Snapshot(connected=True))
strip.set_paused(True)
strip.refresh_health()
assert strip.chip_health._tone == "muted"
assert strip.chip_health.text() != ""
def test_the_status_bar_uses_its_own_theme_token(make_window):
"""The brand navy reads well as a header and heavy as a footer."""
from orisys_app_shared.theme import load_colors
for theme in ("light", "dark"):
colours = load_colors(theme)
assert "status_bar_bg" in colours, theme
assert "status_bar_text" in colours, theme
window = _show(make_window())
assert "QStatusBar" in window.statusBar().styleSheet()
def test_the_sequence_summary_reports_count_and_span(make_window):
"""The two numbers an operator reasons with, before anything runs."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
]
)
text = panel.summary_label.text()
assert "2" in text
# A nil Z span prints once, and the real X span prints both ends.
assert "0.000–10.000" in text
assert "12.400" in text
def test_the_sequence_summary_updates_with_the_profile(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
before = panel.summary_label.text()
panel.field("repeats").setText("10")
assert panel.summary_label.text() != before
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([])
assert panel.summary_label.text()
assert "0" not in panel.summary_label.text()[:2]
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
"""Import replaces the design, and there is no autosave."""
window = _show(make_window())
panel = window.sequence_panel
panel.mark_clean()
assert not panel.dirty
panel.field("pitch_mm").setText("9")
assert panel.dirty, "a profile edit did not mark the design dirty"
def test_export_then_import_restores_the_design(make_window, tmp_dir):
"""The round trip through the real panel, not just the format module."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_name("bench sweep")
panel.field("pitch_mm").setText("3.5")
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
]
)
path = tmp_dir / "sweep.oryseq"
window.export_sequence(str(path))
assert path.is_file()
assert not panel.dirty, "export did not clear the dirty flag"
# Change everything, then import it back.
panel.set_name("")
panel.field("pitch_mm").setText("1")
panel.set_points([])
document = window.import_sequence(str(path))
assert document.point_count == 2
assert panel.name() == "bench sweep"
assert panel.profile()["pitch_mm"] == 3.5
assert panel.point_row_count() == 2
assert not panel.dirty
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("4")
path = tmp_dir / "bad.oryseq"
path.write_text("not json at all", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
window.import_sequence(str(path))
assert "not valid JSON" in str(excinfo.value)
# The design is untouched: a refused import is not a partial import.
assert panel.profile()["pitch_mm"] == 4
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
import json
window = _show(make_window())
window.prefs["sensor_setup"] = {
"sensor_type": "finger",
"geometry_id": "finger-1.0.0",
"probe_tip_set": True,
}
path = tmp_dir / "named.oryseq"
window.export_sequence(str(path))
document = json.loads(path.read_text(encoding="utf-8"))
assert document["device"]["sensor_type"] == "finger"
assert document["device"]["geometry_id"] == "finger-1.0.0"
assert document["versions"]["app"]
def test_language_switch_relabels_the_new_shell(make_window):
from orisys_app_shared.i18n import t
window = _show(make_window(language="zh_CN"))
zh_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
window._set_language("en_US")
assert window.status_strip.title.text() == t("status.motion")
assert window.run_bar.title.text() == t("run.title")
en_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
assert en_tabs != zh_tabs
assert all(en_tabs)
@@ -0,0 +1,88 @@
"""Locale files: same keys everywhere, and a real fallback.
``Translator`` returns the key itself when a string is missing, which is silent
in production — a label would simply read ``axes.nope``. The studio suite guards
this by comparing key sets; the same guard applies here.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from orisys_autocal.preferences import load_preferences
LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales"
LANGUAGES = ("zh_CN", "en_US")
def _load(language: str) -> dict:
return json.loads((LOCALES / f"{language}.json").read_text(encoding="utf-8"))
def _flatten(node: dict, prefix: str = "") -> set[str]:
keys: set[str] = set()
for key, value in node.items():
path = f"{prefix}{key}"
if isinstance(value, dict):
keys |= _flatten(value, f"{path}.")
else:
keys.add(path)
return keys
def test_every_locale_file_exists():
for language in LANGUAGES:
assert (LOCALES / f"{language}.json").is_file(), language
def test_locales_have_identical_key_sets():
reference = _flatten(_load("zh_CN"))
for language in LANGUAGES[1:]:
other = _flatten(_load(language))
assert other == reference, {
f"{language}_only": sorted(other - reference),
f"zh_CN_only": sorted(reference - other),
}
def test_no_translation_is_empty_where_a_label_is_expected():
"""A blank string renders as an empty widget, which looks like a bug."""
for language in LANGUAGES:
for key, value in _walk(_load(language)):
assert str(value).strip(), f"{language}: {key} is blank"
def test_the_product_name_is_localised():
"""The window title is Chinese in zh_CN and English in en_US."""
assert _load("zh_CN")["app"]["title"] != _load("en_US")["app"]["title"]
@pytest.mark.parametrize("language", LANGUAGES)
def test_every_locale_loads_through_the_translator(language):
from orisys_app_shared.i18n import Translator
translator = Translator(LOCALES, locale=language)
for key in sorted(_flatten(_load(language))):
assert translator.t(key) != key, f"{language}: {key} did not resolve"
def test_the_default_preferences_file_matches_the_schema_shape():
"""A shipped default that names a key the app does not have is dead weight."""
shipped = json.loads(
(LOCALES.parent / "preferences.default.json").read_text(encoding="utf-8")
)
schema = load_preferences()
for key in shipped:
assert key in schema, f"preferences.default.json has an unknown key: {key}"
def _walk(node, prefix: str = ""):
for key, value in node.items():
path = f"{prefix}{key}"
if isinstance(value, dict):
yield from _walk(value, f"{path}.")
else:
yield path, value
+328
View File
@@ -0,0 +1,328 @@
"""Guard and state-machine behaviour of the motion service.
Driven against the simulated platform, so every assertion here is about AutoCal's
own logic: what it refuses, what it reads, and when. No test in this file touches
a controller or a DLL.
"""
from __future__ import annotations
import time
import pytest
from orisys_autocal.motion import (
INIT_FAILED,
INIT_OK,
INIT_RUNNING,
AxisParams,
ConnectionSpec,
DemoTransport,
MotionService,
)
@pytest.fixture
def service():
return MotionService(DemoTransport())
def _initialise(service, timeout: float = 5.0) -> None:
"""Download a BAS program and wait for the simulated bus to report ready."""
service.download_bas(__file__, 1)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline and service.bus.init_status != INIT_OK:
service.poll([0])
time.sleep(0.05)
# ----------------------------------------------------------------- connection
def test_a_fresh_service_is_disconnected_and_touches_nothing(service):
assert not service.connected
assert not service.bas_loaded
assert service.poll([0]).connected is False
def test_connect_reports_success_and_an_unknown_bus_without_a_bas(service):
result = service.connect(ConnectionSpec(mode="demo"))
assert result.ok
assert service.connected
# No BAS program has run, so the bus type must not be guessed at.
snapshot = service.poll([0])
assert snapshot.bus.bus_type == "unknown"
assert not snapshot.bus.initialized
def test_connect_failure_is_reported_without_raising(service):
result = service.connect(ConnectionSpec(mode="eth", ip="127.0.0.1"))
assert not result.ok
assert not service.connected
def test_disconnect_clears_bus_state(service):
service.connect(ConnectionSpec(mode="demo"))
service.disconnect()
assert not service.connected
assert service.bus.bus_type == "unknown"
# ---------------------------------------------------------------- bus init
def test_download_requires_a_real_file(service):
service.connect(ConnectionSpec(mode="demo"))
assert not service.download_bas("", 1).ok
assert not service.download_bas(r"D:\nope\missing.bas", 1).ok
def test_download_needs_a_connection(service):
assert not service.download_bas(__file__, 1).ok
def test_download_then_poll_reaches_initialised(service):
service.connect(ConnectionSpec(mode="demo"))
assert service.download_bas(__file__, 1).ok
_initialise(service)
assert service.bus.init_status == INIT_OK
assert service.bus.bus_type == "ecat"
assert service.bus.total_axes == 5
assert service.bus.node_count == 5
def test_bus_is_running_immediately_after_download(service):
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
assert service.bus.init_status == INIT_RUNNING
def test_reinitialise_is_refused_while_already_running(service):
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
assert not service.reinit_bus().ok
def test_reinitialise_is_refused_without_a_bas_program(service):
service.connect(ConnectionSpec(mode="demo"))
result = service.reinit_bus()
assert not result.ok
assert "BAS" in result.message or "Bas" in result.message
def test_init_timeout_is_reported_rather_than_raising():
"""A controller that never publishes its globals must time out, not hang."""
class SilentTransport(DemoTransport):
def user_var(self, name):
from orisys_app_shared.zmotion import ZAuxError
raise ZAuxError(30002, f"{name} is not defined")
service = MotionService(SilentTransport())
service._init_timeout = 0.05 # noqa: SLF001 - shortening the vendor window
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
time.sleep(0.1)
snapshot = service.poll([0])
assert snapshot.bus.init_status == INIT_FAILED
assert snapshot.bus.message # carries the ZDevelop hint, not an empty string
# ------------------------------------------------------------------- guards
def test_motion_is_refused_before_the_bus_is_initialised(service):
service.connect(ConnectionSpec(mode="demo"))
for attempt in (
service.jog(0, True),
service.move_abs(0, 100.0),
service.move_rel(0, 10.0),
service.home(0),
):
assert not attempt.ok
assert attempt.message
def test_motion_is_refused_when_not_connected(service):
assert not service.jog(0, True).ok
assert not service.move_abs(0, 1.0).ok
assert not service.stop(0).ok
def test_motion_on_an_unknown_axis_is_refused(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.jog(99, True)
assert not result.ok
assert "99" in result.message or "0-4" in result.message
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
def test_a_non_finite_target_is_refused(service, bad):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.move_abs(0, bad)
assert not result.ok
assert result.message
def test_home_needs_a_configured_mode(service):
"""0 is not a valid homing mode; inheriting one would be worse than saying so."""
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.home(0)
assert not result.ok
assert "0" in result.message
def test_home_runs_once_a_mode_is_set(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
service.set_axis_params(0, AxisParams(home_mode=17))
assert service.home(0).ok
def test_home_is_refused_on_rtex(service):
"""The vendor example refuses RTEX homing; so do we."""
class RtexTransport(DemoTransport):
def user_var(self, name):
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
rtex = MotionService(RtexTransport())
rtex.connect(ConnectionSpec(mode="demo"))
_initialise(rtex)
assert rtex.bus.bus_type == "rtex"
result = rtex.home(0)
assert not result.ok
assert result.message
# ------------------------------------------------------------------- motion
def test_jog_and_stop_move_the_state_machine(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.jog(0, True).ok
state = service.poll([0]).axis(0)
assert state is not None and not state.idle
assert service.stop(0).ok
state = service.poll([0]).axis(0)
assert state is not None and state.idle
def test_absolute_and_relative_moves_change_the_command_position(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.move_abs(1, 250.0).ok
assert service.poll([1]).axis(1).dpos == pytest.approx(250.0)
assert service.move_rel(1, -50.0).ok
assert service.poll([1]).axis(1).dpos == pytest.approx(200.0)
def test_set_axis_params_writes_and_remembers(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
params = AxisParams(units=5.0, speed=200.0, accel=2000.0)
assert service.set_axis_params(2, params).ok
assert service.params(2) == params
assert service.to_mm(2, 1000.0) == pytest.approx(1.0)
def test_enable_toggles_the_axis(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.set_axis_enable(3, False).ok
assert service.poll([3]).axis(3).enabled is False
assert service.set_axis_enable(3, True).ok
assert service.poll([3]).axis(3).enabled is True
def test_stop_all_covers_every_axis(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
results = service.stop_all()
assert len(results) == service.axis_count
assert all(result.ok for result in results)
def test_clear_alarm_reaches_the_drive(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.clear_alarm(0).ok
# ----------------------------------------------------------------- bus data
def test_sdo_round_trips_on_ethercat(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.sdo_write(0, 0x6040, 0, 3, 6).ok
result, value = service.sdo_read(0, 0x6040, 0, 3)
assert result.ok
assert value == 6
def test_sdo_is_refused_on_rtex(service):
class RtexTransport(DemoTransport):
def user_var(self, name):
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
rtex = MotionService(RtexTransport())
rtex.connect(ConnectionSpec(mode="demo"))
_initialise(rtex)
result, _value = rtex.sdo_read(0, 0x6040, 0, 3)
assert not result.ok
def test_rtex_is_refused_on_ethercat(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result, _value = service.rtex_read(0, 1)
assert not result.ok
def test_bus_data_is_refused_before_the_bus_type_is_known(service):
service.connect(ConnectionSpec(mode="demo"))
result, _value = service.sdo_read(0, 0x6040, 0, 3)
assert not result.ok
# ------------------------------------------------------------------- polling
def test_polling_reports_every_requested_axis(service):
service.connect(ConnectionSpec(mode="demo"))
snapshot = service.poll([0, 1, 2, 3, 4])
assert [state.axis for state in snapshot.axes] == [0, 1, 2, 3, 4]
assert all(state.read_ok for state in snapshot.axes)
def test_a_read_failure_is_marked_not_silently_zeroed():
"""A failed read must not look like a position of zero."""
class FailingReads(DemoTransport):
def get_dpos(self, axis):
from orisys_app_shared.zmotion import ZAuxError
raise ZAuxError(30000, "no response")
service = MotionService(FailingReads())
service.connect(ConnectionSpec(mode="demo"))
snapshot = service.poll([0])
state = snapshot.axis(0)
assert state is not None
assert state.read_ok is False
assert state.error
def test_language_switches_the_refusal_text(service):
assert not service.jog(0, True).ok
zh = service.jog(0, True).message
service.set_language("en_US")
en = service.jog(0, True).message
assert zh != en
+331
View File
@@ -0,0 +1,331 @@
"""Offscreen UI smoke tests: construction, layout, gating, and preferences.
The window is built for real, with the simulated platform standing in for the
controller. Nothing here needs a display or hardware.
"""
from __future__ import annotations
import pytest
from conftest import pump, visible_descendants
RESOLUTIONS = ((1440, 900), (1600, 900), (1920, 1080))
def _show(window, size=(1440, 880)):
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def _enabled(widget) -> bool:
"""Whether ``widget`` is effectively enabled, shown or not.
A plain ``isEnabled()`` is False for every child of a window that has not
been shown, which makes an assertion about gating depend on whether the
test happened to realise the window. ``isEnabledTo`` answers the question
the tests actually mean: would a user be able to operate this control?
"""
return widget.isEnabledTo(widget.window())
# ------------------------------------------------------------------ structure
@pytest.mark.parametrize(("theme", "language"), [
("dark", "zh_CN"),
("light", "zh_CN"),
("dark", "en_US"),
("light", "en_US"),
])
def test_the_window_builds_in_every_theme_and_language(make_window, theme, language):
window = _show(make_window(theme=theme, language=language))
assert window.windowTitle()
assert window.axis_panel.table.rowCount() == 5
def test_the_product_name_is_localised_in_the_window_title(make_window):
window = _show(make_window(language="zh_CN"))
zh = window.windowTitle()
window._set_language("en_US")
assert window.windowTitle() != zh
def test_every_axis_row_has_a_labelled_control(make_window):
window = _show(make_window())
panel = window.axis_panel
for row in range(panel.axis_count):
widgets = panel._rows[row]
for key in ("enable_button", "fwd_button", "rev_button", "stop_button"):
assert widgets[key].text().strip(), f"row {row} {key} has no label"
def test_the_table_shows_one_column_per_documented_quantity(make_window):
from orisys_app_shared.i18n import t
from orisys_autocal.ui.axis_panel import COLUMNS
window = _show(make_window())
headers = [
window.axis_panel.table.horizontalHeaderItem(index).text()
for index in range(window.axis_panel.table.columnCount())
]
assert headers == [t(key) for key in COLUMNS] + [t("axes.controls")]
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_every_axis_row_is_reachable(make_window, size):
"""No drive may be hidden by the splitter.
The panel asks for three rows without scrolling; at the default window size
all five must be visible, because a clipped fifth row on a five-axis platform
reads as a missing drive rather than as a scrollbar. If the panel is ever
squeezed below that, the table has to scroll rather than silently cut a row.
"""
window = _show(make_window(), size)
table = window.axis_panel.table
rows_height = sum(
table.rowHeight(row) for row in range(window.axis_panel.axis_count)
)
header = table.horizontalHeader().height()
available = table.viewport().height()
assert available >= rows_height or table.verticalScrollBar().maximum() > 0, (
f"{size}: rows need {rows_height}px, viewport has {available}px "
f"(header {header}px) and it cannot scroll"
)
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_no_visible_child_escapes_its_parent(make_window, size):
window = _show(make_window(), size)
oversize = []
for child in visible_descendants(window):
if _inside_scroll_area(child):
# A scroll area's content is *meant* to exceed its viewport: the rail
# scrolls instead of squeezing its controls into an unreadable column.
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > parent {parent.width()}x{parent.height()}"
)
assert not oversize, oversize
def _inside_scroll_area(widget) -> bool:
"""Whether ``widget`` is content of a QAbstractScrollArea, not a laid-out child."""
from PySide6.QtWidgets import QAbstractScrollArea
parent = widget.parentWidget()
while parent is not None:
if isinstance(parent, QAbstractScrollArea):
return True
parent = parent.parentWidget()
return False
# ----------------------------------------------------------------------- gating
def test_motion_controls_start_disabled_without_a_bus(make_window):
window = _show(make_window())
assert not _enabled(window.axis_panel._rows[0]["fwd_button"])
assert not _enabled(window.axis_panel._rows[0]["rev_button"])
def test_bus_data_panels_follow_the_reported_bus_type(make_window):
window = _show(make_window())
window.bus_data_panel.set_connected(True)
window.bus_data_panel.set_bus_type("ecat")
assert _enabled(window.bus_data_panel.ecat_box)
assert not _enabled(window.bus_data_panel.rtex_box)
window.bus_data_panel.set_bus_type("rtex")
assert _enabled(window.bus_data_panel.rtex_box)
assert not _enabled(window.bus_data_panel.ecat_box)
window.bus_data_panel.set_bus_type("unknown")
assert not _enabled(window.bus_data_panel.ecat_box)
assert not _enabled(window.bus_data_panel.rtex_box)
def test_bus_data_panels_are_disabled_while_disconnected(make_window):
window = _show(make_window())
window.bus_data_panel.set_bus_type("ecat")
window.bus_data_panel.set_connected(False)
assert not _enabled(window.bus_data_panel.ecat_box)
def test_axis_rows_dim_when_the_bus_reports_fewer_drives(make_window):
window = _show(make_window())
window.axis_panel.set_connected(True)
window.axis_panel.set_axis_count(3)
assert _enabled(window.axis_panel._rows[2]["enable_button"])
assert not _enabled(window.axis_panel._rows[3]["enable_button"])
def test_unknown_connection_modes_report_instead_of_pretending(make_window):
from orisys_app_shared.i18n import t
window = _show(make_window())
panel = window.connection_panel
index = panel.mode_combo.findData("com")
panel.mode_combo.setCurrentIndex(index)
panel._emit_connect()
assert panel.status_label.text() != t("connection.state_open")
# ------------------------------------------------------------------- actions
def test_the_selected_axis_follows_a_table_click(make_window):
window = _show(make_window())
window.axis_panel._on_cell_clicked(3, 0)
assert window.axis_panel.selected_axis == 3
assert window.axis_panel.axis_index_label.text() == "3"
def test_blank_numeric_fields_fall_back_to_defaults(make_window):
from orisys_autocal.motion import DEFAULT_UNITS
window = _show(make_window())
panel = window.axis_panel
panel.units_edit.setText("")
assert panel.current_params().units == DEFAULT_UNITS
panel.units_edit.setText("not a number")
assert panel.current_params().units == DEFAULT_UNITS
def test_a_blank_target_does_not_emit_a_move(make_window):
window = _show(make_window())
emitted = []
window.axis_panel.moveAbsRequested.connect(lambda axis, value: emitted.append(value))
window.axis_panel.target_edit.setText("")
window.axis_panel._on_move_abs()
assert emitted == []
def test_applying_parameters_persists_them_in_the_panel(make_window):
from orisys_autocal.motion import AxisParams
window = _show(make_window())
panel = window.axis_panel
panel.set_selected_axis(1)
panel.units_edit.setText("5")
panel.speed_edit.setText("250")
panel.accel_edit.setText("2500")
panel._on_apply()
stored = panel.stored_params()[1]
assert isinstance(stored, AxisParams)
assert stored.units == 5.0
# --------------------------------------------------------------- preferences
def test_the_window_geometry_round_trips_through_preferences(make_window):
# Above the enforced minimum (1400x860): a smaller request would be clamped,
# which tests Qt's constraint rather than the preference round-trip.
window = _show(make_window(), (1520, 920))
window._persist_prefs()
geometry = window.prefs["window_geometry"]
assert len(geometry) == 4
assert geometry[2] == pytest.approx(1520, abs=5)
assert geometry[3] == pytest.approx(920, abs=5)
def test_a_malformed_geometry_is_ignored_rather_than_crashing(make_window):
window = _show(make_window(window_geometry=["nonsense"]))
assert window.width() > 0
def test_axis_parameters_survive_a_persist_reload(make_window):
from orisys_autocal import preferences as prefs_mod
window = _show(make_window())
window.axis_panel.set_selected_axis(2)
window.axis_panel.units_edit.setText("7")
window.axis_panel._on_apply()
window._persist_prefs()
reloaded = prefs_mod.load_preferences()
assert reloaded["axis_params"]["2"]["units"] == 7.0
def test_the_bus_path_is_remembered(make_window):
window = _show(make_window())
window.bus_panel.set_bas_path(r"D:\somewhere\ECAT.bas")
window._persist_prefs()
assert window.prefs["bas_path"] == r"D:\somewhere\ECAT.bas"
def test_sdo_fields_are_remembered(make_window):
window = _show(make_window())
panel = window.bus_data_panel
panel.node_edit.setText("2")
panel.index_edit.setText("24608")
panel.subindex_edit.setText("1")
window._persist_prefs()
assert window.prefs["sdo_last"]["node"] == 2
assert window.prefs["sdo_last"]["index"] == 24608
# --------------------------------------------------------------- demo session
def test_a_demo_session_fills_the_table_and_gates_motion(make_window, qt_app):
"""End to end through the real signal path: connect, initialise, jog."""
from orisys_autocal.motion import ConnectionSpec
window = _show(make_window())
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
assert _wait_for(lambda: window._last_snapshot.connected, qt_app)
# Download the shipped ECAT.bas, then wait for the simulated scan.
window.bus_panel.set_bas_path(str(_shipped_bas("ECAT.bas")))
window._on_download_bas()
assert _wait_for(lambda: window._last_snapshot.bus.init_status == 1, qt_app, timeout=8.0)
assert window._last_snapshot.bus.bus_type == "ecat"
assert _enabled(window.bus_data_panel.ecat_box)
assert _enabled(window.axis_panel._rows[0]["fwd_button"])
# ATYPE is no longer a table column — the seven-column table shows position
# and status, and the drive's full frame is on the selected-axis detail line.
window.axis_panel.set_selected_axis(0)
assert "65" in window.axis_panel._detail_label.text()
pump(qt_app, 0.2)
def test_closing_the_window_stops_the_poller(make_window, qt_app):
window = _show(make_window())
window.close()
pump(qt_app, 0.2)
assert not window.poller._thread.isRunning()
def _shipped_bas(name: str):
from orisys_autocal.ui.bus_panel import default_bas_dir
return default_bas_dir() / name
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
+233
View File
@@ -0,0 +1,233 @@
"""The poller thread: serialisation, failure isolation, and clean teardown.
The two things that must never happen are a controller call on the GUI thread
and two controller calls at once. Both are asserted here against a transport
that records how it was used.
"""
from __future__ import annotations
import threading
import time
import pytest
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
class RecordingTransport(DemoTransport):
"""A *connected* demo platform that records thread and overlap of every call.
Connected on purpose: the service polls only ``BUS_TYPE`` and ``Bus_InitStatus``
without a BAS program, so an unconnected transport would keep every read
short and nothing could be observed. A ``_concurrent`` guard asserts no two
calls ever overlap rather than merely asserting the maximum sum of started
calls, which would be trivially satisfied.
"""
def __init__(
self,
*,
delay: float = 0.0,
unexpected_on: set[str] | None = None,
) -> None:
super().__init__()
self.delay = delay
self.unexpected_on = unexpected_on or set()
self.thread_ids: set[int] = set()
self.max_concurrent = 0
self.overlaps: list[str] = []
self.calls: list[str] = []
self._lock = threading.Lock()
self._concurrent = 0
# A fake "BAS program has run": the bus reports EtherCAT and ready.
self._init_started = None
self._preloaded = True
def open(self, spec) -> None:
super().open(ConnectionSpec(mode="demo"))
self._bas_loaded = True
def user_var(self, name: str) -> float:
if name == "BUS_TYPE":
return 0.0
if name == "Bus_InitStatus":
return 1.0
if name == "Bus_TotalAxisnum":
return 5.0
return super().user_var(name)
def _enter(self, name: str) -> None:
with self._lock:
self._concurrent += 1
if self._concurrent > 1:
self.overlaps.append(name)
self.max_concurrent = max(self.max_concurrent, self._concurrent)
self.calls.append(name)
self.thread_ids.add(threading.get_ident())
if self.delay:
time.sleep(self.delay)
if name in self.unexpected_on:
raise RuntimeError(f"simulated unexpected fault in {name}")
def _exit(self) -> None:
with self._lock:
self._concurrent -= 1
def get_dpos(self, axis: int) -> float:
self._enter("get_dpos")
try:
return super().get_dpos(axis)
finally:
self._exit()
def get_mpos(self, axis: int) -> float:
self._enter("get_mpos")
try:
return super().get_mpos(axis)
finally:
self._exit()
def get_axis_status(self, axis: int) -> int:
self._enter("get_axis_status")
try:
return super().get_axis_status(axis)
finally:
self._exit()
def get_if_idle(self, axis: int) -> int:
self._enter("get_if_idle")
try:
return super().get_if_idle(axis)
finally:
self._exit()
def _connected_poller(transport: RecordingTransport, *, axes=(0,)) -> MotionPoller:
service = MotionService(transport)
assert service.connect(ConnectionSpec(mode="demo")).ok
return MotionPoller(service, axes=list(axes))
@pytest.fixture
def qt_app_session(qt_app):
return qt_app
def _drain(app, seconds: float = 1.0) -> None:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
app.processEvents()
time.sleep(0.005)
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.01)
return False
def test_completed_commands_are_reported_and_none_are_lost(qt_app):
transport = RecordingTransport()
poller = _connected_poller(transport)
try:
repo = {"finished": [], "failed": []}
poller.commandFinished.connect(lambda token, result: repo["finished"].append(token))
poller.commandFailed.connect(lambda token, message: repo["failed"].append(token))
tokens = [poller.submit(lambda service: None) for _ in range(5)]
assert _wait_for(lambda: len(repo["finished"]) == 5, qt_app)
assert sorted(repo["finished"]) == sorted(tokens)
assert repo["failed"] == []
finally:
poller.shutdown()
def test_every_controller_call_happens_on_one_worker_thread(qt_app):
transport = RecordingTransport()
poller = _connected_poller(transport, axes=(0, 1))
try:
assert _wait_for(lambda: "get_dpos" in transport.calls, qt_app)
poller.submit(lambda service: None)
_drain(qt_app, 0.3)
assert len(transport.thread_ids) == 1, transport.thread_ids
assert threading.get_ident() not in transport.thread_ids, (
"a controller call ran on the test's (GUI) thread"
)
finally:
poller.shutdown()
def test_two_controller_calls_never_overlap(qt_app):
"""A slow poll plus queued commands must still serialise."""
transport = RecordingTransport(delay=0.02)
poller = _connected_poller(transport)
try:
for _ in range(4):
poller.submit(lambda service: None)
_drain(qt_app, 0.8)
assert transport.max_concurrent == 1, (
f"{transport.max_concurrent} controller calls overlapped at once"
)
assert not transport.overlaps, transport.overlaps
finally:
poller.shutdown()
def test_an_unexpected_fault_is_reported_and_does_not_kill_the_poller(qt_app):
"""A non-``ZAuxError`` from inside a command must not stop the timer."""
transport = RecordingTransport(unexpected_on={"get_dpos"})
poller = _connected_poller(transport)
try:
failures = []
poller.commandFailed.connect(lambda token, message: failures.append((token, message)))
# The submitted action asks for the position directly, so the fault
# surfaces from the command rather than being absorbed by a poll.
poller.submit(lambda service: service.transport.get_dpos(0))
assert _wait_for(lambda: bool(failures), qt_app)
assert failures[0][1]
# The poller is still alive: later snapshots keep arriving.
before = len(transport.calls)
_drain(qt_app, 0.4)
assert len(transport.calls) > before
finally:
poller.shutdown()
def test_shutdown_stops_the_thread_and_is_idempotent(qt_app):
poller = _connected_poller(RecordingTransport())
_drain(qt_app, 0.2)
poller.shutdown(timeout_ms=3000)
poller.shutdown(timeout_ms=3000) # must not raise or hang
def test_a_refused_command_still_arrives_with_its_reason(qt_app):
"""An unconnected service refuses in the guard, without touching the DLL."""
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
try:
results = []
poller.commandFinished.connect(lambda token, result: results.append(result))
poller.submit(lambda service: service.jog(0, True))
assert _wait_for(lambda: bool(results), qt_app)
assert results[0].ok is False
assert results[0].message
finally:
poller.shutdown()
def test_connect_and_disconnect_round_trip_through_the_poller(qt_app):
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
try:
states = []
poller.connectionChanged.connect(states.append)
poller.connect_controller(ConnectionSpec(mode="demo"))
assert _wait_for(lambda: poller.service.connected, qt_app)
poller.disconnect_controller()
assert _wait_for(lambda: not poller.service.connected, qt_app)
finally:
poller.shutdown()
+283
View File
@@ -0,0 +1,283 @@
"""The calibration sequence document: round trip, refusals, derived summary.
The format is a contract with another machine's bench, so most of these tests
are about what happens when the file is *wrong* — a sequence half-applied is
worse than one rejected.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from orisys_autocal.sequence_io import (
DOCUMENT_KIND,
FORMAT_VERSION,
SequenceFormatError,
SequencePoint,
_version_tuple,
format_duration,
format_span,
from_document,
load,
save,
summarise,
to_document,
)
#: Where scratch files go. Deliberately not ``tmp_dir``: this suite has to run
#: in a sandboxed shell, and ``tmp_dir`` needs a writable process temp root that
#: such an environment may not provide (the same reason ``conftest.make_window``
#: avoids it).
_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_sequence_io"
@pytest.fixture
def tmp_dir():
shutil.rmtree(_SCRATCH, ignore_errors=True)
_SCRATCH.mkdir(parents=True, exist_ok=True)
yield _SCRATCH
shutil.rmtree(_SCRATCH, ignore_errors=True)
PROFILE = {
"mode": "flat",
"pitch_mm": 2.0,
"margin_mm": 1.0,
"radius_mm": 0.0,
"clear_height_mm": 5.0,
"approach_speed_mm_s": 2.0,
"press_speed_mm_s": 0.5,
"press_depth_mm": 0.2,
"dwell_s": 0.5,
"settle_window_s": 0.5,
"repeats": 3,
"order": "serpentine",
}
def _points(count: int = 6) -> list[SequencePoint]:
return [
SequencePoint(
id=index + 1,
target_mm={"x": 2.0 * index, "y": 0.0, "z": 12.4},
sensor_mm={"x": 2.0 * index, "y": 0.0, "z": 0.0},
clearance_mm=5.0,
)
for index in range(count)
]
def _document(**overrides) -> dict:
payload = to_document(
profile=PROFILE,
points=_points(),
name="sweep",
units_per_mm=10.0,
app_version="0.1.0",
shared_version="0.1.0",
shared_api="0.1",
)
payload.update(overrides)
return payload
# ------------------------------------------------------------------ round trip
def test_a_document_round_trips_through_a_file(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
back = load(path, current_profile=PROFILE)
assert back.point_count == 6
assert back.name == "sweep"
assert back.profile["pitch_mm"] == 2.0
assert back.units_per_mm == 10.0
assert not back.warnings
# Both frames survive: the platform target and the sensor position.
assert back.points[0].target_mm["z"] == pytest.approx(12.4)
assert back.points[0].sensor_mm is not None
def test_the_written_file_is_readable_json_with_the_house_style(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
text = path.read_text(encoding="utf-8")
assert text.endswith("\n")
assert "\n " in text, "the document is not indented"
assert "\\u" not in text, "non-ASCII was escaped"
payload = json.loads(text)
assert payload["kind"] == DOCUMENT_KIND
assert payload["format_version"] == FORMAT_VERSION
def test_saving_leaves_no_temporary_file_behind(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
assert [entry.name for entry in tmp_dir.iterdir()] == ["sweep.oryseq"]
def test_saving_over_an_existing_file_replaces_it(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
save(_document(name="second"), path)
assert json.loads(path.read_text(encoding="utf-8"))["name"] == "second"
# -------------------------------------------------------------------- refusals
@pytest.mark.parametrize(
("case", "mutate", "expected"),
[
("wrong kind", lambda d: d.update(kind="something.else"), "not a sequence document"),
("missing version", lambda d: d.pop("format_version"), "format_version"),
("version not a string", lambda d: d.update(format_version=1.0), "format_version"),
("newer version", lambda d: d.update(format_version="2.0"), "newer AutoCal"),
("missing frame", lambda d: d.pop("frame"), "frame"),
("wrong unit", lambda d: d.update(frame={"length_unit": "um"}), "length_unit"),
("missing profile", lambda d: d.pop("profile"), "profile"),
("points not a list", lambda d: d.update(points={}), "must be a list"),
("point not an object", lambda d: d.update(points=["nope"]), "not an object"),
("point missing axis", lambda d: d.update(points=[{"target_mm": {"x": 1, "y": 2}}]), "missing 'z'"),
("point without target", lambda d: d.update(points=[{}]), "no target_mm"),
],
)
def test_a_bad_document_is_refused_with_a_specific_message(case, mutate, expected):
document = _document()
mutate(document)
with pytest.raises(SequenceFormatError) as excinfo:
from_document(document, current_profile=PROFILE)
assert expected in str(excinfo.value), case
def test_a_non_json_file_is_refused_with_its_position(tmp_dir):
path = tmp_dir / "broken.oryseq"
path.write_text("this is not json", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
load(path, current_profile=PROFILE)
assert "not valid JSON" in str(excinfo.value)
assert "line 1" in str(excinfo.value)
def test_a_missing_file_is_refused_not_crashed(tmp_dir):
with pytest.raises(SequenceFormatError):
load(tmp_dir / "absent.oryseq", current_profile=PROFILE)
def test_a_json_array_is_refused(tmp_dir):
path = tmp_dir / "array.oryseq"
path.write_text("[1, 2, 3]", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
load(path, current_profile=PROFILE)
assert "top level" in str(excinfo.value)
# ------------------------------------------------------------------- summary
def test_the_summary_reports_count_and_travel_span():
summary = summarise(_points(6), PROFILE)
assert summary["count"] == 6
assert summary["min_mm"]["x"] == 0.0
assert summary["max_mm"]["x"] == 10.0
assert summary["span_mm"]["x"] == pytest.approx(10.0)
# A constant coordinate has a nil span, and says so.
assert summary["span_mm"]["z"] == pytest.approx(0.0)
def test_the_summary_is_recomputed_and_wins_over_the_file():
"""A hand-edited summary must not be believed."""
document = _document()
document["summary"]["count"] = 999
document["summary"]["span_mm"]["x"] = 999.0
back = from_document(document, current_profile=PROFILE)
assert back.summary["count"] == 6
assert back.summary["span_mm"]["x"] == pytest.approx(10.0)
assert back.warnings and "disagrees" in back.warnings[0]
def test_an_agreeing_summary_produces_no_warning():
back = from_document(_document(), current_profile=PROFILE)
assert not any("disagrees" in warning for warning in back.warnings)
def test_no_points_imports_the_profile_and_says_so():
document = _document()
document.pop("points")
back = from_document(document, current_profile=PROFILE)
assert back.point_count == 0
assert any("no points" in warning for warning in back.warnings)
def test_a_missing_profile_key_keeps_the_current_value():
document = _document()
document["profile"].pop("pitch_mm")
back = from_document(document, current_profile=PROFILE)
assert back.profile["pitch_mm"] == 2.0
assert any("pitch_mm" in warning for warning in back.warnings)
def test_a_disabled_point_is_counted_separately():
points = _points(4)
points[1] = SequencePoint(id=2, target_mm=points[1].target_mm, enabled=False)
summary = summarise(points, PROFILE)
assert summary["count"] == 4
assert summary["enabled"] == 3
# ------------------------------------------------------------------- duration
def test_the_duration_estimate_scales_with_repeats_and_points():
one = summarise(_points(1), {**PROFILE, "repeats": 1})["estimated_duration_s"]
three = summarise(_points(1), {**PROFILE, "repeats": 3})["estimated_duration_s"]
assert three > one
six = summarise(_points(6), {**PROFILE, "repeats": 3})["estimated_duration_s"]
assert six > three
def test_no_points_means_no_estimate():
summary = summarise([], PROFILE)
assert summary["estimated_duration_s"] == 0.0
assert format_duration(0.0) == "—"
def test_the_duration_is_formatted_as_a_bench_timer():
assert format_duration(72.0) == "1:12"
assert format_duration(3600.0) == "1:00:00"
assert format_duration(None) == "—"
assert format_duration(float("nan")) == "—"
# ---------------------------------------------------------------------- spans
def test_a_zero_span_prints_once_not_twice():
assert format_span(12.4, 12.4) == "12.400"
assert format_span(0.0, 0.0) == "0.000"
def test_a_real_span_shows_both_ends_and_the_length():
assert format_span(0.0, 10.0) == "0.000–10.000 (10.000)"
def test_a_missing_span_is_a_dash():
assert format_span(None, None) == "—"
# --------------------------------------------------------------------- version
def test_versions_compare_as_tuples_not_strings():
"""``"1.10" < "1.9"`` as strings would load a newer file as if supported."""
assert _version_tuple("1.10") > _version_tuple("1.9")
assert _version_tuple("1.0") == (1, 0)
assert _version_tuple("1") == (1,)
# A same-major newer minor is still refused, but is *recognised* as newer.
document = _document()
document["format_version"] = "1.10"
with pytest.raises(SequenceFormatError):
from_document(document, current_profile=PROFILE)
+73
View File
@@ -0,0 +1,73 @@
"""Unit-to-millimetre conversion.
The reference program carries the bench measurement in a comment::
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
# 当量5 → 1000单位=1mm,不是100单位=1mm
These are the assertions that keep that comment honest.
"""
from __future__ import annotations
import math
import pytest
from orisys_autocal.motion.units import (
PULSES_PER_MM,
mm_to_user_units,
safe_float,
user_units_to_mm,
)
@pytest.mark.parametrize(
("units", "user_units", "expected_mm"),
[
(10.0, 1000.0, 2.0), # the measured case from the vendor comment
(5.0, 1000.0, 1.0), # ...and the second one
(10.0, 0.0, 0.0),
(10.0, 5000.0, 10.0),
(10.0, -1000.0, -2.0), # negative travel must stay negative
],
)
def test_conversion_matches_the_bench_measurement(units, user_units, expected_mm):
assert user_units_to_mm(user_units, units=units) == pytest.approx(expected_mm)
def test_default_units_is_the_platform_value():
assert user_units_to_mm(1000.0) == pytest.approx(2.0)
assert PULSES_PER_MM == 5000.0
def test_conversion_round_trips():
for units in (1.0, 5.0, 10.0, 37.5):
millimetres = user_units_to_mm(1234.5, units=units)
assert mm_to_user_units(millimetres, units=units) == pytest.approx(1234.5)
def test_a_zero_or_negative_scale_is_not_a_number():
"""A bad constant must be visibly unusable, not a plausible number."""
assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=0.0))
assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=-1.0))
assert math.isnan(mm_to_user_units(1.0, units=0.0))
@pytest.mark.parametrize(
"text",
["", " ", "abc", "1,5", None, "nan", "inf", "-inf"],
)
def test_unusable_entries_fall_back(text):
assert safe_float(text, 42.0) == 42.0
@pytest.mark.parametrize("text", ["0", "0.0", " 0 "])
def test_zero_falls_back_because_it_would_divide(text):
"""A pulse equivalent of zero must never reach the controller."""
assert safe_float(text, 7.0) == 7.0
@pytest.mark.parametrize(("text", "expected"), [("5", 5.0), (" 2.5 ", 2.5), ("-3", -3.0)])
def test_usable_entries_are_parsed(text, expected):
assert safe_float(text, 42.0) == expected
File diff suppressed because it is too large Load Diff
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""Dependency-free offscreen check of the AutoCal window.
Builds the real window with the simulated platform and asserts the invariants
that are easy to break and hard to notice: theme tokens present, layout fitting
inside parents, controls gated by connection and bus state, locales complete.
python apps/autocal/tools/panel_check.py
No controller, no DLL and no display are needed. Mirrors
``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
os.environ.setdefault("ORISYS_APP_ID", "autocal")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
RESOLUTIONS = ((1440, 900), (1920, 1080))
class Checker:
"""Collects failures instead of asserting, so one run reports everything."""
def __init__(self) -> None:
self.failures: list[str] = []
self.checks = 0
def that(self, condition: bool, message: str) -> None:
self.checks += 1
if not condition:
self.failures.append(message)
def report(self) -> int:
print()
if self.failures:
print(f"FAILED — {len(self.failures)} of {self.checks} checks:")
for failure in self.failures:
print(f" - {failure}")
return 1
print(f"OK — {self.checks} checks passed")
return 0
def _visible_descendants(widget):
for child in widget.findChildren(object):
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
continue
if child.isVisible():
yield child
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
checker = Checker()
app = QApplication.instance() or QApplication([])
print(f"Qt binding: PySide6 {pyside_version}")
# ---- theme tokens -----------------------------------------------------
for theme in ("dark", "light"):
colors = load_colors(theme)
qss = build_qss(theme)
checker.that(bool(colors), f"{theme}: no colour tokens")
checker.that(len(qss) > 500, f"{theme}: the stylesheet looks empty")
checker.that(
len(build_qss(theme)) == len(qss), f"{theme}: the stylesheet is not stable"
)
# ---- locale completeness ---------------------------------------------
from orisys_autocal.preferences import load_preferences
prefs = load_preferences()
for language in ("zh_CN", "en_US"):
set_locale(language)
checker.that(t("app.title") != "app.title", f"{language}: app.title missing")
checker.that(t("axes.title") != "axes.title", f"{language}: axes.title missing")
checker.that(
t("bus.state_ok") != "bus.state_ok", f"{language}: bus.state_ok missing"
)
set_locale("zh_CN")
# ---- window ----------------------------------------------------------
from orisys_autocal.motion import DemoTransport
for theme in ("dark", "light"):
window = AutoCalMainWindow(prefs={**prefs, "theme": theme})
window.poller.service._transport = DemoTransport() # noqa: SLF001
app.setStyleSheet(build_qss(theme))
print(f"\n--- theme={theme} ---")
for width, height in RESOLUTIONS:
window.resize(width, height)
window.show()
app.processEvents()
# Every tab, not just the default one: a page that overflows only
# when its tab is selected is exactly the defect this catches.
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
oversize = []
for child in _visible_descendants(window):
if _inside_scroll_area(child):
continue # a rail's content is meant to exceed its viewport
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if (
geometry.width() > parent.width() + 1
or geometry.height() > parent.height() + 1
):
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > {parent.width()}x{parent.height()}"
)
checker.that(
not oversize, f"{theme} {width}x{height} tab={key}: {oversize}"
)
window.workspace.set_current_key("axes")
print(f" {width}x{height}: every tab fits")
# ---- the permanent shell ----------------------------------------
checker.that(
window.workspace.page_keys() == ["axes", "sequence", "sensor", "surface", "points"],
f"unexpected tab set: {window.workspace.page_keys()}",
)
checker.that(
window.status_strip.isVisible(),
"the motion status strip is not visible",
)
checker.that(
window.status_strip.height() <= 48,
f"the status strip is {window.status_strip.height()}px tall",
)
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
checker.that(
window.status_strip.isVisible(),
f"the status strip vanished on tab {key}",
)
checker.that(
bool(window.workspace.tabText(window.workspace.currentIndex())),
f"tab {key} has no label",
)
window.workspace.set_current_key("axes")
# The readout rail must show all five drives without scrolling.
readout_rows = len(window.axis_readout._rows)
checker.that(readout_rows == 5, f"the readout rail shows {readout_rows} axes")
checker.that(
window.axis_readout.width() >= 200,
f"the readout rail is only {window.axis_readout.width()}px wide",
)
# The sequence tab: three zones, a persisted profile, and a point list
# whose coordinates are generated rather than typed.
sequence = window.sequence_panel
checker.that(
sequence.profile_keys()
== [
"mode",
"pitch_mm",
"margin_mm",
"radius_mm",
"clear_height_mm",
"approach_speed_mm_s",
"press_speed_mm_s",
"press_depth_mm",
"dwell_s",
"settle_window_s",
"repeats",
"order",
],
f"sequence profile fields changed: {sequence.profile_keys()}",
)
checker.that(
sequence.validation_checks()
== ["on_surface", "within_travel", "contact_confirmable"],
f"sequence validation checks changed: {sequence.validation_checks()}",
)
for name, phase in sequence.pending_phases().items():
button = sequence._pending_buttons[name]
checker.that(
not button.isEnabled() and phase in button.toolTip(),
f"sequence control {name} is usable or does not name its phase",
)
checker.that(
sequence.planned_steps() == [],
"the sequence tab handed out steps a runner could start",
)
checker.that(
sequence.point_row_count() > 0,
"the sequence point list is empty",
)
checker.that(
sequence.table.columnCount() == len(sequence.column_keys()),
f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}",
)
# The shell: one rail, a run bar, and a health chip.
checker.that(
window.main_splitter.count() == 2,
f"the layout has {window.main_splitter.count()} columns, expected 2",
)
checker.that(
window.left_rail.widget() is window.rail_content,
"the setup rail is not scrollable",
)
checker.that(
window.run_bar.isVisible(),
"the run bar is not visible",
)
checker.that(
not window.run_bar.abort_button.isEnabled(),
"abort is enabled with no run in progress",
)
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
checker.that(
window.run_bar.isVisible(),
f"the run bar vanished on tab {key}",
)
window.workspace.set_current_key("axes")
checker.that(
bool(window.status_strip.chip_health.text()),
"the poll-health chip has no text",
)
checker.that(
"QStatusBar" in window.statusBar().styleSheet(),
"the status bar is not using its own theme token",
)
# A collapsed bus-data group must not cost the page any height.
checker.that(
not window.bus_data_panel.isVisible(),
"the bus-data panel is expanded by default",
)
print(" shell: strip, readout rail and sequence profile verified")
panel = window.axis_panel
checker.that(panel.axis_count == 5, "the platform is not five axes")
checker.that(
panel.table.columnCount() == len(COLUMNS) + 1,
"the axis table lost a column",
)
# Every header must fit its column: a clipped header is how a table stops
# being readable without anyone noticing.
for index in range(panel.table.columnCount()):
header = panel.table.horizontalHeaderItem(index).text()
width = panel.table.columnWidth(index)
needed = panel.table.fontMetrics().horizontalAdvance(header) + 12
checker.that(
width >= needed,
f"axis column {index} ({header!r}) is {width}px, needs {needed}px",
)
checker.that(
len(COLUMNS) == 7,
f"the axis table should show seven data columns, has {len(COLUMNS)}",
)
checker.that(
window.axis_panel._detail_label.text(),
"the selected-axis detail line is empty",
)
headers = [
panel.table.horizontalHeaderItem(index).text()
for index in range(panel.table.columnCount())
]
checker.that(
all(headers) and headers[0] == t("axes.axis"),
f"axis table headers are not localised: {headers}",
)
checker.that(
all(
panel.table.item(row, 0) is not None for row in range(panel.axis_count)
),
"an axis row has no data cell",
)
# Motion must be unreachable before a bus is initialised.
checker.that(
not panel._rows[0]["fwd_button"].isEnabled(),
"jog is enabled before the bus is initialised",
)
# Bus data follows the reported bus family. ``isEnabledTo`` rather than
# ``isEnabled``: the panel now lives in a collapsed group, and Qt reports
# a hidden ancestor as disabling everything inside it.
box = window.bus_data_group
window.bus_data_panel.set_connected(True)
window.bus_data_panel.set_bus_type("ecat")
checker.that(
window.bus_data_panel.ecat_box.isEnabledTo(box)
and not window.bus_data_panel.rtex_box.isEnabledTo(box),
"the SDO/RTEX panels are not gated by bus type",
)
# Expanding the group must reveal the panel, not resize the page.
before = window.size()
box.setChecked(True)
app.processEvents()
checker.that(
window.bus_data_panel.isVisible(), "expanding the group did not show the panel"
)
box.setChecked(False)
app.processEvents()
checker.that(
window.size() == before, "toggling bus data resized the window"
)
# 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)
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)
checker.that(ok, "the simulated bus did not report initialised")
if ok:
checker.that(
window._last_snapshot.bus.bus_type == "ecat",
"the bus type was not decoded from BUS_TYPE",
)
checker.that(
window._last_snapshot.bus.total_axes == 5,
"the bus did not report five axes",
)
checker.that(
panel._rows[0]["fwd_button"].isEnabled(),
"jog stayed disabled after the bus was initialised",
)
checker.that(
"65" in window.axis_panel._detail_label.text(),
"ATYPE was not read back on the detail line (expected 65, position mode)",
)
print(
f" session: bus={window._last_snapshot.bus.bus_type} "
f"axes={window._last_snapshot.bus.total_axes} "
f"{panel.enabled_count_label.text()}"
)
# The strip must be reporting the live session, from whatever tab.
window.workspace.set_current_key("surface")
app.processEvents()
strip = window.status_strip
checker.that(
strip.chip_connection.text() == t("connection.state_open"),
f"strip connection chip says {strip.chip_connection.text()!r}",
)
checker.that(
strip.chip_bus.text() == t("bus.type_ecat"),
f"strip bus chip says {strip.chip_bus.text()!r}",
)
checker.that(
strip.chip_init.text() == t("bus.state_ok"),
f"strip init chip says {strip.chip_init.text()!r}",
)
checker.that(
t("axes.enabled_count", on=5, total=5) == strip.chip_axes.text()
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")
window.close()
app.processEvents()
return checker.report()
def _inside_scroll_area(widget) -> bool:
"""Whether ``widget`` is scroll-area content rather than a laid-out child."""
from PySide6.QtWidgets import QAbstractScrollArea
parent = widget.parentWidget()
while parent is not None:
if isinstance(parent, QAbstractScrollArea):
return True
parent = parent.parentWidget()
return False
def _wait_for(app, predicate, timeout: float) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
if __name__ == "__main__":
raise SystemExit(main())
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Render the AutoCal window offscreen to a PNG, for review.
python apps/autocal/tools/panel_snapshot.py --theme light --out shot.png
Optionally drives a simulated session first, so the shot shows live values:
python apps/autocal/tools/panel_snapshot.py --demo --language en_US --out shot.png
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
os.environ.setdefault("ORISYS_APP_ID", "autocal")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--theme", default="dark", choices=("dark", "light"))
parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US"))
parser.add_argument("--size", default="1600x900", help="WIDTHxHEIGHT")
parser.add_argument("--out", default="autocal.png")
parser.add_argument(
"--tab",
default="axes",
help="workspace tab to show: axes, sequence, sensor, surface, points",
)
parser.add_argument(
"--compare",
action="store_true",
help="render every tab, named <out stem>_<tab>.<ext>, so the layout can be "
"reviewed in one pass",
)
parser.add_argument(
"--demo",
action="store_true",
help="drive a simulated session so the shot shows live values",
)
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
set_locale(args.language)
app = QApplication.instance() or QApplication([])
app.setStyleSheet(build_qss(args.theme))
prefs = load_preferences()
prefs["theme"] = args.theme
prefs["language"] = args.language
prefs["show_diagnostics"] = False
window = AutoCalMainWindow(prefs=prefs)
window.poller.service._transport = DemoTransport() # noqa: SLF001
window.resize(width, height)
window.show()
app.processEvents()
if args.demo:
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
window._on_download_bas()
_wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
window.axis_panel.set_selected_axis(0)
window.axis_panel.target_edit.setText("1000")
window._on_move_abs(0, 1000.0)
_wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.0)
app.processEvents()
if args.compare:
return _render_every_tab(window, app, args, width, height)
window.workspace.set_current_key(args.tab)
app.processEvents()
output = Path(args.out).resolve()
if not window.grab().save(str(output)):
print(f"could not write {output}", file=sys.stderr)
return 1
print(
f"wrote {output} ({width}x{height}, theme={args.theme}, "
f"lang={args.language}, tab={args.tab})"
)
window.close()
app.processEvents()
return 0
def _render_every_tab(window, app, args, width: int, height: int) -> int:
"""One image per workspace tab, so the whole layout can be reviewed at once."""
base = Path(args.out).resolve()
failures = 0
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
target = base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}")
if window.grab().save(str(target)):
print(f"wrote {target} ({width}x{height}, tab={key})")
else:
print(f"could not write {target}", file=sys.stderr)
failures += 1
window.close()
app.processEvents()
return 1 if failures else 0
def _wait_for(app, predicate, timeout: float) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
if __name__ == "__main__":
raise SystemExit(main())
+9 -3
View File
@@ -1,5 +1,11 @@
# Internal app (stub)
# Internal app (superseded)
Future home for automated calibration and laboratory testing tools.
The internal calibration and laboratory tooling this directory was reserved for
now lives in [`apps/autocal`](../autocal/README.md) — the 5-axis calibration
platform control application.
This directory is intentionally empty in the customer-facing scaffold. Shared code will come from `shared/`; do not ship this app in the customer installer.
That app keeps the property this stub existed to protect: it is **not** part of
the customer-facing product. It is not packaged by `build/studio` and must not
enter the customer installer.
This directory is otherwise unused; it is kept only so the move is documented.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+12
View File
@@ -33,6 +33,10 @@
"scene3d_settings": "3D display settings…",
"show_fps": "Show FPS",
"show_diagnostics": "Diagnostics",
"raw_auto_zero": "Finger raw auto-zero at start",
"raw_auto_zero_tip": "Value cards only: estimate an idle bias and deadband after start. Curves, statistics and CSV keep the measured values.",
"raw_reestimate": "Re-estimate raw baseline…",
"raw_reestimate_tip": "Drop the current display baseline and re-estimate from the next quiet samples (value cards only).",
"settings": "Settings area",
"plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°",
@@ -217,6 +221,8 @@
"selection_empty": "No valid samples in the selection",
"value_unavailable": "n/a",
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
"zero_zone": "zero zone",
"zero_zone_tip": "Display deadband after auto baseline; curves, statistics and CSV keep the measured values",
"unit_toggle": "Switch unit",
"unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units",
"browse_only_tip": "Drag or scroll on the chart to browse history"
@@ -412,6 +418,12 @@
"field_depth": "Depth response",
"field_flow": "Optical flow magnitude",
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
"flow_arrows": "Surface flow arrows",
"flow_arrows_tip": "Draw optical-flow arrows on the sensing surface using the same sample points as the 2D view.",
"flow_arrows_unavailable": "This scene package has no UV→scene surface mapping, so flow arrows cannot be drawn.",
"contact_arrow": "Contact normal arrow",
"contact_arrow_tip": "Draw a yellow local-normal arrow at the contact centre (length from the normal response, capped — not a calibrated force vector).",
"contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.",
"quantity_flow_magnitude": "Optical flow magnitude",
"quantity_depth_response": "Depth response",
"mode_surface": "surface colouring",
+12
View File
@@ -33,6 +33,10 @@
"scene3d_settings": "三维显示设置…",
"show_fps": "显示 FPS",
"show_diagnostics": "诊断信息",
"raw_auto_zero": "Finger raw 启动归零",
"raw_auto_zero_tip": "仅影响数字卡展示:启动后自动估计无载偏置与死区。曲线、统计与 CSV 仍为测量值。",
"raw_reestimate": "重新估计 raw 基线…",
"raw_reestimate_tip": "丢弃当前展示基线,从接下来的无载样本重新估计(仅数字卡)。",
"settings": "设置区",
"plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
@@ -217,6 +221,8 @@
"selection_empty": "选区无有效样本",
"value_unavailable": "不可用",
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
"zero_zone": "零区",
"zero_zone_tip": "展示归零后的死区内读数;曲线、统计与 CSV 仍为原始测量值",
"unit_toggle": "切换单位",
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
@@ -412,6 +418,12 @@
"field_depth": "深度响应",
"field_flow": "光流幅值",
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
"flow_arrows": "表面光流箭头",
"flow_arrows_tip": "在传感表面上按与二维相同的采样点绘制光流箭头。",
"flow_arrows_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制光流箭头。",
"contact_arrow": "接触法向箭头",
"contact_arrow_tip": "在接触中心沿局部法向绘制黄色示意箭头(长度由法向响应限幅,不是标定合力)。",
"contact_arrow_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制接触法向箭头。",
"quantity_flow_magnitude": "光流幅值",
"quantity_depth_response": "深度响应",
"mode_surface": "曲面着色",
@@ -9,6 +9,7 @@
"show_cal_panel": false,
"show_finger_3d": true,
"show_fps": false,
"raw_auto_zero": true,
"gl_profile": "desktop33",
"scene3d_package": "",
"scene3d_display": {
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"schema_version": 2,
"unit": "N",
"channels": {
"normal": {
@@ -14,8 +14,22 @@
[62000.0, 2.377]
]
},
"shear": {
"shear_x": {
"method": "piecewise_linear",
"raw_key": "fshearx",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[36000.0, 1.19],
[68000.0, 2.06]
]
},
"shear_y": {
"method": "piecewise_linear",
"raw_key": "fsheary",
"input": "components",
"signed": true,
"extrapolate": true,
+4 -1
View File
@@ -9,7 +9,10 @@ description = "Orisys Studio desktop app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
"PyQt5>=5.15",
# PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not
# on PyQt5 (whose free option is GPLv3-or-commercial). See
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py.
"PySide6>=6.6",
"orisys-app-shared>=0.1.0",
"orisys>=0.5",
"scipy>=1.10.1",
+95 -19
View File
@@ -9,11 +9,9 @@ from pathlib import Path
from typing import Any
import numpy as np
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QImage
from PyQt5.QtWidgets import (
QAction,
QActionGroup,
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
@@ -240,7 +238,7 @@ class StudioMainWindow(QMainWindow):
root.addWidget(self.toolbar)
self.status_chip = self.toolbar.status_chip
self.content_splitter = QSplitter(Qt.Horizontal)
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
self.arrow_panel = ArrowPanel(theme=self._theme)
self.force_plots = ForcePlotsPanel(theme=self._theme)
@@ -888,7 +886,7 @@ class StudioMainWindow(QMainWindow):
# convention everyone's hands already know.
self.act_play = QAction(self)
self.act_play.setShortcut("Space")
self.act_play.setShortcutContext(Qt.ApplicationShortcut)
self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
self.addAction(self.act_play)
self.view_menu = menu_bar.addMenu("")
@@ -925,6 +923,16 @@ class StudioMainWindow(QMainWindow):
)
self.view_menu.addAction(self.act_show_diagnostics)
self.act_raw_auto_zero = QAction(self)
self.act_raw_auto_zero.setCheckable(True)
self.act_raw_auto_zero.setChecked(bool(self.prefs.get("raw_auto_zero", True)))
self.act_raw_auto_zero.toggled.connect(self.display_ctl.on_raw_auto_zero_toggled)
self.view_menu.addAction(self.act_raw_auto_zero)
self.act_raw_reestimate = QAction(self)
self.act_raw_reestimate.triggered.connect(self.display_ctl.request_raw_reestimate)
self.view_menu.addAction(self.act_raw_reestimate)
self.act_settings = QAction(self)
self.act_settings.setCheckable(True)
self.act_settings.setChecked(self.toolbar.settings_expanded())
@@ -1168,6 +1176,10 @@ class StudioMainWindow(QMainWindow):
self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_show_diagnostics.setText(t("menu.show_diagnostics"))
self.act_raw_auto_zero.setText(t("menu.raw_auto_zero"))
self.act_raw_auto_zero.setToolTip(t("menu.raw_auto_zero_tip"))
self.act_raw_reestimate.setText(t("menu.raw_reestimate"))
self.act_raw_reestimate.setToolTip(t("menu.raw_reestimate_tip"))
self.act_settings.setText(t("menu.settings"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
@@ -1388,7 +1400,7 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
def _show_about(self) -> None:
AboutDialog(self).exec_()
AboutDialog(self).exec()
def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value))
@@ -1493,7 +1505,7 @@ class StudioMainWindow(QMainWindow):
filters_locked=bool(self._running or self.recorder.is_recording),
sample_rate_hz=self.display_ctl.device_sample_rate(),
)
if dialog.exec_() != PlotSettingsDialog.Accepted:
if dialog.exec() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.display_ctl.apply_plot_settings(
@@ -1575,10 +1587,10 @@ class StudioMainWindow(QMainWindow):
sensor_type=sensor_type,
config=config_ref,
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer != QMessageBox.Yes:
if answer != QMessageBox.StandardButton.Yes:
self.recorder.log_event(
"calibration_apply_blocked", "; ".join(reasons)
)
@@ -1588,10 +1600,10 @@ class StudioMainWindow(QMainWindow):
self,
t("cal.apply_title"),
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer != QMessageBox.Yes:
if answer != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage(t("cal.apply_skipped"))
return
worker = self._worker_for(sensor_type)
@@ -1706,7 +1718,18 @@ class StudioMainWindow(QMainWindow):
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
self.act_stop.setEnabled(self._session_active)
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
# session alone: a replay or a recompute pass is something to stop.
#
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
# did not) and never delivers the shortcut of a disabled action, so a Stop that
# is disabled while a replay is playing is a keyboard escape hatch that does not
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
self.act_stop.setEnabled(
self._session_active
or self.replay_ctl.is_playing()
or self._recompute_running
)
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
@@ -1726,10 +1749,16 @@ class StudioMainWindow(QMainWindow):
In the replay view it is the same escape hatch as the rail's 停止 button: a
replay or a recompute pass that will not stop by itself has to be stoppable
from the keyboard.
from the keyboard. The replay branch accepts the same three states
``_sync_shortcut_actions`` enables the action for, so the action can never be
enabled with nothing behind it.
"""
if self.toolbar.run_role() == "playback":
if self.replay_ctl.is_playing() or self._session_active:
if (
self.replay_ctl.is_playing()
or self._session_active
or self._recompute_running
):
self.replay_ctl.stop_activity()
return
if not self._session_active:
@@ -1886,6 +1915,16 @@ class StudioMainWindow(QMainWindow):
self._stop_preview(announce=False)
self.force_plots.reset()
units = "N" if self.toolbar.units() == "newton" else "raw"
self.force_plots.configure_presentation(
sensor_type=sensor_type,
unit=units,
enabled=(
sensor_type == "finger"
and units == "raw"
and bool(self.prefs.get("raw_auto_zero", True))
),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
# Fresh record channel for the new session. The calibration identity is
@@ -2126,6 +2165,30 @@ class StudioMainWindow(QMainWindow):
self.session.last_sample_valid = True
self._last_display_fallback = bool(display.using_raw_fallback)
self._last_display_available = tuple(bool(flag) for flag in display.available)
flow = getattr(packet, "flow", None)
flow_p95 = None
if flow is not None:
try:
import numpy as np
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim == 3 and arr.shape[-1] == 2:
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
if m.shape == mag.shape:
vals = mag[m & np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
flow_p95 = float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
flow_p95 = None
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self.force_plots.push(
# Session-relative time, the same base the store and
# ``forces.csv`` use. The wall clock must never reach the
@@ -2136,6 +2199,9 @@ class StudioMainWindow(QMainWindow):
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
else:
self.session.last_sample_valid = False
@@ -2226,6 +2292,11 @@ class StudioMainWindow(QMainWindow):
def _render_run_image(self, packet) -> None:
if packet is None or self._replay_owns_image():
return
flow_valid = getattr(packet, "flow_valid", None)
depth_valid = getattr(packet, "depth_valid", None)
stale = bool(getattr(packet, "stale_forces", False)) or (
flow_valid is False and depth_valid is False
)
self._show_image_frame(
ImageFrame(
getattr(packet, "processed_frame", None),
@@ -2235,6 +2306,11 @@ class StudioMainWindow(QMainWindow):
source=SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE,
frame_id=int(getattr(packet, "frame_id", 0) or 0),
t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.0),
stale=stale,
flow_valid=flow_valid,
flow_frame_id=getattr(packet, "flow_frame_id", None),
depth_valid=depth_valid,
depth_frame_id=getattr(packet, "depth_frame_id", None),
)
)
@@ -2287,7 +2363,7 @@ class StudioMainWindow(QMainWindow):
geo = self.prefs.get("window_geometry")
if isinstance(geo, str) and geo:
try:
from PyQt5.QtCore import QByteArray
from PySide6.QtCore import QByteArray
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
except Exception:
+44 -1
View File
@@ -21,7 +21,7 @@ from __future__ import annotations
import logging
from typing import Any, Optional, Protocol
from PyQt5.QtCore import QObject
from PySide6.QtCore import QObject
logger = logging.getLogger(__name__)
@@ -84,7 +84,10 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: Finger-raw value cards: startup auto baseline / deadband (display only).
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
def bind_actions(self, *, rotate_arrows: Any) -> None:
"""Take the rotate entry once the menus exist (it follows the sensor)."""
@@ -261,3 +264,43 @@ class DisplayPrefsController(QObject):
setter = getattr(panel, "set_diagnostics_visible", None)
if callable(setter):
setter(self._show_diagnostics)
# ------------------------------------------------------- raw display zeroing
@property
def raw_auto_zero(self) -> bool:
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether Finger raw value cards auto-baseline at session start."""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
self.apply_presentation_prefs()
def on_raw_auto_zero_toggled(self, enabled: bool) -> None:
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the current baseline policy onto the charts (Finger raw only)."""
configure = getattr(self._charts, "configure_presentation", None)
if not callable(configure):
return
sensor = (
self._picker.sensor_type()
if hasattr(self._picker, "sensor_type")
else "box"
)
unit = "N" if self._toolbar.units() == "newton" else "raw"
configure(
sensor_type=sensor,
unit=unit,
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
)
def request_raw_reestimate(self) -> None:
"""Operator entry: restart the idle baseline window on the value cards."""
request = getattr(self._charts, "request_presentation_reestimate", None)
if callable(request):
request()
else:
self.apply_presentation_prefs()
+7 -2
View File
@@ -6,7 +6,12 @@ import logging
import os
import sys
from PyQt5.QtWidgets import QApplication
# pyqtgraph picks its toolkit from whichever binding is already imported, and in a
# development environment that also has PyQt5 installed (``orisys[gui]``) it can pick the
# 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.compat import check_shared_compat
@@ -85,7 +90,7 @@ def main() -> int:
language,
gl_profile,
)
code = int(app.exec_())
code = int(app.exec())
# Leave the process immediately.
#
+5 -5
View File
@@ -24,7 +24,7 @@ from pathlib import Path
from typing import Optional
import numpy as np
from PyQt5.QtCore import QObject, pyqtSignal
from PySide6.QtCore import QObject, Signal
from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader, VideoOnlySource
@@ -47,13 +47,13 @@ class ReplayEngine(QObject):
"""Open one recording and answer "what is at t?" for every view."""
#: The cursor moved; the chart cursor and the 2D panel follow.
position_changed = pyqtSignal(float)
position_changed = Signal(float)
#: The frame under the cursor changed.
frames_changed = pyqtSignal()
frames_changed = Signal()
#: The notes about this recording, or the loaded recording, changed.
status_changed = pyqtSignal()
status_changed = Signal()
#: A recording was opened (True) or the previous one was dropped (False).
recording_changed = pyqtSignal(bool)
recording_changed = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -28,8 +28,8 @@ from pathlib import Path
from typing import Any, Optional, Protocol
import numpy as np
from PyQt5.QtCore import QObject, QTimer
from PyQt5.QtWidgets import QFileDialog
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
@@ -392,6 +392,26 @@ class FingerFrameAdapter:
refused.append(mask_problem)
return None
self.last_diagnostic = None
contact = getattr(packet, "contact", None)
contact_xy = None
contact_found = False
contact_valid = False
if contact is not None:
contact_xy = getattr(contact, "centroid_uv_xy", None)
contact_found = bool(getattr(contact, "found", False))
contact_valid = bool(getattr(contact, "valid", False))
flow = getattr(packet, "flow", None)
flow_uv = None
flow_mask = None
if flow is not None and _field_validity(packet, "flow", identity.frame_id) is None:
arr = np.asarray(flow, dtype=np.float32)
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
flow_uv = freeze(arr, dtype=np.float32)
device_for_flow, _ = _device_mask(packet, self.shape_hw)
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
if not np.isfinite(fn):
fn = float(getattr(packet, "normal_force", 0.0) or 0.0)
return SurfaceFieldFrame(
identity=identity,
geometry_id=self.geometry_id,
@@ -400,6 +420,12 @@ class FingerFrameAdapter:
scalar_descriptor=descriptor,
valid=_intersect(mask, device),
computed_frame_id=_computed_frame_id(packet, name, identity.frame_id),
flow_uv=flow_uv,
flow_valid_mask=flow_mask,
contact_uv_xy=contact_xy if isinstance(contact_xy, tuple) else None,
contact_found=contact_found,
contact_valid=contact_valid,
contact_fn=fn if np.isfinite(fn) else 0.0,
)
@@ -33,7 +33,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional
from PyQt5.QtCore import QObject, pyqtSignal
from PySide6.QtCore import QObject, Signal
from orisys_app_shared.paths import config_dir
from orisys_app_shared.scene3d.contracts import (
@@ -180,8 +180,8 @@ class SceneAssetLoader(QObject):
selection the operator has already moved away from.
"""
loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device
failed = pyqtSignal(object, int) # Diagnostic, request id
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:
super().__init__(parent)
@@ -462,17 +462,24 @@ class ThreeDCoordinator:
self._set_state("no_data")
def set_settings(self, settings: DisplaySettings) -> None:
"""Apply display settings; a gain change means recomposing the last field."""
"""Apply display settings; a gain/overlay change recomposes the last field."""
previous = self._settings
self._settings = settings
self._widget.set_settings(settings)
gain_changed = (
needs_recompose = (
previous.display_gain != settings.display_gain
or previous.deform_enabled != settings.deform_enabled
or previous.field != settings.field
or previous.flow_arrows_enabled != settings.flow_arrows_enabled
or previous.contact_arrow_enabled != settings.contact_arrow_enabled
or previous.flow_on_px != settings.flow_on_px
or previous.flow_off_px != settings.flow_off_px
or previous.flow_grid_spacing != settings.flow_grid_spacing
or previous.max_arrows != settings.max_arrows
)
if gain_changed and self._assets is not None:
# The scalar is untouched by the gain, but the *positions* are not, so
# the last field frame has to be composed again.
if needs_recompose and self._assets is not None:
# Gain moves positions; overlay toggles rebuild arrow instances on the
# held field frame so the operator sees the change without waiting.
self._settings_dirty = True
def show_rest(self) -> None:
@@ -72,21 +72,21 @@ def configure_surface_format(profile: Profile = "desktop33") -> None:
component must not slow down (the acquisition loop feeds the scene through a
capacity-one mailbox), and the design document defers anti-aliasing work.
"""
from PyQt5.QtGui import QSurfaceFormat
from PySide6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat()
if profile == "gles30":
fmt.setRenderableType(QSurfaceFormat.OpenGLES)
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES)
fmt.setVersion(3, 0)
elif profile == "desktop33":
fmt.setRenderableType(QSurfaceFormat.OpenGL)
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
fmt.setVersion(3, 3)
fmt.setProfile(QSurfaceFormat.CoreProfile)
fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
else: # pragma: no cover - a typo must not reach a board silently
raise ValueError(f"unknown GL profile: {profile!r}")
fmt.setDepthBufferSize(24)
fmt.setStencilBufferSize(8)
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSamples(0)
QSurfaceFormat.setDefaultFormat(fmt)
@@ -190,6 +190,31 @@ def shader_header(profile: Profile) -> str:
raise ValueError(f"unknown GL profile: {profile!r}") from None
_ARROW_VERTEX_BODY = """
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec4 a_color;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec4 v_color;
void main() {
v_color = a_color;
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
}
"""
_ARROW_FRAGMENT_BODY = """
in vec4 v_color;
out vec4 frag_color;
void main() {
frag_color = v_color;
}
"""
def shader_source(profile: Profile, program: str) -> tuple[str, str]:
"""``(vertex, fragment)`` source for a profile and a program name.
@@ -199,19 +224,23 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
header = shader_header(profile)
if program == "scalar":
body = _SCALAR_FRAGMENT_BODY
vertex = _VERTEX_BODY
elif program == "diffuse":
body = _DIFFUSE_FRAGMENT_BODY
vertex = _VERTEX_BODY
elif program == "arrow":
return header + _ARROW_VERTEX_BODY, header + _ARROW_FRAGMENT_BODY
else:
raise ValueError(f"unknown shader program: {program!r}")
return header + _VERTEX_BODY, header + body
return header + vertex, header + body
def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
"""The range of the *valid* samples, or ``None`` when there are none.
"""Robust P5/P95 range of *valid* samples, or ``None`` when there are none.
Only valid and finite samples count. Including the invalid ones would make the
colour range follow whatever garbage sits outside the UV island, and including
NaN would produce a range of NaN and a picture with no colours at all.
Using min/max made empty-field noise paint the whole LUT. A percentile span
with a minimum width keeps weak signals visible without amplifying floor
noise into a full contact.
"""
import numpy as np
@@ -222,9 +251,39 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
usable = values[mask & np.isfinite(values)]
if usable.size == 0:
return None
return float(usable.min()), float(usable.max())
if usable.size < 8:
lower = float(usable.min())
upper = float(usable.max())
else:
lower = float(np.percentile(usable, 5.0))
upper = float(np.percentile(usable, 95.0))
# Minimum span: avoid collapsing nearly-constant fields into one colour.
span = upper - lower
min_span = max(abs(lower) * 0.05, abs(upper) * 0.05, 1e-3)
if span < min_span:
mid = 0.5 * (lower + upper)
lower = mid - 0.5 * min_span
upper = mid + 0.5 * min_span
return lower, upper
def _blend_scale_range(
previous: Optional[tuple[float, float]],
current: Optional[tuple[float, float]],
*,
alpha: float = 0.15,
) -> Optional[tuple[float, float]]:
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
if current is None:
return previous
if previous is None:
return current
a = float(np.clip(alpha, 0.0, 1.0))
return (
(1.0 - a) * float(previous[0]) + a * float(current[0]),
(1.0 - a) * float(previous[1]) + a * float(current[1]),
)
def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]:
"""The colour range to draw with: the operator's, or the data's own.
@@ -383,7 +442,10 @@ class GLDispatch:
if context is None or not context.isValid():
raise error("gl_unsupported", "no current OpenGL context")
fmt = context.format()
profile = profile_from_renderable_type(int(fmt.renderableType()))
# PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises TypeError, so read
# `.value`. (PyQt5 exposed them as ints. Getting this wrong made every GL probe raise,
# the 3D pane report "unsupported", and the GL smoke tests skip instead of running.)
profile = profile_from_renderable_type(int(fmt.renderableType().value))
try:
vendor = _gl_string(GL, GL.GL_VENDOR)
renderer = _gl_string(GL, GL.GL_RENDERER)
@@ -395,7 +457,7 @@ class GLDispatch:
"gl_unsupported",
f"the context answered no queries: {type(exc).__name__}: {exc}",
) from exc
from PyQt5.QtGui import QGuiApplication
from PySide6.QtGui import QGuiApplication
platform_name = ""
try:
@@ -514,6 +576,10 @@ class GLRenderer:
self._initialized = False
self._paint_calls = 0
self._last_upload_ms = 0.0
#: Overlay VAOs keyed by kind ("flow" / "contact").
self._overlay_vaos: dict[str, tuple[int, int, int, int]] = {}
self._arrow_program: int = 0
self._arrow_uniforms: dict[str, int] = {}
# ------------------------------------------------------------------ lifetime
def initialize(self, capabilities: GLCapabilities) -> None:
@@ -529,6 +595,7 @@ class GLRenderer:
try:
self._programs["scalar"] = _compile_program(GL, capabilities.profile, "scalar")
self._programs["diffuse"] = _compile_program(GL, capabilities.profile, "diffuse")
self._programs["arrow"] = _compile_program(GL, capabilities.profile, "arrow")
except Exception as exc: # noqa: BLE001
self.release()
raise error(
@@ -604,7 +671,74 @@ class GLRenderer:
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
self._scalar_range = _valid_scalar_range(frame.scalar, frame.valid)
measured = _valid_scalar_range(frame.scalar, frame.valid)
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
self._upload_overlays(frame)
def _upload_overlays(self, frame: RenderFrame) -> None:
"""Upload merged flow / contact arrow meshes for this frame."""
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
for kind, instances in (
("flow", getattr(frame, "flow_arrows", None)),
("contact", getattr(frame, "contact_arrow", None)),
):
if instances is None or int(instances.origins.shape[0]) == 0:
self._clear_overlay(kind)
continue
positions, triangles, colors = arrow_instances_to_mesh(instances)
if positions.size == 0:
self._clear_overlay(kind)
continue
self._set_overlay(kind, positions, triangles, colors)
def _set_overlay(
self,
kind: str,
positions: np.ndarray,
triangles: np.ndarray,
colors: np.ndarray,
) -> None:
from OpenGL import GL
self._clear_overlay(kind)
count = int(positions.shape[0])
interleaved = np.empty((count, 7), dtype=np.float32)
interleaved[:, 0:3] = positions
interleaved[:, 3:7] = colors
vao = int(GL.glGenVertexArrays(1))
vbo = int(GL.glGenBuffers(1))
ebo = int(GL.glGenBuffers(1))
GL.glBindVertexArray(vao)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo)
GL.glBufferData(
GL.GL_ARRAY_BUFFER, interleaved.nbytes, interleaved, GL.GL_DYNAMIC_DRAW
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER, triangles.nbytes, triangles, GL.GL_DYNAMIC_DRAW
)
stride = 7 * 4
GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
GL.glEnableVertexAttribArray(1)
GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
GL.glBindVertexArray(0)
self._overlay_vaos[kind] = (vao, vbo, ebo, int(triangles.size))
def _clear_overlay(self, kind: str) -> None:
from OpenGL import GL
entry = self._overlay_vaos.pop(kind, None)
if entry is None:
return
vao, vbo, ebo, _ = entry
try:
GL.glDeleteVertexArrays(1, [int(vao)])
GL.glDeleteBuffers(1, [int(vbo)])
GL.glDeleteBuffers(1, [int(ebo)])
except Exception: # noqa: BLE001
pass
def draw(
self,
@@ -659,6 +793,27 @@ class GLRenderer:
GL.GL_TRIANGLES, part.triangles * 3, GL.GL_UNSIGNED_INT, None
)
GL.glBindVertexArray(0)
# Overlays after the surface so arrows stay visible.
arrow_program = self._programs.get("arrow")
if arrow_program and self._overlay_vaos:
locations = self._uniforms.get("arrow", {})
GL.glUseProgram(arrow_program)
_set_mat4(GL, locations.get("u_view"), view)
_set_mat4(GL, locations.get("u_projection"), projection)
GL.glDisable(GL.GL_DEPTH_TEST)
for kind in ("flow", "contact"):
entry = self._overlay_vaos.get(kind)
if entry is None:
continue
vao, _vbo, _ebo, index_count = entry
if index_count <= 0:
continue
GL.glBindVertexArray(vao)
GL.glDrawElements(
GL.GL_TRIANGLES, int(index_count), GL.GL_UNSIGNED_INT, None
)
GL.glBindVertexArray(0)
GL.glEnable(GL.GL_DEPTH_TEST)
GL.glUseProgram(0)
self._paint_calls += 1
return RenderStats(
@@ -687,6 +842,8 @@ class GLRenderer:
from OpenGL import GL
self.release_parts()
for kind in list(self._overlay_vaos):
self._clear_overlay(kind)
for program in self._programs.values():
try:
GL.glDeleteProgram(int(program))
+43 -13
View File
@@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Optional
import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QImage
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QImage
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
@@ -87,7 +87,7 @@ class ColorScaleBar(QLabel):
self.setObjectName("colorScaleBar")
self.setFixedHeight(12)
self.setMinimumWidth(120)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setVisible(False)
self._lut_id = "sequential"
@@ -99,14 +99,14 @@ class ColorScaleBar(QLabel):
self.update()
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
from PyQt5.QtGui import QPainter
from PySide6.QtGui import QPainter
from orisys_studio.scene3d.gl_backend import lut_rgba
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
# which is what QImage's borrowed buffer needs.
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format_RGBA8888)
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
painter = QPainter(self)
painter.drawImage(self.rect(), image)
painter.end()
@@ -115,10 +115,10 @@ class ColorScaleBar(QLabel):
class ThreeDPanel(QFrame):
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
enabledChanged = pyqtSignal(bool)
settingsChanged = pyqtSignal(object) # DisplaySettings
resetRequested = pyqtSignal()
presetRequested = pyqtSignal(str)
enabledChanged = Signal(bool)
settingsChanged = Signal(object) # DisplaySettings
resetRequested = Signal()
presetRequested = Signal(str)
def __init__(
self,
@@ -222,6 +222,16 @@ class ThreeDPanel(QFrame):
self.field_note.setVisible(False)
form.addRow(self.field_note)
self.flow_arrows_check = QCheckBox()
self.flow_arrows_check.setChecked(True)
self.flow_arrows_check.toggled.connect(self._emit_settings)
form.addRow(self.flow_arrows_check)
self.contact_arrow_check = QCheckBox()
self.contact_arrow_check.setChecked(True)
self.contact_arrow_check.toggled.connect(self._emit_settings)
form.addRow(self.contact_arrow_check)
self.scale_label = QLabel()
self.scale_combo = QComboBox()
self.scale_combo.addItem("fixed", "fixed")
@@ -289,13 +299,13 @@ class ThreeDPanel(QFrame):
scale_row.setSpacing(6)
self.scale_lower_label = QLabel()
self.scale_lower_label.setObjectName("panelMeta")
self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.scale_lower_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
scale_row.addWidget(self.scale_lower_label)
self.scale_bar = ColorScaleBar()
scale_row.addWidget(self.scale_bar, 1)
self.scale_upper_label = QLabel()
self.scale_upper_label.setObjectName("panelMeta")
self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.scale_upper_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
scale_row.addWidget(self.scale_upper_label)
root.addLayout(scale_row)
@@ -317,7 +327,7 @@ class ThreeDPanel(QFrame):
self.source_label = ShrinkableLabel()
self.source_label.setObjectName("panelMeta")
self.source_label.setWordWrap(False)
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
legend_row.addWidget(self.source_label, 2)
root.addLayout(legend_row)
@@ -403,6 +413,18 @@ class ThreeDPanel(QFrame):
self.deform_check.setChecked(False)
finally:
self._syncing = False
flow_ok = bool(capabilities and capabilities.flow_arrows_available)
contact_ok = bool(capabilities and capabilities.contact_arrow_available)
self.flow_arrows_check.setEnabled(flow_ok and enabled)
self.contact_arrow_check.setEnabled(contact_ok and enabled)
self.flow_arrows_check.setToolTip(
t("scene3d.flow_arrows_unavailable") if not flow_ok else t("scene3d.flow_arrows_tip")
)
self.contact_arrow_check.setToolTip(
t("scene3d.contact_arrow_unavailable")
if not contact_ok
else t("scene3d.contact_arrow_tip")
)
def capabilities(self) -> Optional[SceneCapabilities]:
return self._capabilities
@@ -604,6 +626,8 @@ class ThreeDPanel(QFrame):
self.housing_check,
self.deform_check,
self.gain_spin,
self.flow_arrows_check,
self.contact_arrow_check,
self.scale_combo,
self.fps_combo,
self.reset_button,
@@ -635,6 +659,8 @@ class ThreeDPanel(QFrame):
index = self.field_combo.findData(str(settings.field))
if index >= 0:
self.field_combo.setCurrentIndex(index)
self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled))
self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled))
scale = settings.color_scale
if scale is not None:
index = self.scale_combo.findData(str(scale.mode))
@@ -678,6 +704,8 @@ class ThreeDPanel(QFrame):
housing_visible=self.housing_check.isChecked(),
max_fps=int(self.fps_combo.currentData()),
field=self.field(),
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
)
# ------------------------------------------------------------------ signals
@@ -730,6 +758,8 @@ class ThreeDPanel(QFrame):
self.field_combo.setItemText(0, t("scene3d.field_depth"))
self.field_combo.setItemText(1, t("scene3d.field_flow"))
self._render_field_note()
self.flow_arrows_check.setText(t("scene3d.flow_arrows"))
self.contact_arrow_check.setText(t("scene3d.contact_arrow"))
self.scale_label.setText(t("scene3d.scale"))
self.range_label.setText(t("scene3d.scale_range"))
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
@@ -80,6 +80,12 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
"housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps),
"field": str(settings.field),
"flow_arrows_enabled": bool(settings.flow_arrows_enabled),
"contact_arrow_enabled": bool(settings.contact_arrow_enabled),
"flow_on_px": float(settings.flow_on_px),
"flow_off_px": float(settings.flow_off_px),
"flow_grid_spacing": int(settings.flow_grid_spacing),
"max_arrows": int(settings.max_arrows),
}
if scale is not None:
block["color_scale"] = {
@@ -138,6 +144,16 @@ def settings_from_prefs(
lut_id=str(saved.get("lut_id") or "sequential"),
mode=mode, # type: ignore[arg-type]
)
flow_on = max(0.0, _number(raw.get("flow_on_px"), 0.4))
flow_off = max(0.0, _number(raw.get("flow_off_px"), 0.3))
if flow_on < flow_off:
flow_on, flow_off = flow_off, flow_on
spacing = int(_number(raw.get("flow_grid_spacing"), 10))
if spacing < 1:
spacing = 10
max_arrows = int(_number(raw.get("max_arrows"), 400))
if max_arrows < 1:
max_arrows = 400
return DisplaySettings(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
@@ -145,4 +161,10 @@ def settings_from_prefs(
housing_visible=_flag(raw.get("housing_visible"), True),
max_fps=max_fps,
field=field,
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
contact_arrow_enabled=_flag(raw.get("contact_arrow_enabled"), True),
flow_on_px=flow_on,
flow_off_px=flow_off,
flow_grid_spacing=spacing,
max_arrows=max_arrows,
)
@@ -16,8 +16,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QLabel,
@@ -60,7 +60,7 @@ class Scene3dSettingsDialog(QDialog):
root.addWidget(panel.controls_widget)
panel.controls_widget.setVisible(True)
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
self.buttons.rejected.connect(self.close)
self.buttons.accepted.connect(self.close)
root.addWidget(self.buttons)
@@ -72,7 +72,7 @@ class Scene3dSettingsDialog(QDialog):
self.hint.setText(t("scene3d.settings_hint"))
# Qt ships its own translations for a button box, and the application installs
# none: without this the dialog ends in an English "Close" among Chinese labels.
close_button = self.buttons.button(QDialogButtonBox.Close)
close_button = self.buttons.button(QDialogButtonBox.StandardButton.Close)
if close_button is not None:
close_button.setText(t("dialog.close"))
@@ -84,7 +84,7 @@ class Scene3dSettingsDialog(QDialog):
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
"""Esc closes the dialog, as it does everywhere else in this application."""
if event.key() == Qt.Key_Escape:
if event.key() == Qt.Key.Key_Escape:
self.hide()
return
super().keyPressEvent(event)
+14 -10
View File
@@ -28,8 +28,8 @@ import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Optional
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QVBoxLayout, QWidget
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QVBoxLayout, QWidget
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
@@ -71,9 +71,9 @@ def gl_available() -> tuple[bool, str]:
pane on a machine where PyOpenGL is not installed.
"""
try:
from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401
from PySide6.QtOpenGLWidgets import QOpenGLWidget # noqa: F401
except Exception as exc: # noqa: BLE001
return False, f"PyQt5 OpenGL widgets are unavailable: {exc}"
return False, f"PySide6 OpenGL widgets are unavailable: {exc}"
try:
import OpenGL # noqa: F401
except Exception as exc: # noqa: BLE001
@@ -84,11 +84,11 @@ def gl_available() -> tuple[bool, str]:
class Scene3DView(QWidget):
"""One 3D pane: the GL widget on top, its controls underneath."""
stateChanged = pyqtSignal(object) # SceneState
diagnosticRaised = pyqtSignal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
enabledChanged = pyqtSignal(bool)
stateChanged = Signal(object) # SceneState
diagnosticRaised = Signal(object) # Diagnostic
renderStatsChanged = Signal(object) # RenderStats
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
enabledChanged = Signal(bool)
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
@@ -415,8 +415,10 @@ class Scene3DView(QWidget):
"""
adapter = self._adapter
descriptor = self._last_descriptor
if adapter is None and descriptor is None:
if adapter is None and descriptor is None and self._assets is None:
return None
mapping = getattr(self._assets, "surface_mapping", None) if self._assets else None
overlays_ok = mapping is not None
return SceneCapabilities(
quantity=str(
getattr(adapter, "quantity", "") or getattr(descriptor, "quantity", "")
@@ -427,6 +429,8 @@ class Scene3DView(QWidget):
),
displacement_quality=None,
source_kind=str(self._source_kind),
flow_arrows_available=overlays_ok,
contact_arrow_available=overlays_ok,
)
def _publish_capabilities(self) -> None:
+23 -16
View File
@@ -19,9 +19,11 @@ from __future__ import annotations
import time
from typing import Optional
from PyQt5.QtCore import QEvent, Qt, pyqtSignal
from PyQt5.QtGui import QMouseEvent, QWheelEvent
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
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,
@@ -48,13 +50,13 @@ STATS_INTERVAL_S = 0.5
class Sensor3DWidget(QOpenGLWidget):
"""Renders one scene. Signals carry state, never per-frame arrays."""
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
diagnosticRaised = pyqtSignal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
capabilitiesChanged = Signal(object) # GLCapabilities
diagnosticRaised = Signal(object) # Diagnostic
renderStatsChanged = Signal(object) # RenderStats, rate-limited
#: What the frame on screen claims to be — quantity, unit, quality. The panel
#: labels the colour bar from this and nothing else, so an unmeasured surface
#: cannot inherit the previous frame's range.
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
def __init__(
self,
@@ -259,14 +261,19 @@ class Sensor3DWidget(QOpenGLWidget):
When the platform cannot create a GL context (the offscreen plugin, a broken
driver, a board without EGL) ``initializeGL`` is simply never called: there is
no exception to catch, and the widget would sit there looking like a working
view that has no data yet. Qt does answer the question, though — ``context()``
stays ``None`` — so the first paint asks it and turns "no context" into the
state the rest of the application already knows how to show.
view that has no data yet. Qt does answer the question, though, so the first
paint asks it and turns "no context" into the state the rest of the application
already knows how to show.
Under Qt5 that answer was ``context() is None``. Qt6 returns an *invalid*
context object instead, so the check has to be ``isValid()`` as well — comparing
against ``None`` alone silently stops reporting a broken GL stack.
"""
super().paintEvent(event)
if self._unavailable or self._capabilities is not None:
return
if self.context() is None:
context = self.context()
if context is None or not context.isValid():
self._fail(
diagnostic(
"gl_unsupported",
@@ -305,7 +312,7 @@ class Sensor3DWidget(QOpenGLWidget):
# --------------------------------------------------------------- interaction
def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover
self._last_press = (float(event.x()), float(event.y()))
self._last_press = (float(event.position().x()), float(event.position().y()))
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
@@ -316,17 +323,17 @@ class Sensor3DWidget(QOpenGLWidget):
if self._last_press is None:
super().mouseMoveEvent(event)
return
x, y = float(event.x()), float(event.y())
x, y = float(event.position().x()), float(event.position().y())
delta = (x - self._last_press[0], y - self._last_press[1])
self._last_press = (x, y)
viewport = (max(self.width(), 1), max(self.height(), 1))
buttons = event.buttons()
panning = bool(buttons & Qt.MiddleButton) or bool(
buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier
panning = bool(buttons & Qt.MouseButton.MiddleButton) or bool(
buttons & Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier
)
if panning:
self._camera.pan(delta, viewport)
elif buttons & Qt.LeftButton:
elif buttons & Qt.MouseButton.LeftButton:
self._camera.orbit(delta, viewport)
self.update()
@@ -26,7 +26,7 @@ import logging
import time
from typing import Any, Callable, Optional
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
from PySide6.QtCore import QObject, QTimer, Signal
from orisys_app_shared.i18n import t
from orisys_studio import session_metadata
@@ -47,11 +47,11 @@ class SessionController(QObject):
"""Owns the acquisition session's state and its UI-free mechanics."""
#: The run timer fired: the window's presenter slot does the painting.
tick = pyqtSignal()
tick = Signal()
#: The phase was (re)applied, not only changed: the status chip repaints from it,
#: and an idempotent repaint is cheaper than a second source of truth.
phaseChanged = pyqtSignal(str)
runningChanged = pyqtSignal(bool)
phaseChanged = Signal(str)
runningChanged = Signal(bool)
def __init__(
self,
@@ -2,9 +2,9 @@
from __future__ import annotations
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
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
@@ -24,19 +24,19 @@ class AboutDialog(QDialog):
logo = QLabel()
pix = QPixmap(str(logo_topbar_path()))
if not pix.isNull():
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
logo.setPixmap(pix.scaledToHeight(48, Qt.TransformationMode.SmoothTransformation))
row.addWidget(logo)
row.addStretch(1)
root.addLayout(row)
self.body = QLabel()
self.body.setWordWrap(True)
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
root.addWidget(self.body)
close_btn = QPushButton("OK")
close_btn.clicked.connect(self.accept)
root.addWidget(close_btn, alignment=Qt.AlignRight)
root.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
self.retranslate()
def retranslate(self) -> None:
+12 -12
View File
@@ -12,9 +12,9 @@ from __future__ import annotations
from typing import Optional
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
from PyQt5.QtWidgets import (
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
@@ -48,7 +48,7 @@ def bgr_to_qimage(frame) -> QImage:
"""
rgb = frame[:, :, ::-1].copy()
height, width, channels = rgb.shape
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
return QImage(rgb.data, width, height, channels * width, QImage.Format.Format_RGB888).copy()
_VIEW_LABELS = {
@@ -92,7 +92,7 @@ class AspectImageLabel(QLabel):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._aspect = DEFAULT_FRAME_ASPECT
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
policy.setHeightForWidth(True)
self.setSizePolicy(policy)
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
@@ -132,7 +132,7 @@ class ColorBarLabel(QLabel):
self.setObjectName("colorBar")
self.setFixedHeight(_COLORBAR_HEIGHT)
self.setMinimumWidth(_COLORBAR_WIDTH)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setVisible(False)
def set_theme(self, theme: str) -> None:
@@ -145,14 +145,14 @@ class ColorBarLabel(QLabel):
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
# the whole call, which is what QImage's borrowed buffer needs.
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888)
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format.Format_RGB888)
painter = QPainter(self)
painter.drawImage(self.rect(), image)
painter.end()
class ArrowPanel(QFrame):
view_mode_changed = pyqtSignal(str)
view_mode_changed = Signal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -188,7 +188,7 @@ class ArrowPanel(QFrame):
layout.addLayout(header)
self.image_label = AspectImageLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setScaledContents(False)
self.image_label.setWordWrap(True)
# The image keeps the frame's shape and is centred in whatever height is
@@ -515,11 +515,11 @@ class ArrowPanel(QFrame):
deg = self._effective_rotation_deg()
if deg:
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation)
pix = pix.transformed(QTransform().rotate(-deg), Qt.TransformationMode.FastTransformation)
scaled = pix.scaled(
self.image_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.image_label.setPixmap(scaled)
self.image_label.setText("")
+12 -12
View File
@@ -14,9 +14,9 @@ from __future__ import annotations
import logging
from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QPainter, QPixmap
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
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
@@ -39,13 +39,13 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap
if source.isNull():
return QPixmap()
scaled = source.scaled(
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation
size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
)
out = QPixmap(scaled.size())
out.fill(Qt.transparent)
out.fill(Qt.GlobalColor.transparent)
painter = QPainter(out)
painter.drawPixmap(0, 0, scaled)
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(out.rect(), QColor(tint))
painter.end()
return out
@@ -55,11 +55,11 @@ class BrandBar(QFrame):
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
#: The user asked for the live view.
live_requested = pyqtSignal()
live_requested = Signal()
#: The user asked for the history/replay view.
playback_requested = pyqtSignal()
playback_requested = Signal()
#: The user asked for the calibration view.
calibration_requested = pyqtSignal()
calibration_requested = Signal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -74,7 +74,7 @@ class BrandBar(QFrame):
self.logo = QLabel()
self.logo.setObjectName("brandLogo")
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
self.logo.setAlignment(Qt.AlignCenter)
self.logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.logo)
# The lockup keeps its two labels (the product's tagline stays available
@@ -113,7 +113,7 @@ class BrandBar(QFrame):
self.product = QLabel()
self.product.setObjectName("brandProduct")
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.product.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(self.product)
self.set_theme(theme)
@@ -123,7 +123,7 @@ class BrandBar(QFrame):
def _nav_button(self) -> QPushButton:
button = QPushButton()
button.setObjectName("navButton")
button.setCursor(Qt.PointingHandCursor)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setFlat(True)
button.setCheckable(True)
return button
+19 -19
View File
@@ -3,9 +3,9 @@
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
@@ -60,11 +60,11 @@ def _mono_font() -> str:
class CalPanel(QFrame):
status_message = pyqtSignal(str)
status_message = Signal(str)
#: Coefficients were written to disk. It does **not** mean they were applied.
calibration_exported = pyqtSignal(str)
calibration_exported = Signal(str)
#: The operator asked to push the last export into the running device.
apply_requested = pyqtSignal(str)
apply_requested = Signal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -97,9 +97,9 @@ class CalPanel(QFrame):
outer.setSpacing(0)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
content = QWidget()
self.scroll.setWidget(content)
outer.addWidget(self.scroll)
@@ -135,7 +135,7 @@ class CalPanel(QFrame):
form = QFormLayout()
form.setHorizontalSpacing(10)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._weight_label = QLabel()
self._weight_label.setObjectName("fieldLabel")
self.weight_spin = QDoubleSpinBox()
@@ -182,16 +182,16 @@ class CalPanel(QFrame):
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
# stability measure and the point's state.
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setAlternatingRowColors(True)
self.table.setShowGrid(False)
header = self.table.horizontalHeader()
# Every column takes the width its own header and cells need: eliding a
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
# panel scrolls instead.
header.setSectionResizeMode(QHeaderView.ResizeToContents)
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
header.setMinimumSectionSize(64)
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
@@ -235,7 +235,7 @@ class CalPanel(QFrame):
):
# The action row must survive a narrow three-panel layout.
button.setProperty("compact", "true")
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
self.btn_delete.clicked.connect(self._on_delete_row)
@@ -523,11 +523,11 @@ class CalPanel(QFrame):
)
for column, text in enumerate(values):
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
if column in (0, 1, 2, 3):
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
item.setTextAlignment(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
else:
item.setTextAlignment(int(Qt.AlignCenter))
item.setTextAlignment(int(Qt.AlignmentFlag.AlignCenter))
if point.excluded:
item.setForeground(QColor(color(self._theme, "text_muted")))
elif point.is_validation:
@@ -611,7 +611,7 @@ class CalPanel(QFrame):
"""Double click a row to inspect that point's three-axis curve."""
dialog = self.point_dialog(self._current_point())
if dialog is not None:
dialog.exec_()
dialog.exec()
def _on_toggle_excluded(self) -> None:
point = self._current_point()
@@ -7,9 +7,9 @@ the three-panel layout, while a single point can still be examined in full.
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
@@ -103,7 +103,7 @@ class CalibrationPointDialog(QDialog):
root.addWidget(self.plot_widget, 1)
self._build_traces()
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
+3 -3
View File
@@ -9,8 +9,8 @@ clips the text and the tooltip remains the way to read it.
from __future__ import annotations
from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QLabel, QSizePolicy
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QLabel, QSizePolicy
class ShrinkableLabel(QLabel):
@@ -18,7 +18,7 @@ class ShrinkableLabel(QLabel):
def __init__(self, text: str = "", parent=None) -> None:
super().__init__(text, parent)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
self.setMinimumWidth(0)
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
+108 -20
View File
@@ -5,9 +5,9 @@ from __future__ import annotations
import time
import numpy as np
from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
@@ -18,6 +18,7 @@ from PyQt5.QtWidgets import (
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import ForcePresentation
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
@@ -70,8 +71,12 @@ def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
def format_force(value: float, unit: str) -> str:
def format_force(
value: float, unit: str, *, presentation: ForcePresentation | None = None
) -> str:
"""Render one force reading for the value cards."""
if presentation is not None:
return presentation.format_force(value, unit=unit)
if unit == "N":
return f"{value:.3f}"
return f"{value:.1f}"
@@ -111,18 +116,18 @@ class _Sync:
class ForcePlotsPanel(QFrame):
#: The visible time window changed while browsing history.
window_changed = pyqtSignal(float, float)
window_changed = Signal(float, float)
#: The shared cursor moved; other views sync their playback position to it.
cursor_moved = pyqtSignal(float)
cursor_moved = Signal(float)
#: "follow" (live) or "browse" (user-controlled window).
mode_changed = pyqtSignal(str)
mode_changed = Signal(str)
#: The unit chip was clicked: switch between newton and raw.
unit_toggle_requested = pyqtSignal()
unit_toggle_requested = Signal()
#: The gear was clicked: open the plot settings (window, range, filters).
plot_settings_requested = pyqtSignal()
plot_settings_requested = Signal()
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
#: or moved the overview marker): playback stops where it is.
timeline_taken_over = pyqtSignal()
timeline_taken_over = Signal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -137,6 +142,11 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for Finger raw value cards.
self.presentation = ForcePresentation(enabled=False)
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
# -- browsing state -------------------------------------------------
#: Where the curves read from: the in-memory session store here, a
#: recording on disk in the replay. One interface either way.
@@ -207,7 +217,7 @@ class ForcePlotsPanel(QFrame):
self.unit_badge = QPushButton()
self.unit_badge.setObjectName("unitBadge")
self.unit_badge.setProperty("compact", "true")
self.unit_badge.setCursor(Qt.PointingHandCursor)
self.unit_badge.setCursor(Qt.CursorShape.PointingHandCursor)
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
header.addWidget(self.unit_badge)
root.addLayout(header)
@@ -319,7 +329,7 @@ class ForcePlotsPanel(QFrame):
self.window_label.setObjectName("panelMeta")
self.stats_label = QLabel()
self.stats_label.setObjectName("panelMeta")
self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.stats_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
browse_row.addWidget(self.window_caption)
browse_row.addWidget(self.window_label)
browse_row.addStretch(1)
@@ -338,7 +348,7 @@ class ForcePlotsPanel(QFrame):
button.setProperty("iconOnly", "true")
button.setIcon(icons.icon(icon_name, self._theme))
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
button.setCursor(Qt.PointingHandCursor)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
return button
@@ -379,7 +389,7 @@ class ForcePlotsPanel(QFrame):
value_row.setSpacing(5)
value_label = QLabel()
value_label.setObjectName("valueCard")
value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
value_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
value_label.setFont(_mono_font(16, bold=True))
# Never a fixed height: the assertion that a value is not clipped is
# derived from the font it is actually drawn with.
@@ -484,7 +494,9 @@ class ForcePlotsPanel(QFrame):
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._display_values = (0.0, 0.0, 0.0)
self._available = (True, True, True)
self.presentation.reset_session(unit=self._unit)
self._unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
@@ -500,6 +512,49 @@ class ForcePlotsPanel(QFrame):
self._apply_axis_mode()
self._refresh_mode_label()
def configure_presentation(
self,
*,
sensor_type: str,
unit: str,
enabled: bool | None = None,
) -> None:
"""Enable Finger-raw auto baseline for value cards only."""
self._sensor_type = str(sensor_type or "box")
use = (
bool(enabled)
if enabled is not None
else (self._sensor_type == "finger" and unit != "N")
)
self.presentation.enabled = use
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display cards only)."""
self.presentation.request_reestimate()
self._display_values = self._last_values
self._render_value_cards()
def observe_for_baseline(
self,
values: tuple[float, float, float],
*,
t_rel_s: float,
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
) -> None:
"""Feed the startup estimator; curves keep the raw measurement."""
if not self.presentation.enabled:
return
self.presentation.observe(
values,
t_rel_s=t_rel_s,
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
)
def push(
self,
t_rel_s: float,
@@ -509,6 +564,9 @@ class ForcePlotsPanel(QFrame):
*,
unit: str = "raw",
available: tuple[bool, bool, bool] | None = None,
flow_p95: float | None = None,
contact_found: bool = False,
forces_valid: bool = True,
) -> None:
"""Append one live sample.
@@ -532,6 +590,18 @@ class ForcePlotsPanel(QFrame):
self._last_values = (float(fn), float(fx), float(fy))
if available is not None:
self._available = tuple(bool(flag) for flag in available)
self.observe_for_baseline(
self._last_values,
t_rel_s=float(t_rel_s),
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
)
self._display_values = displayed.values
self._display_suppressed = displayed.suppressed
if unit != self._unit:
# A unit change mid-stream is a *switch*, never a reinterpretation:
# the buffered values were measured in the old unit and must not be
@@ -1182,6 +1252,8 @@ class ForcePlotsPanel(QFrame):
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled and self.presentation.ready:
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
# presented as a newton value.
@@ -1192,14 +1264,30 @@ class ForcePlotsPanel(QFrame):
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
)
continue
value_label.setText(fmt.format(value=value))
value_label.setToolTip(
t("chart.selection_peak_tip") if selecting is not None else ""
value_label.setText(
format_force(value, self._unit, presentation=self.presentation)
if selecting is None
else fmt.format(value=value)
)
tip = t("chart.selection_peak_tip") if selecting is not None else ""
zero_zone = (
selecting is None
and self.presentation.enabled
and self.presentation.ready
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
)
if zero_zone:
tip = t("chart.zero_zone_tip")
value_label.setToolTip(tip)
unit_label.setText(unit_display(self._unit))
detail_label.setText(
self._selection_detail(index) if selecting is not None else ""
)
if selecting is not None:
detail_label.setText(self._selection_detail(index))
elif zero_zone:
detail_label.setText(t("chart.zero_zone"))
else:
detail_label.setText("")
self.unit_badge.setText(unit_display(self._unit))
def _selection_detail(self, index: int) -> str:
+8 -8
View File
@@ -14,8 +14,8 @@ from __future__ import annotations
from functools import lru_cache
from PyQt5.QtCore import QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color
@@ -50,7 +50,7 @@ def _paint_zoom_fit(painter: QPainter, size: int) -> None:
def _paint_box_zoom(painter: QPainter, size: int) -> None:
"""A dashed frame: drag a region to zoom into it."""
pen = painter.pen()
pen.setStyle(Qt.DashLine)
pen.setStyle(Qt.PenStyle.DashLine)
pen.setDashPattern([2.2, 1.6])
painter.setPen(pen)
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
@@ -143,19 +143,19 @@ _PAINTERS = {
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
canvas = QPixmap(size, size)
canvas.fill(Qt.transparent)
canvas.fill(Qt.GlobalColor.transparent)
paint = _PAINTERS.get(str(name))
if paint is None:
return canvas
painter = QPainter(canvas)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen()
pen.setWidthF(_PEN_WIDTH)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
pen.setColor(QColor(tint))
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
painter.setBrush(Qt.BrushStyle.NoBrush)
try:
paint(painter, size)
finally:
@@ -16,8 +16,8 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtWidgets import (
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
@@ -78,7 +78,7 @@ class Mesh3dPanel(QFrame):
#: Emitted when the operator turns the pane on. The window wires the packet sink
#: from this, so no packet is adapted while the pane is off.
enabledChanged = pyqtSignal(bool)
enabledChanged = Signal(bool)
def __init__(
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
@@ -128,14 +128,14 @@ class Mesh3dPanel(QFrame):
self.btn_reset_view = QPushButton()
self.btn_reset_view.setProperty("compact", "true")
self.btn_reset_view.setProperty("iconOnly", "true")
self.btn_reset_view.setCursor(Qt.PointingHandCursor)
self.btn_reset_view.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_reset_view.clicked.connect(self.reset_view)
header.addWidget(self.btn_reset_view)
self.btn_settings = QPushButton()
self.btn_settings.setProperty("compact", "true")
self.btn_settings.setProperty("iconOnly", "true")
self.btn_settings.setCursor(Qt.PointingHandCursor)
self.btn_settings.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_settings.clicked.connect(self.open_settings)
header.addWidget(self.btn_settings)
root.addLayout(header)
@@ -145,7 +145,7 @@ class Mesh3dPanel(QFrame):
self.message = QLabel()
self.message.setObjectName("hintText")
self.message.setAlignment(Qt.AlignCenter)
self.message.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.message.setWordWrap(True)
self._stack.addWidget(self.message)
@@ -9,7 +9,7 @@ reports as done is what the record will say (see ``force/filters.py``).
from __future__ import annotations
from PyQt5.QtWidgets import (
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
@@ -133,7 +133,7 @@ class PlotSettingsDialog(QDialog):
self.btn_reset.clicked.connect(self._reset_defaults)
buttons_row.addWidget(self.btn_reset)
buttons_row.addStretch(1)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
buttons_row.addWidget(self.button_box)
@@ -384,8 +384,8 @@ class PlotSettingsDialog(QDialog):
self.rate_label.setText(t("dialog.filter_rate"))
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
self._refresh_filter_note()
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
if ok_btn is not None:
ok_btn.setText(t("dialog.ok"))
if cancel_btn is not None:
@@ -14,7 +14,7 @@ timer without touching the writer thread.
from __future__ import annotations
from PyQt5.QtWidgets import QLabel
from PySide6.QtWidgets import QLabel
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
@@ -17,7 +17,7 @@ from __future__ import annotations
from datetime import datetime
from pathlib import Path
from PyQt5.QtWidgets import (
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
@@ -111,7 +111,7 @@ class SensorDialog(QDialog):
def _section_rule() -> QFrame:
rule = QFrame()
rule.setObjectName("sectionSeparator")
rule.setFrameShape(QFrame.HLine)
rule.setFrameShape(QFrame.Shape.HLine)
rule.setFixedHeight(1)
return rule
@@ -9,8 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
@@ -29,8 +29,8 @@ BADGE_WIDTH = 46
class SensorPickerWidget(QWidget):
sensor_type_changed = pyqtSignal(str)
config_changed = pyqtSignal(str)
sensor_type_changed = Signal(str)
config_changed = Signal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -42,7 +42,7 @@ class SensorPickerWidget(QWidget):
#: attribute: it states which sensor the whole window is configured for.
self.thumb_label = QLabel()
self.thumb_label.setObjectName("unitBadge")
self.thumb_label.setAlignment(Qt.AlignCenter)
self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
self.thumb_label.setProperty("rail", "true")
@@ -55,7 +55,7 @@ class SensorPickerWidget(QWidget):
self.type_combo = QComboBox()
self.type_combo.setMinimumWidth(TYPE_WIDTH)
self.type_combo.setProperty("rail", "true")
self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.type_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
for name in list_sensor_types():
self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
@@ -65,7 +65,7 @@ class SensorPickerWidget(QWidget):
self.config_combo = QComboBox()
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
self.config_combo.setProperty("rail", "true")
self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
layout.addWidget(self.thumb_label)
+20 -20
View File
@@ -12,8 +12,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
@@ -42,9 +42,9 @@ RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.
def _separator() -> QFrame:
line = QFrame()
line.setObjectName("sectionSeparator")
line.setFrameShape(QFrame.VLine)
line.setFrameShape(QFrame.Shape.VLine)
line.setFixedWidth(1)
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
line.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
return line
@@ -70,23 +70,23 @@ def _group(*widgets: QWidget) -> QWidget:
class ToolbarWidget(QWidget):
search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int)
search_clicked = Signal()
preview_toggled = Signal(bool)
camera_id_changed = Signal(int)
#: The sensor summary button: opens the sensor and calibration dialog.
sensor_clicked = pyqtSignal()
sensor_clicked = Signal()
#: "Back to the camera" while a video file is the run source.
use_camera_clicked = pyqtSignal()
use_camera_clicked = Signal()
#: Replay view: pick the recorded video to play.
replay_video_clicked = pyqtSignal()
replay_video_clicked = Signal()
#: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
record_toggled = pyqtSignal(bool)
recompute_clicked = Signal()
run_toggled = Signal(bool)
reset_clicked = Signal()
units_changed = Signal(str)
record_toggled = Signal(bool)
#: The settings area was expanded or collapsed by the user.
settings_toggled = pyqtSignal(bool)
settings_toggled = Signal(bool)
def __init__(
self,
@@ -124,9 +124,9 @@ class ToolbarWidget(QWidget):
# narrow window.
self._settings_scroll = QScrollArea()
self._settings_scroll.setWidgetResizable(True)
self._settings_scroll.setFrameShape(QFrame.NoFrame)
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._settings_scroll.setFrameShape(QFrame.Shape.NoFrame)
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.settings_page = self._build_settings()
self._settings_scroll.setWidget(self.settings_page)
outer.addWidget(self._settings_scroll)
@@ -157,7 +157,7 @@ class ToolbarWidget(QWidget):
self.status_chip = QLabel()
self.status_chip.setObjectName("statusChip")
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
self.status_chip.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
self.source_summary = ShrinkableLabel()
self.source_summary.setObjectName("panelMeta")
self.calibration_label = ShrinkableLabel()
+1 -1
View File
@@ -24,7 +24,7 @@ import logging
from dataclasses import dataclass
from typing import Any, Optional, Protocol
from PyQt5.QtCore import QObject
from PySide6.QtCore import QObject
logger = logging.getLogger(__name__)
+25 -2
View File
@@ -3,6 +3,10 @@
The tests render real Qt widgets with the offscreen platform plugin, so they need
no display and no camera hardware. A single ``QApplication`` is shared by the
whole session (Qt forbids creating more than one).
The suite requires PySide6, the app's only Qt binding. Where it is missing the
modules are skipped rather than failed, so a broken environment cannot look like a
broken application (the same rule ``apps/autocal`` follows).
"""
from __future__ import annotations
@@ -16,11 +20,30 @@ from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# In a development environment that also has PyQt5 installed, pyqtgraph would otherwise
# bind to it (it takes whichever toolkit it finds first) and the plots would break.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
def _have_pyside6() -> tuple[bool, str]:
try:
import PySide6
except ImportError as exc: # pragma: no cover - depends on the machine
return False, str(exc)
return True, getattr(PySide6, "__version__", "unknown")
HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6()
if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine
# Mirrors apps/autocal/tests/conftest.py: nothing is collected, so a machine without
# the binding reports no tests instead of a wall of import errors.
collect_ignore_glob = ["test_*.py"]
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
# truth between the pytest suite and the dependency-free checker.
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
@@ -30,7 +53,7 @@ if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
@pytest.fixture(scope="session")
def qt_app():
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
@@ -83,7 +106,7 @@ def visible_descendants(widget):
def show_window(window, size):
"""Show a window at ``size`` and flush pending Qt events."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
+224
View File
@@ -0,0 +1,224 @@
"""Binding policy for the studio.
The studio is **PySide6 only** — Qt's LGPLv3 binding, which is why the app was migrated off
PyQt5 (GPLv3-or-commercial). AutoCal has the same guard in
``apps/autocal/tests/test_binding_purity.py``; this file is the studio's half of it:
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has PySide6 and
nothing else, so such an import is a hard start-up failure;
* every UI and 3D module must name the binding it uses rather than inheriting one;
* the PyQt5 signal spellings (``pyqtSignal``, ``pyqtBoundSignal``) do not exist in PySide6;
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``, ``QPainter.Antialiasing``);
* importing the application must not drag another binding into ``sys.modules`` — that is
what breaks ``pyqtgraph``, which binds to whichever toolkit it finds first.
Two lessons from the migration are baked in here. The unscoped-enum check asks PySide6
instead of using a hand-written list of enum holders: a curated list missed
``QPainter.RenderHint.Antialiasing``, ``QPainter.CompositionMode.CompositionMode_SourceIn`` and
``QDialogButtonBox.StandardButton.Ok|Cancel|Close``. And the ``sys.modules`` check runs in a **fresh
interpreter**, because in-process the answer depends on what some earlier test imported.
"""
from __future__ import annotations
import ast
import enum
import subprocess
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1] # apps/studio
SRC = APP / "src"
PROJECT_ROOT = APP.parents[1] # orisys-app
#: Everything that ships or drives the app.
SCAN_ROOTS = (SRC, APP / "tools")
#: Bindings no module may import.
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed.
ALLOWED_BINDING = "PySide6"
#: Directories that must name their binding explicitly.
#:
#: ``ui/`` only: every widget there imports PySide6. ``scene3d/`` is deliberately mixed —
#: ``coordinator.py``, ``camera.py``, ``adapters.py``, ``prefs.py`` and ``keys.py`` import no
#: Qt binding at all so they can be tested headless, and requiring one would forbid that
#: design. The modules there that *do* use Qt already name PySide6.
BINDING_DECLARING_DIRS = (SRC / "orisys_studio" / "ui",)
#: Names that only ever existed in PyQt. Quoted here on purpose — the migration scripts that
#: do the rewriting skip this file, because a text-based rule cannot tell a forbidden spelling
#: in code from one named in the policy that forbids it.
PYQT_ONLY_NAMES = ("pyqtSignal", "pyqtBoundSignal", "pyqtSlot", "pyqtProperty")
_SKIP_PARTS = ("__pycache__",)
def _python_files(root: Path) -> list[Path]:
return sorted(
path
for path in root.rglob("*.py")
if not any(part in path.parts for part in _SKIP_PARTS)
)
def _read(path: Path) -> str:
# One file in the workspace carries a UTF-8 BOM; `utf-8-sig` keeps `ast` happy either way.
return path.read_text(encoding="utf-8-sig", errors="replace")
def _imported_packages(path: Path) -> set[str]:
"""Every top-level package name imported by ``path``."""
found: set[str] = set()
for node in ast.walk(ast.parse(_read(path))):
if isinstance(node, ast.Import):
found.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
found.add(node.module.split(".")[0])
return found
def _qt_namespace() -> dict[str, object]:
"""The Qt classes the sources could name, resolved through the binding itself."""
from PySide6 import QtCore, QtGui, QtOpenGLWidgets, QtWidgets
namespace: dict[str, object] = {"Qt": QtCore.Qt}
return namespace, (QtCore, QtGui, QtWidgets, QtOpenGLWidgets)
def _unscoped_enums(path: Path, namespace: dict[str, object], modules: tuple) -> list[str]:
"""Attribute reads whose value resolves to an enum instance rather than an enum class.
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute of an enum class, so the outer
``Qt.AlignmentFlag`` resolves to a *class* and the inner one is not a plain name — neither
is a candidate. ``QMessageBox.information`` resolves to a method. ``Qt.AlignmentFlag.AlignLeft``
resolves to an enum instance, which is the spelling this test exists to reject.
"""
tree = ast.parse(_read(path))
called = {
id(node.func)
for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
}
inner = {id(node.value) for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
found: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
continue
if id(node) in called or id(node) in inner:
continue
holder, member = node.value.id, node.attr
target_holder = namespace.get(holder)
if target_holder is None:
for module in modules:
if hasattr(module, holder):
target_holder = namespace[holder] = getattr(module, holder)
break
else:
continue
value = getattr(target_holder, member, None)
if isinstance(value, (enum.Enum, enum.Flag)) and member not in namespace:
found.add(f"{holder}.{member}")
return sorted(found)
def test_no_studio_module_imports_a_forbidden_binding():
offenders = []
for root in SCAN_ROOTS:
for path in _python_files(root):
bad = _imported_packages(path) & FORBIDDEN_BINDINGS
if bad:
offenders.append(f"{path.relative_to(APP)}: {sorted(bad)}")
assert not offenders, (
f"the studio is {ALLOWED_BINDING}-only; these modules import another binding: "
f"{offenders}"
)
def test_every_ui_module_imports_the_allowed_binding():
"""A widget module that names no binding would be using one it does not declare."""
offenders = []
for directory in BINDING_DECLARING_DIRS:
for path in _python_files(directory):
if path.name == "__init__.py":
continue # a re-export barrel, not a widget module
if ALLOWED_BINDING not in _imported_packages(path):
offenders.append(str(path.relative_to(APP)))
assert not offenders, f"these modules do not import {ALLOWED_BINDING}: {offenders}"
def test_no_pyqt_only_spelling_survives():
offenders = []
for root in SCAN_ROOTS:
for path in _python_files(root):
text = _read(path)
for name in PYQT_ONLY_NAMES:
if name in text:
offenders.append(f"{path.relative_to(APP)}: {name}")
assert not offenders, f"these PyQt-only names do not exist in PySide6: {offenders}"
def test_no_module_uses_an_unscoped_enum():
try:
import PySide6 # noqa: F401
except ImportError: # pragma: no cover - depends on the machine
import pytest
pytest.skip("PySide6 is not installed here, so the enum rule cannot be checked")
namespace, modules = _qt_namespace()
offenders = {}
for root in SCAN_ROOTS:
for path in _python_files(root):
found = _unscoped_enums(path, namespace, modules)
if found:
offenders[str(path.relative_to(APP))] = found
assert not offenders, (
"unscoped Qt enumerators are the spelling PySide6 is removing; use the scoped "
f"form: {offenders}"
)
def test_importing_the_application_pulls_in_no_other_binding():
"""Fresh interpreter: PySide6 only, whatever this test session imported before."""
import os
script = (
"import sys;"
"import orisys_studio.app_window;"
"import orisys_studio.scene3d.view;"
"import orisys_studio.ui.force_plots;"
"print(','.join(m for m in ('PyQt5', 'PyQt6', 'PySide2') if m in sys.modules))"
)
env = dict(os.environ)
entries = [str(PROJECT_ROOT / "shared" / "src"), str(SRC)]
if env.get("PYTHONPATH"):
entries.append(env["PYTHONPATH"])
env["PYTHONPATH"] = os.pathsep.join(entries)
# The app's own start-up contract (``main.py``) and a display-free platform: the point
# is which *binding* comes in, not whether a window can be shown.
env.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
env.setdefault("QT_QPA_PLATFORM", "offscreen")
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=180,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
pulled = [name for name in result.stdout.strip().split(",") if name]
assert pulled == [], (
f"importing the app pulled in {pulled}; pyqtgraph binds to whichever toolkit it "
"finds first, so a second binding in the environment breaks the plots"
)
def test_the_app_declares_its_binding_dependency():
"""The packaging must state PySide6, not leave it to chance."""
pyproject = (APP / "pyproject.toml").read_text(encoding="utf-8")
assert ALLOWED_BINDING in pyproject
for forbidden in FORBIDDEN_BINDINGS:
assert f'"{forbidden}' not in pyproject, forbidden
+4 -3
View File
@@ -94,15 +94,16 @@ def _render(widget, qt_app) -> np.ndarray:
on a little-endian machine, with a stride that can exceed the width — so the
conversion and the stride trim are both load-bearing.
"""
from PyQt5.QtGui import QImage
from PySide6.QtGui import QImage
widget.repaint()
qt_app.processEvents()
image = widget.grabFramebuffer()
assert not image.isNull(), "Qt returned no framebuffer"
image = image.convertToFormat(QImage.Format_RGB888)
image = image.convertToFormat(QImage.Format.Format_RGB888)
width, height = image.width(), image.height()
buffer = image.constBits().asarray(image.byteCount())
# PySide6 returns a `memoryview` from constBits(), not a QByteArray: wrap it directly.
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
stride = image.bytesPerLine()
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
return rows[:, : width * 3].reshape(height, width, 3).copy()
+115
View File
@@ -0,0 +1,115 @@
"""Composer overlays and 2D/3D sample-point parity."""
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 (
DisplaySettings,
FieldDescriptor,
SurfaceFieldFrame,
freeze,
)
from orisys_app_shared.scene3d.synthetic import poc_identity, poc_scene
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
def _depth_frame(assets, *, flow=None, contact_xy=None, fn=0.0):
h, w = assets.shape_hw
scalar = freeze(np.zeros((h, w), dtype=np.float32), dtype=np.float32)
valid = freeze(np.ones((h, w), dtype=bool), dtype=np.bool_)
flow_uv = None if flow is None else freeze(np.asarray(flow, dtype=np.float32), dtype=np.float32)
flow_mask = None if flow is None else freeze(np.asarray(valid, dtype=bool).copy(), dtype=np.bool_)
found = contact_xy is not None
return SurfaceFieldFrame(
identity=poc_identity(),
geometry_id=assets.geometry_id,
field_domain_id=assets.field_domain_id,
scalar=scalar,
valid=valid,
scalar_descriptor=FieldDescriptor(
"depth_response", "1", "approximate", False
),
computed_frame_id=0,
flow_uv=flow_uv,
flow_valid_mask=flow_mask,
contact_uv_xy=None if contact_xy is None else tuple(contact_xy),
contact_found=found,
contact_valid=found,
contact_fn=float(fn),
)
def test_composer_builds_overlays_when_mapping_present() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0)
composed = SceneComposer().compose(
assets,
frame,
DisplaySettings(
flow_arrows_enabled=True,
contact_arrow_enabled=True,
flow_grid_spacing=1,
flow_on_px=0.4,
flow_off_px=0.3,
max_arrows=50,
),
)
assert composed.flow_arrows is not None
assert composed.flow_arrows.origins.shape[0] >= 1
assert composed.contact_arrow is not None
assert composed.contact_arrow.origins.shape[0] == 1
def test_composer_hides_overlays_when_disabled() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0)
composed = SceneComposer().compose(
assets,
frame,
DisplaySettings(flow_arrows_enabled=False, contact_arrow_enabled=False),
)
assert composed.flow_arrows is None
assert composed.contact_arrow is None
def test_2d_3d_flow_sample_points_match() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float64)
flow[1, 1] = (0.7, 0.2)
mask = np.ones((h, w), dtype=bool)
settings = DisplaySettings(
flow_on_px=0.4,
flow_off_px=0.3,
flow_grid_spacing=1,
max_arrows=50,
)
policy_3d = _policy_from_style(
"finger",
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
)
policy_shared = VisualPolicy(
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
flow_edge_margin=0,
).normalised()
samples_2d = select_flow_samples(flow, mask, policy_shared)
samples_3d = select_flow_samples(flow, mask, policy_shared)
assert list(zip(samples_2d.rows, samples_2d.cols)) == list(
zip(samples_3d.rows, samples_3d.cols)
)
assert policy_3d.flow_on_px == settings.flow_on_px
+6 -6
View File
@@ -13,8 +13,8 @@ view menu / the pane's header both reach that dialog.
from __future__ import annotations
import pytest
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from orisys_app_shared.i18n import t
from orisys_studio.ui import icons
@@ -117,8 +117,8 @@ def test_the_pane_header_offers_the_two_actions(qt_app):
# Reset forwards to the view when there is one.
class _View(QWidget):
diagnosticRaised = pyqtSignal(object)
enabledChanged = pyqtSignal(bool)
diagnosticRaised = Signal(object)
enabledChanged = Signal(bool)
def __init__(self) -> None:
super().__init__()
@@ -229,7 +229,7 @@ def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
"""One line under the canvas: what the picture is, not how it was made."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = make_window()
window.show()
@@ -262,7 +262,7 @@ def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
def test_the_view_menu_opens_the_3d_settings(make_window):
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = make_window(sensor_type="finger", show_finger_3d=True)
window.show()
+5 -5
View File
@@ -19,7 +19,7 @@ import pytest
@pytest.fixture
def restore_surface_format():
"""The default surface format is process-wide; put it back afterwards."""
from PyQt5.QtGui import QSurfaceFormat
from PySide6.QtGui import QSurfaceFormat
previous = QSurfaceFormat.defaultFormat()
try:
@@ -49,21 +49,21 @@ 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 PyQt5.QtGui import QSurfaceFormat
from PySide6.QtGui import QSurfaceFormat
from orisys_studio.scene3d.gl_backend import configure_surface_format
configure_surface_format("desktop33")
desktop = QSurfaceFormat.defaultFormat()
assert desktop.renderableType() == QSurfaceFormat.OpenGL
assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
assert desktop.profile() == QSurfaceFormat.CoreProfile
assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile
assert desktop.depthBufferSize() == 24
assert desktop.samples() == 0, "v1 does not pay for multisampling"
configure_surface_format("gles30")
embedded = QSurfaceFormat.defaultFormat()
assert embedded.renderableType() == QSurfaceFormat.OpenGLES
assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
with pytest.raises(ValueError):
+23 -6
View File
@@ -29,8 +29,8 @@ import numpy as np
import pytest
import ui_check
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
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 (
@@ -276,8 +276,8 @@ class _RecordingView(QWidget):
a real ``QWidget`` because the container puts it in its stack.
"""
diagnosticRaised = pyqtSignal(object)
enabledChanged = pyqtSignal(bool)
diagnosticRaised = Signal(object)
enabledChanged = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -433,6 +433,12 @@ def test_the_display_settings_round_trip():
deform_enabled=True,
housing_visible=False,
max_fps=60,
flow_arrows_enabled=False,
contact_arrow_enabled=True,
flow_on_px=0.55,
flow_off_px=0.35,
flow_grid_spacing=12,
max_arrows=250,
color_scale=ColorScale(
descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
lower=0.25,
@@ -446,6 +452,12 @@ def test_the_display_settings_round_trip():
assert restored.deform_enabled is True
assert restored.housing_visible is False
assert restored.max_fps == 60
assert restored.flow_arrows_enabled is False
assert restored.contact_arrow_enabled is True
assert restored.flow_on_px == 0.55
assert restored.flow_off_px == 0.35
assert restored.flow_grid_spacing == 12
assert restored.max_arrows == 250
assert restored.color_scale is not None
assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5)
assert restored.color_scale.mode == "fixed"
@@ -547,7 +559,9 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
loaded: list = []
failed: list = []
loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
loader.failed.connect(failed.append)
# Same arity caveat as below: this branch should never fire, and under PySide6 a
# builtin slot would fail *silently* if it ever did.
loader.failed.connect(lambda item, request_id: failed.append(item))
try:
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
@@ -667,7 +681,10 @@ def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path):
broken.write_bytes(b"not a zip archive at all")
loader = SceneAssetLoader()
failed: list = []
loader.failed.connect(failed.append)
# ``failed`` carries (diagnostic, request id). PySide6 adapts to a *Python* callable's
# arity but not to a builtin's, so ``failed.append`` raises TypeError inside the emit,
# Qt swallows it, and the failure signal looks like it was never sent.
loader.failed.connect(lambda item, request_id: failed.append(item))
try:
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
+29 -29
View File
@@ -559,7 +559,7 @@ def test_force_plots_render_demo_data(make_window):
window = _show(make_window(), (1440, 880))
for i in range(60):
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
assert window.force_plots.history.sample_count > 0
@@ -1009,7 +1009,7 @@ def test_top_area_stays_compact(make_window):
assert window.toolbar.record_chip.text() == t("record.state_idle")
window.toolbar.btn_settings.setChecked(True)
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
assert window.toolbar.settings_expanded()
@@ -1032,7 +1032,7 @@ def test_settings_area_stays_inside_its_container(make_window, size):
The settings area is the one part of the top rail that may scroll: its own
content is exempt, everything else must fit.
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
window._set_settings_expanded(True)
@@ -1063,7 +1063,7 @@ def test_idle_run_is_the_only_filled_action(make_window):
def test_connecting_cancel_stays_filled(make_window):
"""Cancel is the way out of a hung device open, so it keeps the weight."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker()
@@ -1197,7 +1197,7 @@ 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 PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1229,7 +1229,7 @@ def test_the_charts_start_in_follow_mode(make_window):
def test_the_charts_zoom_and_pan_in_every_view(make_window):
"""Zoom/pan must be reachable from the live view, not only while browsing."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window)
panel = window.force_plots
@@ -1306,7 +1306,7 @@ 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 PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1344,7 +1344,7 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
def test_the_source_picker_follows_the_view(make_window):
"""Live offers a camera, the replay a recorded video — and no residue."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window)
assert window.toolbar.camera_group.isVisible()
@@ -1425,11 +1425,11 @@ def test_the_nav_items_switch_the_main_window_layout(make_window):
def test_the_calibration_view_does_not_clip_its_controls(make_window):
from PyQt5.QtWidgets import QHeaderView
from PySide6.QtWidgets import QHeaderView
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
window.brand_bar.btn_calibration.click()
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
for _ in range(3):
QApplication.processEvents()
@@ -1437,7 +1437,7 @@ def test_the_calibration_view_does_not_clip_its_controls(make_window):
cal = window.cal_panel
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
assert (
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents
), "the diagnostic columns can still be elided"
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
assert button.height() >= button.fontMetrics().height(), button.text()
@@ -1468,7 +1468,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click()
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
live_sizes = tuple(window.content_splitter.sizes())
@@ -1496,7 +1496,7 @@ 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 PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1602,7 +1602,7 @@ def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window):
from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import unit_display
from PyQt5.QtWidgets import QPushButton
from PySide6.QtWidgets import QPushButton
window = _show(make_window(), (1440, 880))
plots = window.force_plots
@@ -1734,7 +1734,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
While the replay view is up, its frames own the 2D panel; a live packet used
to paint over the recording. Leaving the view hands the pane back.
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window)
window.brand_bar.btn_playback.click()
@@ -1786,7 +1786,7 @@ 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 PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1820,7 +1820,7 @@ 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 PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1847,7 +1847,7 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
"""Play/pause like an editor: 1× forward, and a manual gesture takes over."""
import time
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
@@ -1887,7 +1887,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
make_window, replay_recording
):
"""Off: the recorded CSV. On: the recorded video through the vision chain."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
@@ -1929,7 +1929,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
wrong. So the rail's second button becomes an unconditional stop in this view, and
it covers both things that can run there: plain playback and a recompute pass.
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1989,7 +1989,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
def test_escape_stops_the_replay_too(make_window, replay_recording):
"""Esc is the documented way out; in the replay view it stops the replay."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
@@ -2007,7 +2007,7 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
def test_the_live_view_keeps_the_tracker_reset(make_window):
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click()
@@ -2028,7 +2028,7 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2091,7 +2091,7 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco
"""
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module
from orisys_app_shared.acquisition.image_backend import (
@@ -2186,7 +2186,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
"""
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2239,7 +2239,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
make_window, record_root
):
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2272,7 +2272,7 @@ 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 PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2300,7 +2300,7 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor
import csv
import json
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2399,7 +2399,7 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window):
"""
import time
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module
+5 -5
View File
@@ -48,7 +48,7 @@ def check(condition: bool, message: str) -> None:
def _inside_scroll_area(widget) -> bool:
"""True when ``widget`` lives inside a QScrollArea's content."""
from PyQt5.QtWidgets import QScrollArea
from PySide6.QtWidgets import QScrollArea
node = widget
while node is not None:
@@ -538,7 +538,7 @@ def audit_palette(verbose: bool = False) -> list[str]:
def main() -> int:
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.force.calibration_io import CalSample
from orisys_app_shared.i18n import set_locale, t
@@ -1491,7 +1491,7 @@ def main() -> int:
window.brand_bar.btn_calibration.click()
for _ in range(3):
app.processEvents()
from PyQt5.QtWidgets import QHeaderView as _QHeaderView
from PySide6.QtWidgets import QHeaderView as _QHeaderView
sizes = window.content_splitter.sizes()
check(
@@ -2000,7 +2000,7 @@ def main() -> int:
"import os, sys\n"
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
"os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n"
"from PyQt5.QtCore import QTimer\n"
"from PySide6.QtCore import QTimer\n"
"import orisys_studio.main as studio_main\n"
"show = studio_main.StudioMainWindow.show\n"
"def show_and_close(self):\n"
@@ -2259,7 +2259,7 @@ def main() -> int:
import orisys_studio.ui.cal_panel as _cal_panel_module
import orisys_studio.calibration_service as _cal_service_module
from PyQt5.QtWidgets import QMessageBox as _QMessageBox
from PySide6.QtWidgets import QMessageBox as _QMessageBox
from orisys_app_shared.force.calibration_io import (
build_force_calibration_v2 as _build_v2b,
+1 -1
View File
@@ -391,7 +391,7 @@ def main() -> int:
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
+147 -32
View File
@@ -1,40 +1,84 @@
# Build
Windows packaging for the studio desktop app.
Windows packaging for the studio desktop app: **one provisioning step per machine, then
repeatable builds.**
> **AutoCal is not built here.** `apps/autocal` (the internal 5-axis platform
> control app) runs from source and has no PyInstaller spec, deliberately: this
> spec would otherwise have to learn about the ZMotion runtime DLLs, and AutoCal
> must stay out of the customer installer. See `apps/autocal/README.md`.
## Layout
```text
build/
studio/
entry.py # frozen entry (no source chdir)
orisys-studio.spec # PyInstaller onedir spec
build.ps1 # create/reuse .venv-build and build
_pyinstaller/ # PyInstaller workpath (gitignored)
setup.bat / setup.ps1 # provision the build venv (run once per machine)
constraints-build.txt # pinned build-time versions
build.bat / build.ps1 # build the EXE and verify it
entry.py # frozen entry (no source chdir)
orisys-studio.spec # PyInstaller onedir spec
_pyinstaller/ # PyInstaller workpath (gitignored)
_smoke/ # log of the last build's start-up smoke test
dist/
OrisysStudio/
OrisysStudio.exe
config/ # lean locales + sensors (no finger visualization/)
_internal/ # Python, PyQt5, opencv-headless, orisys, …
config/ # lean locales + sensors (no finger visualization/)
_internal/ # Python, PySide6, opencv-headless, orisys, OpenGL, …
```
## Prerequisites
## Why there are two steps
- Windows + Python 3.10+ on PATH
- `orisys` installable into the build venv (`pip install orisys>=0.5`)
- Do **not** install `orisys[gui]` / Open3D for this build
A PyInstaller bundle depends on the *machine* as much as on the sources: which interpreter,
whether it is Anaconda (whose `libexpat`/`libffi`/OpenSSL DLLs live outside the venv and are
therefore **not** bundled), and where the `orisys` SDK checkout is. Those facts are decided
once, by `setup.ps1`, and recorded in `.venv-build\orisys-build-env.json`.
`build.ps1` then installs nothing and guesses nothing: it verifies the environment, builds,
and verifies the artefact. That split is what keeps a build reproducible instead of
"works on my machine".
## First time on a machine
```bat
cd build\studio
setup.bat
setup.bat -Python C:\Python310\python.exe
setup.bat -AllowConda
```
`setup.bat` finds an interpreter, creates or repairs `.venv-build`, installs the pinned
toolchain plus the app's extras (including the studio's `three-d` extra — without it the 3D
pane silently reports "PyOpenGL is not installed" on the customer's machine), writes the
stamp, and then verifies the result by importing what the frozen app imports.
Interpreter resolution, in order: `-Python <path>`; `py -3.10`; `python`; the interpreter
already behind `.venv-build`. The Microsoft Store `python.exe` alias is refused (it is not an
interpreter), and an Anaconda interpreter is refused unless `-AllowConda` is passed — in that
case its `Library\bin` is recorded and the spec adds the runtime DLLs, because PyInstaller
does not search there and the EXE would otherwise die at start-up with
`ImportError: DLL load failed while importing pyexpat`. Building with python.org CPython is
still the preferred answer.
The SDK is installed from `..\..\..\orisys-sdk` when that checkout exists (`-SdkPath` to point
elsewhere, `-FromPypi` to install the published wheel instead). Installing the published SDK
is how a build once shipped with no `orisys.finger` at all.
Re-running `setup.bat` is safe: with a valid stamp for this checkout it reports and exits;
without one it repairs the existing venv rather than deleting it. `-Force` recreates it.
## Build
From `build\studio` (recommended):
```bat
build.bat
build.bat -Clean
build.bat -SkipInstall
build.bat -Clean # after dependency/spec changes
build.bat -RebuildVenv # setup.bat -Force, then build
build.bat -SkipInstall # never provision: fail instead
build.bat -SkipSmokeTest # do not launch the produced EXE
build.bat -StrictSmokeTest # treat a 3D-context warning as a failure
```
Or from repo root:
Or from the repo root:
```powershell
powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
@@ -42,35 +86,106 @@ powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
Flags:
## What the build verifies, and why
| Flag | Meaning |
|------|---------|
| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) |
| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) |
Every silent failure this packaging has produced looked like a *successful* build, so the
script checks the things that only show up when the EXE runs:
Output: `dist\OrisysStudio\OrisysStudio.exe`
| check | failure it prevents |
|---|---|
| the venv imports `orisys_app_shared`, `orisys_studio`, `orisys`, `OpenGL`, `cv2`, `PyInstaller` | a stale editable install still builds (the spec's `pathex` finds the modules) and produces an EXE that cannot start |
| `collect_data_files('orisys_app_shared', includes=['theme/*.json'])` is non-empty | the theme colours are package data; without them the app dies in `build_qss()` before logging anything |
| every `config\sensors\**\*.oryscene` is present, and the finger manifest/`sensor.json`/unroll map survive the config strip | the 3D pane refuses the scene when the device description cannot be derived |
| no `open3d*` under `dist` | the offline tool leaking into the shipped bundle |
| **smoke test**: start the produced EXE, wait for its own log to say `Studio shell started` (and `3D scene loaded` when a finger scene shipped), then stop it | a bundle that cannot start at all — missing runtime DLLs, an excluded module, missing data. The log is kept under `build\_smoke\` |
The smoke test seeds a `preference.json` beside the EXE to select the finger sensor with the
3D pane on, and restores (or removes) it afterwards. A *GL context* failure is reported as a
warning, because it is environment-dependent; `-StrictSmokeTest` turns it into a failure.
## Where the app's own code lives in the bundle
`_internal\` contains **native code and data files only**. Every pure-Python module —
`orisys_studio.*`, `orisys_app_shared.*`, `orisys.*` — is compiled into the `PYZ` archive that
sits *inside* `OrisysStudio.exe` (that is most of the EXE's ~13 MB). So there is deliberately
no `_internal\orisys_studio\` folder, and no `.py` files anywhere: PyInstaller ships bytecode,
never sources.
`_internal` therefore shows only these orisys-related paths:
```text
_internal\orisys_app_shared\theme\colors_dark.json <- package data, bundled (qss.py reads it)
_internal\orisys_app_shared\theme\colors_light.json
_internal\orisys\.gitignore <- stray non-.py file of the SDK package
_internal\branding\... <- logos
```
(`collect_data_files("orisys")` exists for the case where the SDK is installed from a wheel that
bundles `sensors/` assets; with a local editable checkout the only non-`.py` file is that
`.gitignore`. It is harmless — the app prefers `config\sensors` — but it does look odd.)
To list what is really in the archive:
```bat
.venv-build\Scripts\python.exe -m PyInstaller.utils.cliutils.archive_viewer ^
-l -r -b dist\OrisysStudio\OrisysStudio.exe
```
(The `PYZ-00.toc` under `build\_pyinstaller\orisys-studio\` is the same information at build
time, and `build\_smoke\*.log` is the runtime proof: "Studio shell started" and "3D scene
loaded" can only be printed if those modules imported.)
## Dependency notes
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI).
- Upstream `orisys` may still declare `opencv-python`. `build.ps1` uninstalls that wheel and installs **`opencv-python-headless>=4.10,<5`** (5.x has shipped broken wheels without a `cv2` module). The script verifies `import cv2` before packaging.
- **Open3D is excluded** (spec + build env). Finger 3D remains a placeholder in the UI.
- **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is
GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it
(`apps/studio/tests/test_binding_purity.py`, `apps/autocal/tests/test_binding_purity.py`).
`setup.ps1` **uninstalls** PyQt5/PyQt6/PySide2 from the build venv and verifies only one
binding is present: `pyqtgraph` binds to whichever toolkit it imports first, so a leftover
PyQt5 yields a bundle whose plots were built from the wrong binding. The spec also excludes
`PyQt5`/`PyQt6`/`PySide2` outright, and must never exclude `PySide6` itself.
- App shared dep uses **`opencv-python-headless`** (PySide6 owns the GUI). `setup.ps1`
uninstalls any `opencv-python` and pins `opencv-python-headless>=4.10,<5` (5.x has shipped
wheels without a `cv2` module) and verifies `import cv2`.
- **PyOpenGL** is the GL binding for the 3D pane and comes from the studio's `three-d` extra.
Add the `protected-assets` extra (`cryptography`) if sealed `.oryscene` packages are shipped.
- **`QOpenGLWidget` lives in `PySide6.QtOpenGLWidgets`**, not `QtWidgets` (Qt6 moved it): the
spec names it in `hiddenimports` so the module and `Qt6OpenGLWidgets.dll` are bundled.
- **Open3D stays excluded** (spec + build env): it is an offline geometry tool. The 3D pane is
Qt `QOpenGLWidget` + PyOpenGL.
- `constraints-build.txt` pins the toolchain. Move a pin only together with `setup.bat -Force`
and a passing `build.bat` — the smoke test is the acceptance evidence, not the file.
## GL note
The pane asks for the profile in `gl_profile` (`desktop33` by default, `gles30` otherwise). A
`desktop33` request needs a desktop GL 3.3 core context; on some sessions Qt falls back to its
ANGLE (GLES) backend, which cannot satisfy it, and the widget then reports no context on a
machine whose GPU is fine. `QT_OPENGL=desktop` is the workaround; the durable fix is to pin
the backend in `orisys_studio.main` before `QApplication` is constructed
(`Qt.AA_UseDesktopOpenGL` / `Qt.AA_UseOpenGLES`). `build.bat -StrictSmokeTest` makes this a
build failure.
## Size / startup
- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder.
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved).
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved). Nothing
in the app reads it (`manifest.json`'s `viz_pose` is used only by the SDK examples); the 3D
pane needs `scene/*.oryscene` plus `assets/30fps-0703`, and the build checks both.
- Studio shell defers OpenCV / acquisition imports until Preview or Run.
Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle.
Expect ~450 MB total (Qt + numpy/scipy/numba + OpenCV + PyOpenGL).
## Verification
## Verification on a new machine
1. Launch `OrisysStudio.exe` on the build machine (and ideally a clean PC without Python).
2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE.
3. Camera preview + box/mini/finger Run (video) with image modes and force plots.
4. Confirm no `open3d*` files under `dist\OrisysStudio`.
1. `setup.bat` (see its report: interpreter, conda or not, SDK source).
2. `build.bat` — its smoke test must print "the application started" and "the packaged 3D
scene loaded".
3. Launch `OrisysStudio.exe` and check theme/locale/prefs; `preference.json` and `outputs\`
appear next to the EXE.
4. Camera preview + box/mini/finger Run (video) with image modes and force plots.
5. Open the 3D pane on a finger source: the surface draws, the legend shows the field.
6. Confirm no `open3d*` files under `dist\OrisysStudio`.
## Editable run (unchanged)
+235 -58
View File
@@ -1,90 +1,134 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Build Orisys Studio Windows onedir EXE with PyInstaller.
Build the Orisys Studio Windows onedir EXE with PyInstaller.
.DESCRIPTION
This script installs nothing. Provisioning belongs to `setup.ps1`, which decides the
machine-specific facts once (interpreter, Anaconda or not, where the SDK checkout is) and
records them in `.venv-build\orisys-build-env.json`. Here we only:
1. make sure that provisioning happened (and run `setup.ps1` if it plainly has not),
2. verify the environment can import what the frozen app imports,
3. build,
4. verify the artefact — config, excludes, and a real start-up of the produced EXE.
Step 2 and step 4 exist because the interesting failures are silent ones: a stale venv
still builds (the spec's pathex finds the modules) yet loses the theme data; an excluded
module still builds yet makes `import orisys.finger` fail; a missing runtime DLL still
builds yet the EXE never writes a log. Each of those looked like a successful build.
.PARAMETER Clean
Pass --clean to PyInstaller (slower; use when deps/spec change).
Pass --clean to PyInstaller (slower; use after dependency or spec changes).
.PARAMETER SkipInstall
Skip pip install steps (reuse existing .venv-build packages).
Do not run `setup.ps1`, even if the environment looks unprovisioned. Fails instead of
provisioning silently.
.PARAMETER RebuildVenv
Run `setup.ps1 -Force` first (recreate the venv from scratch), then build.
.PARAMETER SkipSmokeTest
Do not launch the produced EXE. The smoke test is the only step that proves the bundle
starts; skip it only where a desktop session is genuinely unavailable.
.PARAMETER StrictSmokeTest
Treat a 3D-context warning from the smoke test as a failure.
.PARAMETER GlProfile
GL profile the smoke test starts the app with (default: the shipped `desktop33`).
#>
param(
[switch]$Clean,
[switch]$SkipInstall
[switch]$SkipInstall,
[switch]$RebuildVenv,
[switch]$SkipSmokeTest,
[switch]$StrictSmokeTest,
[string]$GlProfile = "desktop33"
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$StudioDir = $PSScriptRoot
$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path
$VenvDir = Join-Path $RepoRoot ".venv-build"
$Python = Join-Path $VenvDir "Scripts\python.exe"
$Spec = Join-Path $PSScriptRoot "orisys-studio.spec"
$StampPath = Join-Path $VenvDir "orisys-build-env.json"
$Spec = Join-Path $StudioDir "orisys-studio.spec"
$Setup = Join-Path $StudioDir "setup.ps1"
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
$DistPath = Join-Path $RepoRoot "dist"
$OutDir = Join-Path $DistPath "OrisysStudio"
$Exe = Join-Path $OutDir "OrisysStudio.exe"
$SmokeKeep = Join-Path $RepoRoot "build\_smoke"
function Assert-Ok([string]$what) {
if ($LASTEXITCODE -ne 0) { throw "$what failed (exit $LASTEXITCODE)" }
}
# PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, and a BOM makes Python's
# `json.loads` raise ("Unexpected UTF-8 BOM") — the app then silently falls back to its
# defaults, which is exactly the kind of quiet failure this script exists to avoid.
function Write-JsonFile([string]$path, [string]$json) {
[System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false)))
}
Write-Host "Repo: $RepoRoot"
if (-not (Test-Path $Python)) {
Write-Host "Creating build venv at $VenvDir"
python -m venv $VenvDir
# --------------------------------------------------------------- 1. provisioning
if ($RebuildVenv) {
Write-Host "Recreating the build environment ..."
& $Setup -Force
Assert-Ok "setup.ps1 -Force"
}
if (-not $SkipInstall) {
Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…"
& $Python -m pip install --upgrade pip
& $Python -m pip install "pyinstaller>=6.0"
& $Python -m pip install -e (Join-Path $RepoRoot "shared")
& $Python -m pip install -e (Join-Path $RepoRoot "apps\studio")
# orisys may already be installed; ensure present without [gui]/open3d
& $Python -m pip install "orisys>=0.5"
Write-Host "Forcing opencv-python-headless 4.x (orisys may have pulled opencv-python; 5.x can be broken)…"
& $Python -m pip uninstall -y opencv-python opencv-python-headless 2>$null
& $Python -m pip install "opencv-python-headless>=4.10.0.84,<5"
& $Python -c "import cv2; print('cv2 OK', cv2.__version__)"
if ($LASTEXITCODE -ne 0) {
Write-Error "cv2 import failed after installing opencv-python-headless"
exit $LASTEXITCODE
}
# Guard: Open3D must not be present in the build env
$hasOpen3d = & $Python -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
if ($hasOpen3d -eq "True") {
Write-Host "Uninstalling open3d from build venv…"
& $Python -m pip uninstall -y open3d
}
$stampRoot = ""
if (Test-Path $StampPath) {
$stampRoot = (Get-Content $StampPath -Raw | ConvertFrom-Json).checkout_root
}
$needsSetup = (-not (Test-Path $Python)) -or ($stampRoot -ne $RepoRoot)
if ($needsSetup) {
if ($SkipInstall) {
throw "the build environment is missing or was provisioned for another checkout. Run setup.bat (or drop -SkipInstall)."
}
Write-Host "No usable build environment for this checkout: running setup.ps1 ..."
& $Setup
Assert-Ok "setup.ps1"
}
if (-not (Test-Path $Spec)) { throw "spec not found: $Spec" }
# ---------------------------------------------------- 2. environment verification
# Cheap (about a second) and it catches the two provisioning mistakes that build silently:
# an editable install pointing at another checkout, and a missing GL binding.
Write-Host "Verifying the build environment ..."
& $Python -c "import orisys_app_shared, orisys_studio, orisys, OpenGL, cv2, PyInstaller; print('imports OK')"
Assert-Ok "the build venv cannot import what the app imports"
& $Python -c "from PyInstaller.utils.hooks import collect_data_files as c; d=c('orisys_app_shared', includes=['theme/*.json']); assert d, 'theme data not collectable'; print('theme data OK:', len(d), 'files')"
Assert-Ok "theme data would not be bundled"
# ------------------------------------------------------------------ 3. PyInstaller
$pyiArgs = @(
"--noconfirm",
"--distpath", $DistPath,
"--workpath", $WorkPath,
$Spec
)
if ($Clean) {
$pyiArgs = @("--clean") + $pyiArgs
}
if ($Clean) { $pyiArgs = @("--clean") + $pyiArgs }
Write-Host "Running PyInstaller…"
Write-Host "Running PyInstaller ..."
& $Python -m PyInstaller @pyiArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "PyInstaller failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
Assert-Ok "PyInstaller"
$OutDir = Join-Path $DistPath "OrisysStudio"
Write-Host ""
Write-Host "Build complete: $OutDir"
# Lean config beside EXE (exclude finger visualization assets)
# -------------------------------------------------------------- 4. shipped config
# Lean config beside the EXE (the finger visualization bundle is a development artefact).
$ConfigSrc = Join-Path $RepoRoot "apps\studio\config"
$ConfigDst = Join-Path $OutDir "config"
if (Test-Path $ConfigSrc) {
Write-Host "Copying lean config to $ConfigDst …"
if (Test-Path $ConfigDst) {
Remove-Item $ConfigDst -Recurse -Force
}
Write-Host "Copying lean config to $ConfigDst ..."
if (Test-Path $ConfigDst) { Remove-Item $ConfigDst -Recurse -Force }
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
Get-ChildItem $ConfigSrc -Force | ForEach-Object {
Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force
@@ -96,21 +140,154 @@ if (Test-Path $ConfigSrc) {
}
}
Write-Host "Launch: $(Join-Path $OutDir 'OrisysStudio.exe')"
# The 3D pane refuses the whole scene when the device description cannot be derived, and that
# description needs the sensor manifest, the unroll map and the compiled package. A strip
# step that removed one of them would leave a pane that explains itself instead of drawing.
Write-Host "Checking the shipped 3D prerequisites ..."
$scenePackages = @(Get-ChildItem (Join-Path $ConfigDst "sensors") -Recurse -Filter "*.oryscene" -ErrorAction SilentlyContinue)
if ($scenePackages.Count -eq 0) {
Write-Warning "No *.oryscene package under config\sensors: the 3D pane will report 'no compiled scene package'."
} else {
foreach ($pkg in $scenePackages) { Write-Host " scene: $($pkg.FullName.Substring($ConfigDst.Length + 1))" }
}
$fingerCfg = Join-Path $ConfigDst "sensors\finger"
if (Test-Path $fingerCfg) {
$required = @("manifest.json", "sensor.json")
foreach ($name in $required) {
if (-not (Test-Path (Join-Path $fingerCfg $name))) { throw "the shipped finger config is missing $name" }
}
$manifest = Get-Content (Join-Path $fingerCfg "manifest.json") -Raw | ConvertFrom-Json
$assets = Join-Path $fingerCfg ([string]$manifest.assets_dir)
$sensor = Get-Content (Join-Path $fingerCfg "sensor.json") -Raw | ConvertFrom-Json
$scale = $sensor.camera.source_scale
$mapName = if ($null -eq $scale -or [double]$scale -eq 1.0) { "unroll_map_uv.npz" } else { "unroll_map_uv_s$([double]$scale).npz" }
if (-not (Test-Path (Join-Path $assets $mapName))) {
throw "the shipped finger assets have no $mapName (the device geometry description needs it; the pane would refuse the scene)"
}
Write-Host " finger assets: $($manifest.assets_dir) ($mapName present)"
}
# Size summary
# ------------------------------------------------------------------- 5. no Open3D
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'open3d' }
if ($o3dHits) {
Write-Warning "Open3D-related files found under dist (should be empty):"
$o3dHits | ForEach-Object { Write-Warning $_.FullName }
} else {
Write-Host "Open3D check: none found under dist."
}
# ----------------------------------------------------------------- 6. smoke test
# Start the EXE the way a user would and require the application's own log to say it came up.
# This is the step that would have caught every silent failure found in this build.
$smokeFailed = $false
$smokeWarned = $false
if (-not $SkipSmokeTest -and (Test-Path $Exe)) {
Write-Host ""
Write-Host "Smoke test: starting $Exe ..."
$prefPath = Join-Path $OutDir "preference.json"
$hadPref = Test-Path $prefPath
$prefBackup = $null
if ($hadPref) { $prefBackup = Get-Content $prefPath -Raw }
$logsDir = Join-Path $OutDir "outputs\logs"
$before = @()
$outputsExisted = Test-Path (Join-Path $OutDir "outputs")
if (Test-Path $logsDir) { $before = @(Get-ChildItem $logsDir -Filter *.log | ForEach-Object { $_.Name }) }
# The pane is shown for the finger sensor only; seeding the preference is how the build
# proves the *packaged* asset loads, not just that the window opened.
$sensorFor3D = ""
if (Test-Path $fingerCfg) { $sensorFor3D = "finger" }
$seed = [ordered]@{ language = "en_US"; theme = "dark"; view_preset = "live"; show_finger_3d = $true }
if ($sensorFor3D) { $seed["sensor_type"] = $sensorFor3D }
($seed | ConvertTo-Json) | ForEach-Object { Write-JsonFile $prefPath $_ }
$oldQpa = $env:QT_QPA_PLATFORM
$env:QT_QPA_PLATFORM = "windows"
$proc = $null
try {
$proc = Start-Process -FilePath $Exe -WorkingDirectory $OutDir -PassThru
# The scene is read on a worker thread *after* the shell is up, so the two lines
# arrive separately: waiting for both (rather than reading the log once) is what
# keeps this test from being a race.
$wanted = @("Studio shell started")
if ($sensorFor3D) { $wanted += "3D scene loaded" }
$deadline = (Get-Date).AddSeconds(75)
$logText = ""
$logPath = ""
while ((Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 1000
$fresh = Get-ChildItem $logsDir -Filter *.log -ErrorAction SilentlyContinue |
Where-Object { $before -notcontains $_.Name }
if ($fresh) {
$newest = $fresh | Sort-Object LastWriteTime | Select-Object -Last 1
$logPath = $newest.FullName
$logText = Get-Content $logPath -Raw -ErrorAction SilentlyContinue
$missing = @($wanted | Where-Object { $logText -notmatch [regex]::Escape($_) })
if ($missing.Count -eq 0) { break }
}
if ($proc.HasExited) { break }
}
if (-not $logText -or $logText -notmatch "Studio shell started") {
$smokeFailed = $true
Write-Warning "The produced EXE did not reach 'Studio shell started'."
if ($logPath) { Write-Warning "log: $logPath" } else { Write-Warning "no log was written at all" }
} else {
Write-Host " the application started"
if ($sensorFor3D -and $logText -notmatch "3D scene loaded") {
$smokeFailed = $true
Write-Warning "The 3D scene did not load from the shipped package."
# The loader logs the diagnostic it refused with; quoting it here is the
# difference between "the build failed" and "this asset was rejected".
$rejection = @($logText -split "`r?`n" | Where-Object { $_ -match "rejected|no compiled scene|asset_" })
if ($rejection) { Write-Warning (" " + $rejection[-1]) }
} elseif ($sensorFor3D) {
Write-Host " the packaged 3D scene loaded"
}
# A fallback to Qt's ANGLE backend cannot satisfy a desktop 3.3 core request, and
# the pane then reports itself unavailable on a machine whose GPU is fine. It is
# environment-dependent, so it is a warning unless -StrictSmokeTest is asked for.
if ($logText -match "QWindowsEGLContext|Failed to create context|context creation failed") {
$smokeWarned = $true
Write-Warning "The 3D widget could not create a GL context in this session."
Write-Warning " desktop33: set QT_OPENGL=desktop (Qt otherwise falls back to ANGLE, which is GLES-only)"
Write-Warning " or pin it in the app (Qt.AA_UseDesktopOpenGL / AA_UseOpenGLES) before QApplication."
}
}
if ($logPath) {
New-Item -ItemType Directory -Path $SmokeKeep -Force | Out-Null
$keepName = "smoke-{0}.log" -f (Get-Date).ToString("yyyyMMdd_HHmmss")
Copy-Item $logPath (Join-Path $SmokeKeep $keepName) -Force
Write-Host " smoke log kept: build\_smoke\$keepName"
}
} finally {
if ($proc -and -not $proc.HasExited) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
# The app is not given the chance to save preferences; restore what was there.
if ($hadPref) { Write-JsonFile $prefPath $prefBackup }
elseif (Test-Path $prefPath) { Remove-Item $prefPath -Force -ErrorAction SilentlyContinue }
if (-not $outputsExisted -and (Test-Path (Join-Path $OutDir "outputs"))) {
Remove-Item (Join-Path $OutDir "outputs") -Recurse -Force -ErrorAction SilentlyContinue
}
if ($null -eq $oldQpa) { Remove-Item Env:\QT_QPA_PLATFORM -ErrorAction SilentlyContinue }
else { $env:QT_QPA_PLATFORM = $oldQpa }
}
}
# ------------------------------------------------------------------- 7. summary
if (Test-Path $OutDir) {
$bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
$mb = [math]::Round($bytes / 1MB, 1)
Write-Host ("Output size: {0} MB" -f $mb)
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'open3d' }
if ($o3dHits) {
Write-Warning "Open3D-related files found under dist (should be empty):"
$o3dHits | ForEach-Object { Write-Warning $_.FullName }
} else {
Write-Host "Open3D check: none found under dist."
}
Write-Host "Launch: $Exe"
}
if ($smokeFailed -or ($StrictSmokeTest -and $smokeWarned)) {
Write-Host ""
Write-Host "FAILED: the build produced an EXE that did not pass the smoke test." -ForegroundColor Red
exit 1
}
exit 0
+32
View File
@@ -0,0 +1,32 @@
# Pinned build-time versions, applied with `pip install -c` by setup.ps1.
#
# The point is not tidiness: PyInstaller's output depends on the *environment* as much as
# on the spec (which Qt, which numpy, which hooks-contrib), so a build that "works" on one
# machine and produces a non-starting EXE on another is usually a version difference that
# nobody wrote down. `-c` constrains whatever else asks for these packages, so the app's
# own loose requirements (PySide6>=6.6, scipy>=1.10.1, ...) resolve to exactly this set.
#
# PySide6 rather than PyQt5: the apps are LGPLv3-bound. PyQt5 must not be present in the
# build environment at all — `pyqtgraph` binds to whichever toolkit it finds first, so a
# leftover PyQt5 produces plots built from the wrong binding (setup.ps1 uninstalls it).
#
# `orisys` is deliberately absent: it is installed from a local checkout (or deliberately
# from PyPI with -FromPypi), and pinning it by name would fight the editable install.
#
# To move a pin: change it here, run `setup.bat -Force`, and run the build's smoke test.
# That test is the acceptance evidence, not this file.
PyInstaller==6.22.2
pyinstaller-hooks-contrib==2026.7
PyOpenGL==3.1.10
PySide6==6.11.2
PySide6-Essentials==6.11.2
PySide6-Addons==6.11.2
shiboken6==6.11.2
numpy==2.2.6
scipy==1.15.3
numba==0.67.0
llvmlite==0.49.0
opencv-python-headless==4.14.0.94
pyqtgraph==0.14.0
cryptography==49.0.0
+102 -18
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from PyInstaller.building.api import COLLECT, EXE, PYZ
@@ -13,6 +15,50 @@ SPEC_DIR = Path(SPECPATH).resolve()
REPO_ROOT = SPEC_DIR.parents[1]
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
ENTRY = SPEC_DIR / "entry.py"
#: What ``setup.ps1`` recorded about this machine (interpreter, and where its runtime DLLs
#: live when that interpreter is not a python.org one).
BUILD_ENV_STAMP = REPO_ROOT / ".venv-build" / "orisys-build-env.json"
#: The runtime DLLs an Anaconda interpreter keeps in ``<prefix>\Library\bin`` or
#: ``<prefix>\DLLs``. PyInstaller does not search those directories, so a frozen app built
#: without them dies before it can log anything:
#: ``ImportError: DLL load failed while importing pyexpat`` (libexpat)
#: ``ImportError: DLL load failed while importing _ctypes`` (ffi-8)
#: Building with python.org CPython avoids the whole list; when ``setup.ps1`` accepts a conda
#: interpreter it records the directory, and this is where that decision is honoured.
_RUNTIME_DLLS = (
"libexpat.dll",
"expat.dll",
"ffi-8.dll",
"ffi-7.dll",
"ffi.dll",
"libssl-3-x64.dll",
"libcrypto-3-x64.dll",
"sqlite3.dll",
"liblzma.dll",
"libbz2.dll",
"zlib.dll",
)
def _extra_runtime_binaries() -> list[tuple[str, str]]:
"""Runtime DLLs from a non-python.org interpreter, as ``(source, dest)`` pairs."""
folders: list[str] = []
try:
stamp = json.loads(BUILD_ENV_STAMP.read_text(encoding="utf-8"))
folders.extend(str(item) for item in (stamp.get("extra_dll_dirs") or ()))
except Exception:
# No stamp (a spec run by hand): fall back to the environment, if it says it is conda.
prefix = os.environ.get("CONDA_PREFIX")
if prefix:
folders.append(os.path.join(prefix, "Library", "bin"))
out: list[tuple[str, str]] = []
for folder in folders:
for name in _RUNTIME_DLLS:
candidate = Path(folder) / name
if candidate.is_file():
out.append((str(candidate), "."))
return out
def _branding_datas() -> list[tuple[str, str]]:
@@ -30,10 +76,15 @@ block_cipher = None
# Config is copied beside the EXE by build.ps1 (writable / editable).
# Branding + package data go into _internal via Analysis datas.
datas = _branding_datas()
binaries: list = []
binaries: list = _extra_runtime_binaries()
hiddenimports = [
"cv2",
"PyQt5.sip",
# PySide6's runtime is shiboken6; the Qt binding itself is found from the imports, but
# QtOpenGLWidgets is unusual enough to name explicitly: it is a separate module and DLL
# (Qt6OpenGLWidgets), and without it the 3D pane reports "no OpenGL widgets" while the
# rest of the app looks fine.
"shiboken6",
"PySide6.QtOpenGLWidgets",
"pyqtgraph.graphicsItems",
"scipy.signal",
"numba",
@@ -68,29 +119,62 @@ a = Analysis(
runtime_hooks=[],
excludes=[
"open3d",
"orisys.finger.viewer",
# NOTE: `orisys.finger` imports `.viewer` at module level, and `finger/viewer.py`
# imports open3d *inside* its functions — so bundling it costs a few KB of Python and
# pulls in no Open3D. Excluding it made `import orisys.finger` raise
# ModuleNotFoundError in the frozen app, which took the whole window down with it
# (the app cannot even reach logging). The `open3d` entry above is the one that
# matters.
"tkinter",
"matplotlib",
"IPython",
"jupyter",
"notebook",
"pytest",
"PyQt5.QtBluetooth",
"PyQt5.QtNfc",
"PyQt5.QtPositioning",
"PyQt5.QtLocation",
"PyQt5.QtMultimedia",
"PyQt5.QtMultimediaWidgets",
"PyQt5.QtSensors",
"PyQt5.QtSerialPort",
"PyQt5.QtWebChannel",
"PyQt5.QtWebEngine",
"PyQt5.QtWebEngineCore",
"PyQt5.QtWebEngineWidgets",
"PyQt5.QtWebSockets",
"PySide2",
"PySide6",
# The app uses PySide6 only. Qt modules it never imports are excluded to keep the
# bundle small — but `"PySide6"` itself must NOT appear here: excluding the whole
# binding produces an EXE with no Qt at all (it fails at start-up, not at build
# time). `PySide6.QtOpenGLWidgets` is required by the 3D pane and is deliberately
# absent from this list.
"PySide6.Qt3DAnimation",
"PySide6.Qt3DCore",
"PySide6.Qt3DExtras",
"PySide6.Qt3DInput",
"PySide6.Qt3DLogic",
"PySide6.Qt3DRender",
"PySide6.QtBluetooth",
"PySide6.QtCharts",
"PySide6.QtDataVisualization",
"PySide6.QtGraphs",
"PySide6.QtHelp",
"PySide6.QtLocation",
"PySide6.QtMultimedia",
"PySide6.QtMultimediaWidgets",
"PySide6.QtNfc",
"PySide6.QtPdf",
"PySide6.QtPdfWidgets",
"PySide6.QtPositioning",
"PySide6.QtQml",
"PySide6.QtQuick",
"PySide6.QtQuick3D",
"PySide6.QtQuickControls2",
"PySide6.QtQuickWidgets",
"PySide6.QtRemoteObjects",
"PySide6.QtScxml",
"PySide6.QtSensors",
"PySide6.QtSerialBus",
"PySide6.QtSerialPort",
"PySide6.QtSpatialAudio",
"PySide6.QtStateMachine",
"PySide6.QtTextToSpeech",
"PySide6.QtWebChannel",
"PySide6.QtWebEngineCore",
"PySide6.QtWebEngineQuick",
"PySide6.QtWebEngineWidgets",
"PySide6.QtWebSockets",
"PyQt5",
"PyQt6",
"PySide2",
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
+21
View File
@@ -0,0 +1,21 @@
@echo off
setlocal EnableExtensions
REM Provision the Orisys Studio build environment (run once per machine).
REM Works from any cwd; forwards args to setup.ps1
REM Examples:
REM setup.bat
REM setup.bat -Force
REM setup.bat -Python C:\Python310\python.exe
REM setup.bat -AllowConda
set "SCRIPT_DIR=%~dp0"
set "PS1=%SCRIPT_DIR%setup.ps1"
if not exist "%PS1%" (
echo ERROR: setup.ps1 not found at "%PS1%"
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS1%" %*
set "ERR=%ERRORLEVEL%"
exit /b %ERR%

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