Compare commits
3 Commits
c538dbf074
...
ccedb9ddd9
| Author | SHA1 | Date | |
|---|---|---|---|
| ccedb9ddd9 | |||
| e276b5de0f | |||
| 07d7c3bad6 |
@@ -5,6 +5,7 @@ __pycache__/
|
|||||||
.eggs/
|
.eggs/
|
||||||
dist/
|
dist/
|
||||||
build/_pyinstaller/
|
build/_pyinstaller/
|
||||||
|
build/_smoke/
|
||||||
*.egg
|
*.egg
|
||||||
.venv/
|
.venv/
|
||||||
.venv-build/
|
.venv-build/
|
||||||
|
|||||||
@@ -2,18 +2,42 @@
|
|||||||
|
|
||||||
Desktop applications that consume the Orisys SDK.
|
Desktop applications that consume the Orisys SDK.
|
||||||
|
|
||||||
| App | Status |
|
| App | Status | Qt binding |
|
||||||
|-----|--------|
|
|-----|--------|-----------|
|
||||||
| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) |
|
| `apps/studio` | The customer-facing shell (theme / locale / prefs / logging, acquisition, 3D) | **PySide6** |
|
||||||
| `apps/internal` | Stub for future calibration & automated testing |
|
| `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
|
## 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/config/` — locales, default prefs, editable `sensors/` copies
|
||||||
- `apps/studio/outputs/` — generated logs and calibration exports (gitignored)
|
- `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`)
|
- `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)
|
## Run (studio shell)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` |
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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])
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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 |
@@ -33,6 +33,10 @@
|
|||||||
"scene3d_settings": "3D display settings…",
|
"scene3d_settings": "3D display settings…",
|
||||||
"show_fps": "Show FPS",
|
"show_fps": "Show FPS",
|
||||||
"show_diagnostics": "Diagnostics",
|
"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",
|
"settings": "Settings area",
|
||||||
"plot_settings": "Plot settings…",
|
"plot_settings": "Plot settings…",
|
||||||
"rotate_arrows": "Rotate arrows 90°",
|
"rotate_arrows": "Rotate arrows 90°",
|
||||||
@@ -217,6 +221,8 @@
|
|||||||
"selection_empty": "No valid samples in the selection",
|
"selection_empty": "No valid samples in the selection",
|
||||||
"value_unavailable": "n/a",
|
"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)",
|
"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": "Switch unit",
|
||||||
"unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units",
|
"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"
|
"browse_only_tip": "Drag or scroll on the chart to browse history"
|
||||||
@@ -412,6 +418,12 @@
|
|||||||
"field_depth": "Depth response",
|
"field_depth": "Depth response",
|
||||||
"field_flow": "Optical flow magnitude",
|
"field_flow": "Optical flow magnitude",
|
||||||
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
|
"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_flow_magnitude": "Optical flow magnitude",
|
||||||
"quantity_depth_response": "Depth response",
|
"quantity_depth_response": "Depth response",
|
||||||
"mode_surface": "surface colouring",
|
"mode_surface": "surface colouring",
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
"scene3d_settings": "三维显示设置…",
|
"scene3d_settings": "三维显示设置…",
|
||||||
"show_fps": "显示 FPS",
|
"show_fps": "显示 FPS",
|
||||||
"show_diagnostics": "诊断信息",
|
"show_diagnostics": "诊断信息",
|
||||||
|
"raw_auto_zero": "Finger raw 启动归零",
|
||||||
|
"raw_auto_zero_tip": "仅影响数字卡展示:启动后自动估计无载偏置与死区。曲线、统计与 CSV 仍为测量值。",
|
||||||
|
"raw_reestimate": "重新估计 raw 基线…",
|
||||||
|
"raw_reestimate_tip": "丢弃当前展示基线,从接下来的无载样本重新估计(仅数字卡)。",
|
||||||
"settings": "设置区",
|
"settings": "设置区",
|
||||||
"plot_settings": "绘图设置…",
|
"plot_settings": "绘图设置…",
|
||||||
"rotate_arrows": "旋转箭头图 90°",
|
"rotate_arrows": "旋转箭头图 90°",
|
||||||
@@ -217,6 +221,8 @@
|
|||||||
"selection_empty": "选区无有效样本",
|
"selection_empty": "选区无有效样本",
|
||||||
"value_unavailable": "不可用",
|
"value_unavailable": "不可用",
|
||||||
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
|
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
|
||||||
|
"zero_zone": "零区",
|
||||||
|
"zero_zone_tip": "展示归零后的死区内读数;曲线、统计与 CSV 仍为原始测量值",
|
||||||
"unit_toggle": "切换单位",
|
"unit_toggle": "切换单位",
|
||||||
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
|
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
|
||||||
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
|
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
|
||||||
@@ -412,6 +418,12 @@
|
|||||||
"field_depth": "深度响应",
|
"field_depth": "深度响应",
|
||||||
"field_flow": "光流幅值",
|
"field_flow": "光流幅值",
|
||||||
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
|
"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_flow_magnitude": "光流幅值",
|
||||||
"quantity_depth_response": "深度响应",
|
"quantity_depth_response": "深度响应",
|
||||||
"mode_surface": "曲面着色",
|
"mode_surface": "曲面着色",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"show_cal_panel": false,
|
"show_cal_panel": false,
|
||||||
"show_finger_3d": true,
|
"show_finger_3d": true,
|
||||||
"show_fps": false,
|
"show_fps": false,
|
||||||
|
"raw_auto_zero": true,
|
||||||
"gl_profile": "desktop33",
|
"gl_profile": "desktop33",
|
||||||
"scene3d_package": "",
|
"scene3d_package": "",
|
||||||
"scene3d_display": {
|
"scene3d_display": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"unit": "N",
|
"unit": "N",
|
||||||
"channels": {
|
"channels": {
|
||||||
"normal": {
|
"normal": {
|
||||||
@@ -14,8 +14,22 @@
|
|||||||
[62000.0, 2.377]
|
[62000.0, 2.377]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"shear": {
|
"shear_x": {
|
||||||
"method": "piecewise_linear",
|
"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",
|
"input": "components",
|
||||||
"signed": true,
|
"signed": true,
|
||||||
"extrapolate": true,
|
"extrapolate": true,
|
||||||
|
|||||||
Binary file not shown.
@@ -9,7 +9,10 @@ description = "Orisys Studio desktop app"
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "Orisys-Tech" }]
|
authors = [{ name = "Orisys-Tech" }]
|
||||||
dependencies = [
|
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-app-shared>=0.1.0",
|
||||||
"orisys>=0.5",
|
"orisys>=0.5",
|
||||||
"scipy>=1.10.1",
|
"scipy>=1.10.1",
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer
|
||||||
from PyQt5.QtGui import QIcon, QImage
|
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAction,
|
|
||||||
QActionGroup,
|
|
||||||
QApplication,
|
QApplication,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -240,7 +238,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
root.addWidget(self.toolbar)
|
root.addWidget(self.toolbar)
|
||||||
self.status_chip = self.toolbar.status_chip
|
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.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||||
@@ -888,7 +886,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# convention everyone's hands already know.
|
# convention everyone's hands already know.
|
||||||
self.act_play = QAction(self)
|
self.act_play = QAction(self)
|
||||||
self.act_play.setShortcut("Space")
|
self.act_play.setShortcut("Space")
|
||||||
self.act_play.setShortcutContext(Qt.ApplicationShortcut)
|
self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
||||||
self.addAction(self.act_play)
|
self.addAction(self.act_play)
|
||||||
|
|
||||||
self.view_menu = menu_bar.addMenu("")
|
self.view_menu = menu_bar.addMenu("")
|
||||||
@@ -925,6 +923,16 @@ class StudioMainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self.view_menu.addAction(self.act_show_diagnostics)
|
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 = QAction(self)
|
||||||
self.act_settings.setCheckable(True)
|
self.act_settings.setCheckable(True)
|
||||||
self.act_settings.setChecked(self.toolbar.settings_expanded())
|
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_scene3d_settings.setText(t("menu.scene3d_settings"))
|
||||||
self.act_show_fps.setText(t("menu.show_fps"))
|
self.act_show_fps.setText(t("menu.show_fps"))
|
||||||
self.act_show_diagnostics.setText(t("menu.show_diagnostics"))
|
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_settings.setText(t("menu.settings"))
|
||||||
self.act_plot_settings.setText(t("menu.plot_settings"))
|
self.act_plot_settings.setText(t("menu.plot_settings"))
|
||||||
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
|
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
|
||||||
@@ -1388,7 +1400,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._persist_prefs()
|
self._persist_prefs()
|
||||||
|
|
||||||
def _show_about(self) -> None:
|
def _show_about(self) -> None:
|
||||||
AboutDialog(self).exec_()
|
AboutDialog(self).exec()
|
||||||
|
|
||||||
def _on_camera_id_changed(self, value: int) -> None:
|
def _on_camera_id_changed(self, value: int) -> None:
|
||||||
self.prefs["camera_id"] = str(int(value))
|
self.prefs["camera_id"] = str(int(value))
|
||||||
@@ -1493,7 +1505,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
filters_locked=bool(self._running or self.recorder.is_recording),
|
filters_locked=bool(self._running or self.recorder.is_recording),
|
||||||
sample_rate_hz=self.display_ctl.device_sample_rate(),
|
sample_rate_hz=self.display_ctl.device_sample_rate(),
|
||||||
)
|
)
|
||||||
if dialog.exec_() != PlotSettingsDialog.Accepted:
|
if dialog.exec() != PlotSettingsDialog.Accepted:
|
||||||
return
|
return
|
||||||
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
|
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
|
||||||
self.display_ctl.apply_plot_settings(
|
self.display_ctl.apply_plot_settings(
|
||||||
@@ -1575,10 +1587,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
sensor_type=sensor_type,
|
sensor_type=sensor_type,
|
||||||
config=config_ref,
|
config=config_ref,
|
||||||
),
|
),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
QMessageBox.No,
|
QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.Yes:
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
self.recorder.log_event(
|
self.recorder.log_event(
|
||||||
"calibration_apply_blocked", "; ".join(reasons)
|
"calibration_apply_blocked", "; ".join(reasons)
|
||||||
)
|
)
|
||||||
@@ -1588,10 +1600,10 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self,
|
self,
|
||||||
t("cal.apply_title"),
|
t("cal.apply_title"),
|
||||||
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
QMessageBox.No,
|
QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if answer != QMessageBox.Yes:
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
self.statusBar().showMessage(t("cal.apply_skipped"))
|
self.statusBar().showMessage(t("cal.apply_skipped"))
|
||||||
return
|
return
|
||||||
worker = self._worker_for(sensor_type)
|
worker = self._worker_for(sensor_type)
|
||||||
@@ -1706,7 +1718,18 @@ class StudioMainWindow(QMainWindow):
|
|||||||
if getattr(self, "act_run", None) is None:
|
if getattr(self, "act_run", None) is None:
|
||||||
return
|
return
|
||||||
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
|
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_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
|
||||||
self.act_reset.setEnabled(self.toolbar.btn_reset.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
|
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
|
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.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()
|
self.replay_ctl.stop_activity()
|
||||||
return
|
return
|
||||||
if not self._session_active:
|
if not self._session_active:
|
||||||
@@ -1886,6 +1915,16 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self._stop_preview(announce=False)
|
self._stop_preview(announce=False)
|
||||||
|
|
||||||
self.force_plots.reset()
|
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_plot_packet_key = None
|
||||||
self._last_rendered_view_mode = None
|
self._last_rendered_view_mode = None
|
||||||
# Fresh record channel for the new session. The calibration identity is
|
# 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.session.last_sample_valid = True
|
||||||
self._last_display_fallback = bool(display.using_raw_fallback)
|
self._last_display_fallback = bool(display.using_raw_fallback)
|
||||||
self._last_display_available = tuple(bool(flag) for flag in display.available)
|
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(
|
self.force_plots.push(
|
||||||
# Session-relative time, the same base the store and
|
# Session-relative time, the same base the store and
|
||||||
# ``forces.csv`` use. The wall clock must never reach the
|
# ``forces.csv`` use. The wall clock must never reach the
|
||||||
@@ -2136,6 +2199,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
display.values[2],
|
display.values[2],
|
||||||
unit=display.unit,
|
unit=display.unit,
|
||||||
available=display.available,
|
available=display.available,
|
||||||
|
flow_p95=flow_p95,
|
||||||
|
contact_found=contact_found,
|
||||||
|
forces_valid=bool(getattr(packet, "forces_valid", True)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.session.last_sample_valid = False
|
self.session.last_sample_valid = False
|
||||||
@@ -2226,6 +2292,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
def _render_run_image(self, packet) -> None:
|
def _render_run_image(self, packet) -> None:
|
||||||
if packet is None or self._replay_owns_image():
|
if packet is None or self._replay_owns_image():
|
||||||
return
|
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(
|
self._show_image_frame(
|
||||||
ImageFrame(
|
ImageFrame(
|
||||||
getattr(packet, "processed_frame", None),
|
getattr(packet, "processed_frame", None),
|
||||||
@@ -2235,6 +2306,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
source=SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE,
|
source=SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE,
|
||||||
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
frame_id=int(getattr(packet, "frame_id", 0) or 0),
|
||||||
t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.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")
|
geo = self.prefs.get("window_geometry")
|
||||||
if isinstance(geo, str) and geo:
|
if isinstance(geo, str) and geo:
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtCore import QByteArray
|
from PySide6.QtCore import QByteArray
|
||||||
|
|
||||||
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
|
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject
|
from PySide6.QtCore import QObject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -84,7 +84,10 @@ class DisplayPrefsController(QObject):
|
|||||||
#: Whether the panels explain the pipeline around the picture (see
|
#: Whether the panels explain the pipeline around the picture (see
|
||||||
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
|
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
|
||||||
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
|
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_diagnostics()
|
||||||
|
self.apply_presentation_prefs()
|
||||||
|
|
||||||
def bind_actions(self, *, rotate_arrows: Any) -> None:
|
def bind_actions(self, *, rotate_arrows: Any) -> None:
|
||||||
"""Take the rotate entry once the menus exist (it follows the sensor)."""
|
"""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)
|
setter = getattr(panel, "set_diagnostics_visible", None)
|
||||||
if callable(setter):
|
if callable(setter):
|
||||||
setter(self._show_diagnostics)
|
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()
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
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 import API_VERSION, __version__ as shared_version
|
||||||
from orisys_app_shared.compat import check_shared_compat
|
from orisys_app_shared.compat import check_shared_compat
|
||||||
@@ -85,7 +90,7 @@ def main() -> int:
|
|||||||
language,
|
language,
|
||||||
gl_profile,
|
gl_profile,
|
||||||
)
|
)
|
||||||
code = int(app.exec_())
|
code = int(app.exec())
|
||||||
|
|
||||||
# Leave the process immediately.
|
# Leave the process immediately.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
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.i18n import t
|
||||||
from orisys_app_shared.record import RecordingReader, VideoOnlySource
|
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."""
|
"""Open one recording and answer "what is at t?" for every view."""
|
||||||
|
|
||||||
#: The cursor moved; the chart cursor and the 2D panel follow.
|
#: 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.
|
#: The frame under the cursor changed.
|
||||||
frames_changed = pyqtSignal()
|
frames_changed = Signal()
|
||||||
#: The notes about this recording, or the loaded recording, changed.
|
#: 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).
|
#: 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:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import QObject, QTimer
|
from PySide6.QtCore import QObject, QTimer
|
||||||
from PyQt5.QtWidgets import QFileDialog
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
|
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|||||||
@@ -392,6 +392,26 @@ class FingerFrameAdapter:
|
|||||||
refused.append(mask_problem)
|
refused.append(mask_problem)
|
||||||
return None
|
return None
|
||||||
self.last_diagnostic = 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(
|
return SurfaceFieldFrame(
|
||||||
identity=identity,
|
identity=identity,
|
||||||
geometry_id=self.geometry_id,
|
geometry_id=self.geometry_id,
|
||||||
@@ -400,6 +420,12 @@ class FingerFrameAdapter:
|
|||||||
scalar_descriptor=descriptor,
|
scalar_descriptor=descriptor,
|
||||||
valid=_intersect(mask, device),
|
valid=_intersect(mask, device),
|
||||||
computed_frame_id=_computed_frame_id(packet, name, identity.frame_id),
|
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 pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
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.paths import config_dir
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -180,8 +180,8 @@ class SceneAssetLoader(QObject):
|
|||||||
selection the operator has already moved away from.
|
selection the operator has already moved away from.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device
|
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
|
||||||
failed = pyqtSignal(object, int) # Diagnostic, request id
|
failed = Signal(object, int) # Diagnostic, request id
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|||||||
@@ -462,17 +462,24 @@ class ThreeDCoordinator:
|
|||||||
self._set_state("no_data")
|
self._set_state("no_data")
|
||||||
|
|
||||||
def set_settings(self, settings: DisplaySettings) -> None:
|
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
|
previous = self._settings
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._widget.set_settings(settings)
|
self._widget.set_settings(settings)
|
||||||
gain_changed = (
|
needs_recompose = (
|
||||||
previous.display_gain != settings.display_gain
|
previous.display_gain != settings.display_gain
|
||||||
or previous.deform_enabled != settings.deform_enabled
|
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:
|
if needs_recompose and self._assets is not None:
|
||||||
# The scalar is untouched by the gain, but the *positions* are not, so
|
# Gain moves positions; overlay toggles rebuild arrow instances on the
|
||||||
# the last field frame has to be composed again.
|
# held field frame so the operator sees the change without waiting.
|
||||||
self._settings_dirty = True
|
self._settings_dirty = True
|
||||||
|
|
||||||
def show_rest(self) -> None:
|
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
|
component must not slow down (the acquisition loop feeds the scene through a
|
||||||
capacity-one mailbox), and the design document defers anti-aliasing work.
|
capacity-one mailbox), and the design document defers anti-aliasing work.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtGui import QSurfaceFormat
|
from PySide6.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
fmt = QSurfaceFormat()
|
fmt = QSurfaceFormat()
|
||||||
if profile == "gles30":
|
if profile == "gles30":
|
||||||
fmt.setRenderableType(QSurfaceFormat.OpenGLES)
|
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES)
|
||||||
fmt.setVersion(3, 0)
|
fmt.setVersion(3, 0)
|
||||||
elif profile == "desktop33":
|
elif profile == "desktop33":
|
||||||
fmt.setRenderableType(QSurfaceFormat.OpenGL)
|
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
|
||||||
fmt.setVersion(3, 3)
|
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
|
else: # pragma: no cover - a typo must not reach a board silently
|
||||||
raise ValueError(f"unknown GL profile: {profile!r}")
|
raise ValueError(f"unknown GL profile: {profile!r}")
|
||||||
fmt.setDepthBufferSize(24)
|
fmt.setDepthBufferSize(24)
|
||||||
fmt.setStencilBufferSize(8)
|
fmt.setStencilBufferSize(8)
|
||||||
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
|
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
|
||||||
fmt.setSamples(0)
|
fmt.setSamples(0)
|
||||||
QSurfaceFormat.setDefaultFormat(fmt)
|
QSurfaceFormat.setDefaultFormat(fmt)
|
||||||
|
|
||||||
@@ -190,6 +190,31 @@ def shader_header(profile: Profile) -> str:
|
|||||||
raise ValueError(f"unknown GL profile: {profile!r}") from None
|
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]:
|
def shader_source(profile: Profile, program: str) -> tuple[str, str]:
|
||||||
"""``(vertex, fragment)`` source for a profile and a program name.
|
"""``(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)
|
header = shader_header(profile)
|
||||||
if program == "scalar":
|
if program == "scalar":
|
||||||
body = _SCALAR_FRAGMENT_BODY
|
body = _SCALAR_FRAGMENT_BODY
|
||||||
|
vertex = _VERTEX_BODY
|
||||||
elif program == "diffuse":
|
elif program == "diffuse":
|
||||||
body = _DIFFUSE_FRAGMENT_BODY
|
body = _DIFFUSE_FRAGMENT_BODY
|
||||||
|
vertex = _VERTEX_BODY
|
||||||
|
elif program == "arrow":
|
||||||
|
return header + _ARROW_VERTEX_BODY, header + _ARROW_FRAGMENT_BODY
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"unknown shader program: {program!r}")
|
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]]:
|
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
|
Using min/max made empty-field noise paint the whole LUT. A percentile span
|
||||||
colour range follow whatever garbage sits outside the UV island, and including
|
with a minimum width keeps weak signals visible without amplifying floor
|
||||||
NaN would produce a range of NaN and a picture with no colours at all.
|
noise into a full contact.
|
||||||
"""
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -222,9 +251,39 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
|
|||||||
usable = values[mask & np.isfinite(values)]
|
usable = values[mask & np.isfinite(values)]
|
||||||
if usable.size == 0:
|
if usable.size == 0:
|
||||||
return None
|
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]:
|
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.
|
"""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():
|
if context is None or not context.isValid():
|
||||||
raise error("gl_unsupported", "no current OpenGL context")
|
raise error("gl_unsupported", "no current OpenGL context")
|
||||||
fmt = context.format()
|
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:
|
try:
|
||||||
vendor = _gl_string(GL, GL.GL_VENDOR)
|
vendor = _gl_string(GL, GL.GL_VENDOR)
|
||||||
renderer = _gl_string(GL, GL.GL_RENDERER)
|
renderer = _gl_string(GL, GL.GL_RENDERER)
|
||||||
@@ -395,7 +457,7 @@ class GLDispatch:
|
|||||||
"gl_unsupported",
|
"gl_unsupported",
|
||||||
f"the context answered no queries: {type(exc).__name__}: {exc}",
|
f"the context answered no queries: {type(exc).__name__}: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
from PyQt5.QtGui import QGuiApplication
|
from PySide6.QtGui import QGuiApplication
|
||||||
|
|
||||||
platform_name = ""
|
platform_name = ""
|
||||||
try:
|
try:
|
||||||
@@ -514,6 +576,10 @@ class GLRenderer:
|
|||||||
self._initialized = False
|
self._initialized = False
|
||||||
self._paint_calls = 0
|
self._paint_calls = 0
|
||||||
self._last_upload_ms = 0.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
|
# ------------------------------------------------------------------ lifetime
|
||||||
def initialize(self, capabilities: GLCapabilities) -> None:
|
def initialize(self, capabilities: GLCapabilities) -> None:
|
||||||
@@ -529,6 +595,7 @@ class GLRenderer:
|
|||||||
try:
|
try:
|
||||||
self._programs["scalar"] = _compile_program(GL, capabilities.profile, "scalar")
|
self._programs["scalar"] = _compile_program(GL, capabilities.profile, "scalar")
|
||||||
self._programs["diffuse"] = _compile_program(GL, capabilities.profile, "diffuse")
|
self._programs["diffuse"] = _compile_program(GL, capabilities.profile, "diffuse")
|
||||||
|
self._programs["arrow"] = _compile_program(GL, capabilities.profile, "arrow")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.release()
|
self.release()
|
||||||
raise error(
|
raise error(
|
||||||
@@ -604,7 +671,74 @@ class GLRenderer:
|
|||||||
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
|
||||||
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
|
||||||
self._last_upload_ms = (time.perf_counter() - started) * 1000.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(
|
def draw(
|
||||||
self,
|
self,
|
||||||
@@ -659,6 +793,27 @@ class GLRenderer:
|
|||||||
GL.GL_TRIANGLES, part.triangles * 3, GL.GL_UNSIGNED_INT, None
|
GL.GL_TRIANGLES, part.triangles * 3, GL.GL_UNSIGNED_INT, None
|
||||||
)
|
)
|
||||||
GL.glBindVertexArray(0)
|
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)
|
GL.glUseProgram(0)
|
||||||
self._paint_calls += 1
|
self._paint_calls += 1
|
||||||
return RenderStats(
|
return RenderStats(
|
||||||
@@ -687,6 +842,8 @@ class GLRenderer:
|
|||||||
from OpenGL import GL
|
from OpenGL import GL
|
||||||
|
|
||||||
self.release_parts()
|
self.release_parts()
|
||||||
|
for kind in list(self._overlay_vaos):
|
||||||
|
self._clear_overlay(kind)
|
||||||
for program in self._programs.values():
|
for program in self._programs.values():
|
||||||
try:
|
try:
|
||||||
GL.glDeleteProgram(int(program))
|
GL.glDeleteProgram(int(program))
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Optional
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtGui import QImage
|
from PySide6.QtGui import QImage
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
@@ -87,7 +87,7 @@ class ColorScaleBar(QLabel):
|
|||||||
self.setObjectName("colorScaleBar")
|
self.setObjectName("colorScaleBar")
|
||||||
self.setFixedHeight(12)
|
self.setFixedHeight(12)
|
||||||
self.setMinimumWidth(120)
|
self.setMinimumWidth(120)
|
||||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
self._lut_id = "sequential"
|
self._lut_id = "sequential"
|
||||||
|
|
||||||
@@ -99,14 +99,14 @@ class ColorScaleBar(QLabel):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
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
|
from orisys_studio.scene3d.gl_backend import lut_rgba
|
||||||
|
|
||||||
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
|
||||||
# which is what QImage's borrowed buffer needs.
|
# which is what QImage's borrowed buffer needs.
|
||||||
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
|
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 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 = QPainter(self)
|
||||||
painter.drawImage(self.rect(), image)
|
painter.drawImage(self.rect(), image)
|
||||||
painter.end()
|
painter.end()
|
||||||
@@ -115,10 +115,10 @@ class ColorScaleBar(QLabel):
|
|||||||
class ThreeDPanel(QFrame):
|
class ThreeDPanel(QFrame):
|
||||||
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
|
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
|
||||||
|
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
settingsChanged = pyqtSignal(object) # DisplaySettings
|
settingsChanged = Signal(object) # DisplaySettings
|
||||||
resetRequested = pyqtSignal()
|
resetRequested = Signal()
|
||||||
presetRequested = pyqtSignal(str)
|
presetRequested = Signal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -222,6 +222,16 @@ class ThreeDPanel(QFrame):
|
|||||||
self.field_note.setVisible(False)
|
self.field_note.setVisible(False)
|
||||||
form.addRow(self.field_note)
|
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_label = QLabel()
|
||||||
self.scale_combo = QComboBox()
|
self.scale_combo = QComboBox()
|
||||||
self.scale_combo.addItem("fixed", "fixed")
|
self.scale_combo.addItem("fixed", "fixed")
|
||||||
@@ -289,13 +299,13 @@ class ThreeDPanel(QFrame):
|
|||||||
scale_row.setSpacing(6)
|
scale_row.setSpacing(6)
|
||||||
self.scale_lower_label = QLabel()
|
self.scale_lower_label = QLabel()
|
||||||
self.scale_lower_label.setObjectName("panelMeta")
|
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)
|
scale_row.addWidget(self.scale_lower_label)
|
||||||
self.scale_bar = ColorScaleBar()
|
self.scale_bar = ColorScaleBar()
|
||||||
scale_row.addWidget(self.scale_bar, 1)
|
scale_row.addWidget(self.scale_bar, 1)
|
||||||
self.scale_upper_label = QLabel()
|
self.scale_upper_label = QLabel()
|
||||||
self.scale_upper_label.setObjectName("panelMeta")
|
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)
|
scale_row.addWidget(self.scale_upper_label)
|
||||||
root.addLayout(scale_row)
|
root.addLayout(scale_row)
|
||||||
|
|
||||||
@@ -317,7 +327,7 @@ class ThreeDPanel(QFrame):
|
|||||||
self.source_label = ShrinkableLabel()
|
self.source_label = ShrinkableLabel()
|
||||||
self.source_label.setObjectName("panelMeta")
|
self.source_label.setObjectName("panelMeta")
|
||||||
self.source_label.setWordWrap(False)
|
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)
|
legend_row.addWidget(self.source_label, 2)
|
||||||
root.addLayout(legend_row)
|
root.addLayout(legend_row)
|
||||||
|
|
||||||
@@ -403,6 +413,18 @@ class ThreeDPanel(QFrame):
|
|||||||
self.deform_check.setChecked(False)
|
self.deform_check.setChecked(False)
|
||||||
finally:
|
finally:
|
||||||
self._syncing = False
|
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]:
|
def capabilities(self) -> Optional[SceneCapabilities]:
|
||||||
return self._capabilities
|
return self._capabilities
|
||||||
@@ -604,6 +626,8 @@ class ThreeDPanel(QFrame):
|
|||||||
self.housing_check,
|
self.housing_check,
|
||||||
self.deform_check,
|
self.deform_check,
|
||||||
self.gain_spin,
|
self.gain_spin,
|
||||||
|
self.flow_arrows_check,
|
||||||
|
self.contact_arrow_check,
|
||||||
self.scale_combo,
|
self.scale_combo,
|
||||||
self.fps_combo,
|
self.fps_combo,
|
||||||
self.reset_button,
|
self.reset_button,
|
||||||
@@ -635,6 +659,8 @@ class ThreeDPanel(QFrame):
|
|||||||
index = self.field_combo.findData(str(settings.field))
|
index = self.field_combo.findData(str(settings.field))
|
||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.field_combo.setCurrentIndex(index)
|
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
|
scale = settings.color_scale
|
||||||
if scale is not None:
|
if scale is not None:
|
||||||
index = self.scale_combo.findData(str(scale.mode))
|
index = self.scale_combo.findData(str(scale.mode))
|
||||||
@@ -678,6 +704,8 @@ class ThreeDPanel(QFrame):
|
|||||||
housing_visible=self.housing_check.isChecked(),
|
housing_visible=self.housing_check.isChecked(),
|
||||||
max_fps=int(self.fps_combo.currentData()),
|
max_fps=int(self.fps_combo.currentData()),
|
||||||
field=self.field(),
|
field=self.field(),
|
||||||
|
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
|
||||||
|
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ signals
|
# ------------------------------------------------------------------ signals
|
||||||
@@ -730,6 +758,8 @@ class ThreeDPanel(QFrame):
|
|||||||
self.field_combo.setItemText(0, t("scene3d.field_depth"))
|
self.field_combo.setItemText(0, t("scene3d.field_depth"))
|
||||||
self.field_combo.setItemText(1, t("scene3d.field_flow"))
|
self.field_combo.setItemText(1, t("scene3d.field_flow"))
|
||||||
self._render_field_note()
|
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.scale_label.setText(t("scene3d.scale"))
|
||||||
self.range_label.setText(t("scene3d.scale_range"))
|
self.range_label.setText(t("scene3d.scale_range"))
|
||||||
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
|
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),
|
"housing_visible": bool(settings.housing_visible),
|
||||||
"max_fps": int(settings.max_fps),
|
"max_fps": int(settings.max_fps),
|
||||||
"field": str(settings.field),
|
"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:
|
if scale is not None:
|
||||||
block["color_scale"] = {
|
block["color_scale"] = {
|
||||||
@@ -138,6 +144,16 @@ def settings_from_prefs(
|
|||||||
lut_id=str(saved.get("lut_id") or "sequential"),
|
lut_id=str(saved.get("lut_id") or "sequential"),
|
||||||
mode=mode, # type: ignore[arg-type]
|
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(
|
return DisplaySettings(
|
||||||
display_gain=gain,
|
display_gain=gain,
|
||||||
deform_enabled=_flag(raw.get("deform_enabled"), False),
|
deform_enabled=_flag(raw.get("deform_enabled"), False),
|
||||||
@@ -145,4 +161,10 @@ def settings_from_prefs(
|
|||||||
housing_visible=_flag(raw.get("housing_visible"), True),
|
housing_visible=_flag(raw.get("housing_visible"), True),
|
||||||
max_fps=max_fps,
|
max_fps=max_fps,
|
||||||
field=field,
|
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 typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -60,7 +60,7 @@ class Scene3dSettingsDialog(QDialog):
|
|||||||
root.addWidget(panel.controls_widget)
|
root.addWidget(panel.controls_widget)
|
||||||
panel.controls_widget.setVisible(True)
|
panel.controls_widget.setVisible(True)
|
||||||
|
|
||||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
self.buttons.rejected.connect(self.close)
|
self.buttons.rejected.connect(self.close)
|
||||||
self.buttons.accepted.connect(self.close)
|
self.buttons.accepted.connect(self.close)
|
||||||
root.addWidget(self.buttons)
|
root.addWidget(self.buttons)
|
||||||
@@ -72,7 +72,7 @@ class Scene3dSettingsDialog(QDialog):
|
|||||||
self.hint.setText(t("scene3d.settings_hint"))
|
self.hint.setText(t("scene3d.settings_hint"))
|
||||||
# Qt ships its own translations for a button box, and the application installs
|
# 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.
|
# 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:
|
if close_button is not None:
|
||||||
close_button.setText(t("dialog.close"))
|
close_button.setText(t("dialog.close"))
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class Scene3dSettingsDialog(QDialog):
|
|||||||
|
|
||||||
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
|
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
"""Esc closes the dialog, as it does everywhere else in this application."""
|
"""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()
|
self.hide()
|
||||||
return
|
return
|
||||||
super().keyPressEvent(event)
|
super().keyPressEvent(event)
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ import logging
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import pyqtSignal
|
from PySide6.QtCore import Signal
|
||||||
from PyQt5.QtWidgets import QVBoxLayout, QWidget
|
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.composer import SceneComposer
|
from orisys_app_shared.scene3d.composer import SceneComposer
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -71,9 +71,9 @@ def gl_available() -> tuple[bool, str]:
|
|||||||
pane on a machine where PyOpenGL is not installed.
|
pane on a machine where PyOpenGL is not installed.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401
|
from PySide6.QtOpenGLWidgets import QOpenGLWidget # noqa: F401
|
||||||
except Exception as exc: # noqa: BLE001
|
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:
|
try:
|
||||||
import OpenGL # noqa: F401
|
import OpenGL # noqa: F401
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@@ -84,11 +84,11 @@ def gl_available() -> tuple[bool, str]:
|
|||||||
class Scene3DView(QWidget):
|
class Scene3DView(QWidget):
|
||||||
"""One 3D pane: the GL widget on top, its controls underneath."""
|
"""One 3D pane: the GL widget on top, its controls underneath."""
|
||||||
|
|
||||||
stateChanged = pyqtSignal(object) # SceneState
|
stateChanged = Signal(object) # SceneState
|
||||||
diagnosticRaised = pyqtSignal(object) # Diagnostic
|
diagnosticRaised = Signal(object) # Diagnostic
|
||||||
renderStatsChanged = pyqtSignal(object) # RenderStats
|
renderStatsChanged = Signal(object) # RenderStats
|
||||||
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
|
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -415,8 +415,10 @@ class Scene3DView(QWidget):
|
|||||||
"""
|
"""
|
||||||
adapter = self._adapter
|
adapter = self._adapter
|
||||||
descriptor = self._last_descriptor
|
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
|
return None
|
||||||
|
mapping = getattr(self._assets, "surface_mapping", None) if self._assets else None
|
||||||
|
overlays_ok = mapping is not None
|
||||||
return SceneCapabilities(
|
return SceneCapabilities(
|
||||||
quantity=str(
|
quantity=str(
|
||||||
getattr(adapter, "quantity", "") or getattr(descriptor, "quantity", "")
|
getattr(adapter, "quantity", "") or getattr(descriptor, "quantity", "")
|
||||||
@@ -427,6 +429,8 @@ class Scene3DView(QWidget):
|
|||||||
),
|
),
|
||||||
displacement_quality=None,
|
displacement_quality=None,
|
||||||
source_kind=str(self._source_kind),
|
source_kind=str(self._source_kind),
|
||||||
|
flow_arrows_available=overlays_ok,
|
||||||
|
contact_arrow_available=overlays_ok,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _publish_capabilities(self) -> None:
|
def _publish_capabilities(self) -> None:
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QEvent, Qt, pyqtSignal
|
from PySide6.QtCore import QEvent, Qt, Signal
|
||||||
from PyQt5.QtGui import QMouseEvent, QWheelEvent
|
from PySide6.QtGui import QMouseEvent, QWheelEvent
|
||||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
|
||||||
|
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
@@ -48,13 +50,13 @@ STATS_INTERVAL_S = 0.5
|
|||||||
class Sensor3DWidget(QOpenGLWidget):
|
class Sensor3DWidget(QOpenGLWidget):
|
||||||
"""Renders one scene. Signals carry state, never per-frame arrays."""
|
"""Renders one scene. Signals carry state, never per-frame arrays."""
|
||||||
|
|
||||||
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
|
capabilitiesChanged = Signal(object) # GLCapabilities
|
||||||
diagnosticRaised = pyqtSignal(object) # Diagnostic
|
diagnosticRaised = Signal(object) # Diagnostic
|
||||||
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
|
renderStatsChanged = Signal(object) # RenderStats, rate-limited
|
||||||
#: What the frame on screen claims to be — quantity, unit, quality. The panel
|
#: 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
|
#: labels the colour bar from this and nothing else, so an unmeasured surface
|
||||||
#: cannot inherit the previous frame's range.
|
#: cannot inherit the previous frame's range.
|
||||||
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
|
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -259,14 +261,19 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
When the platform cannot create a GL context (the offscreen plugin, a broken
|
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
|
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
|
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()``
|
view that has no data yet. Qt does answer the question, though, so the first
|
||||||
stays ``None`` — so the first paint asks it and turns "no context" into the
|
paint asks it and turns "no context" into the state the rest of the application
|
||||||
state the rest of the application already knows how to show.
|
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)
|
super().paintEvent(event)
|
||||||
if self._unavailable or self._capabilities is not None:
|
if self._unavailable or self._capabilities is not None:
|
||||||
return
|
return
|
||||||
if self.context() is None:
|
context = self.context()
|
||||||
|
if context is None or not context.isValid():
|
||||||
self._fail(
|
self._fail(
|
||||||
diagnostic(
|
diagnostic(
|
||||||
"gl_unsupported",
|
"gl_unsupported",
|
||||||
@@ -305,7 +312,7 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
|
|
||||||
# --------------------------------------------------------------- interaction
|
# --------------------------------------------------------------- interaction
|
||||||
def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
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)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
|
||||||
@@ -316,17 +323,17 @@ class Sensor3DWidget(QOpenGLWidget):
|
|||||||
if self._last_press is None:
|
if self._last_press is None:
|
||||||
super().mouseMoveEvent(event)
|
super().mouseMoveEvent(event)
|
||||||
return
|
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])
|
delta = (x - self._last_press[0], y - self._last_press[1])
|
||||||
self._last_press = (x, y)
|
self._last_press = (x, y)
|
||||||
viewport = (max(self.width(), 1), max(self.height(), 1))
|
viewport = (max(self.width(), 1), max(self.height(), 1))
|
||||||
buttons = event.buttons()
|
buttons = event.buttons()
|
||||||
panning = bool(buttons & Qt.MiddleButton) or bool(
|
panning = bool(buttons & Qt.MouseButton.MiddleButton) or bool(
|
||||||
buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier
|
buttons & Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
||||||
)
|
)
|
||||||
if panning:
|
if panning:
|
||||||
self._camera.pan(delta, viewport)
|
self._camera.pan(delta, viewport)
|
||||||
elif buttons & Qt.LeftButton:
|
elif buttons & Qt.MouseButton.LeftButton:
|
||||||
self._camera.orbit(delta, viewport)
|
self._camera.orbit(delta, viewport)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Optional
|
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_app_shared.i18n import t
|
||||||
from orisys_studio import session_metadata
|
from orisys_studio import session_metadata
|
||||||
@@ -47,11 +47,11 @@ class SessionController(QObject):
|
|||||||
"""Owns the acquisition session's state and its UI-free mechanics."""
|
"""Owns the acquisition session's state and its UI-free mechanics."""
|
||||||
|
|
||||||
#: The run timer fired: the window's presenter slot does the painting.
|
#: 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,
|
#: 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.
|
#: and an idempotent repaint is cheaper than a second source of truth.
|
||||||
phaseChanged = pyqtSignal(str)
|
phaseChanged = Signal(str)
|
||||||
runningChanged = pyqtSignal(bool)
|
runningChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PyQt5.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.paths import logo_topbar_path
|
from orisys_app_shared.paths import logo_topbar_path
|
||||||
@@ -24,19 +24,19 @@ class AboutDialog(QDialog):
|
|||||||
logo = QLabel()
|
logo = QLabel()
|
||||||
pix = QPixmap(str(logo_topbar_path()))
|
pix = QPixmap(str(logo_topbar_path()))
|
||||||
if not pix.isNull():
|
if not pix.isNull():
|
||||||
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
|
logo.setPixmap(pix.scaledToHeight(48, Qt.TransformationMode.SmoothTransformation))
|
||||||
row.addWidget(logo)
|
row.addWidget(logo)
|
||||||
row.addStretch(1)
|
row.addStretch(1)
|
||||||
root.addLayout(row)
|
root.addLayout(row)
|
||||||
|
|
||||||
self.body = QLabel()
|
self.body = QLabel()
|
||||||
self.body.setWordWrap(True)
|
self.body.setWordWrap(True)
|
||||||
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
root.addWidget(self.body)
|
root.addWidget(self.body)
|
||||||
|
|
||||||
close_btn = QPushButton("OK")
|
close_btn = QPushButton("OK")
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
root.addWidget(close_btn, alignment=Qt.AlignRight)
|
root.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
from PySide6.QtCore import QSize, Qt, Signal
|
||||||
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
|
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -48,7 +48,7 @@ def bgr_to_qimage(frame) -> QImage:
|
|||||||
"""
|
"""
|
||||||
rgb = frame[:, :, ::-1].copy()
|
rgb = frame[:, :, ::-1].copy()
|
||||||
height, width, channels = rgb.shape
|
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 = {
|
_VIEW_LABELS = {
|
||||||
@@ -92,7 +92,7 @@ class AspectImageLabel(QLabel):
|
|||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._aspect = DEFAULT_FRAME_ASPECT
|
self._aspect = DEFAULT_FRAME_ASPECT
|
||||||
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
policy.setHeightForWidth(True)
|
policy.setHeightForWidth(True)
|
||||||
self.setSizePolicy(policy)
|
self.setSizePolicy(policy)
|
||||||
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
|
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
|
||||||
@@ -132,7 +132,7 @@ class ColorBarLabel(QLabel):
|
|||||||
self.setObjectName("colorBar")
|
self.setObjectName("colorBar")
|
||||||
self.setFixedHeight(_COLORBAR_HEIGHT)
|
self.setFixedHeight(_COLORBAR_HEIGHT)
|
||||||
self.setMinimumWidth(_COLORBAR_WIDTH)
|
self.setMinimumWidth(_COLORBAR_WIDTH)
|
||||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
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
|
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
|
||||||
# the whole call, which is what QImage's borrowed buffer needs.
|
# the whole call, which is what QImage's borrowed buffer needs.
|
||||||
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
|
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 = QPainter(self)
|
||||||
painter.drawImage(self.rect(), image)
|
painter.drawImage(self.rect(), image)
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
|
|
||||||
class ArrowPanel(QFrame):
|
class ArrowPanel(QFrame):
|
||||||
view_mode_changed = pyqtSignal(str)
|
view_mode_changed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -188,7 +188,7 @@ class ArrowPanel(QFrame):
|
|||||||
layout.addLayout(header)
|
layout.addLayout(header)
|
||||||
|
|
||||||
self.image_label = AspectImageLabel()
|
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.setScaledContents(False)
|
||||||
self.image_label.setWordWrap(True)
|
self.image_label.setWordWrap(True)
|
||||||
# The image keeps the frame's shape and is centred in whatever height is
|
# 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()
|
deg = self._effective_rotation_deg()
|
||||||
if deg:
|
if deg:
|
||||||
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
|
# 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(
|
scaled = pix.scaled(
|
||||||
self.image_label.size(),
|
self.image_label.size(),
|
||||||
Qt.KeepAspectRatio,
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
Qt.SmoothTransformation,
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
)
|
)
|
||||||
self.image_label.setPixmap(scaled)
|
self.image_label.setPixmap(scaled)
|
||||||
self.image_label.setText("")
|
self.image_label.setText("")
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtGui import QColor, QPainter, QPixmap
|
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.paths import logo_topbar_path
|
from orisys_app_shared.paths import logo_topbar_path
|
||||||
@@ -39,13 +39,13 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap
|
|||||||
if source.isNull():
|
if source.isNull():
|
||||||
return QPixmap()
|
return QPixmap()
|
||||||
scaled = source.scaled(
|
scaled = source.scaled(
|
||||||
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
|
||||||
)
|
)
|
||||||
out = QPixmap(scaled.size())
|
out = QPixmap(scaled.size())
|
||||||
out.fill(Qt.transparent)
|
out.fill(Qt.GlobalColor.transparent)
|
||||||
painter = QPainter(out)
|
painter = QPainter(out)
|
||||||
painter.drawPixmap(0, 0, scaled)
|
painter.drawPixmap(0, 0, scaled)
|
||||||
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
|
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
|
||||||
painter.fillRect(out.rect(), QColor(tint))
|
painter.fillRect(out.rect(), QColor(tint))
|
||||||
painter.end()
|
painter.end()
|
||||||
return out
|
return out
|
||||||
@@ -55,11 +55,11 @@ class BrandBar(QFrame):
|
|||||||
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
|
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
|
||||||
|
|
||||||
#: The user asked for the live view.
|
#: The user asked for the live view.
|
||||||
live_requested = pyqtSignal()
|
live_requested = Signal()
|
||||||
#: The user asked for the history/replay view.
|
#: The user asked for the history/replay view.
|
||||||
playback_requested = pyqtSignal()
|
playback_requested = Signal()
|
||||||
#: The user asked for the calibration view.
|
#: The user asked for the calibration view.
|
||||||
calibration_requested = pyqtSignal()
|
calibration_requested = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -74,7 +74,7 @@ class BrandBar(QFrame):
|
|||||||
self.logo = QLabel()
|
self.logo = QLabel()
|
||||||
self.logo.setObjectName("brandLogo")
|
self.logo.setObjectName("brandLogo")
|
||||||
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
|
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
|
||||||
self.logo.setAlignment(Qt.AlignCenter)
|
self.logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addWidget(self.logo)
|
layout.addWidget(self.logo)
|
||||||
|
|
||||||
# The lockup keeps its two labels (the product's tagline stays available
|
# The lockup keeps its two labels (the product's tagline stays available
|
||||||
@@ -113,7 +113,7 @@ class BrandBar(QFrame):
|
|||||||
|
|
||||||
self.product = QLabel()
|
self.product = QLabel()
|
||||||
self.product.setObjectName("brandProduct")
|
self.product.setObjectName("brandProduct")
|
||||||
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
self.product.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
layout.addWidget(self.product)
|
layout.addWidget(self.product)
|
||||||
|
|
||||||
self.set_theme(theme)
|
self.set_theme(theme)
|
||||||
@@ -123,7 +123,7 @@ class BrandBar(QFrame):
|
|||||||
def _nav_button(self) -> QPushButton:
|
def _nav_button(self) -> QPushButton:
|
||||||
button = QPushButton()
|
button = QPushButton()
|
||||||
button.setObjectName("navButton")
|
button.setObjectName("navButton")
|
||||||
button.setCursor(Qt.PointingHandCursor)
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
button.setFlat(True)
|
button.setFlat(True)
|
||||||
button.setCheckable(True)
|
button.setCheckable(True)
|
||||||
return button
|
return button
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
from PySide6.QtGui import QColor, QFont, QFontDatabase
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QButtonGroup,
|
QButtonGroup,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
@@ -60,11 +60,11 @@ def _mono_font() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class CalPanel(QFrame):
|
class CalPanel(QFrame):
|
||||||
status_message = pyqtSignal(str)
|
status_message = Signal(str)
|
||||||
#: Coefficients were written to disk. It does **not** mean they were applied.
|
#: 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.
|
#: 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:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -97,9 +97,9 @@ class CalPanel(QFrame):
|
|||||||
outer.setSpacing(0)
|
outer.setSpacing(0)
|
||||||
self.scroll = QScrollArea()
|
self.scroll = QScrollArea()
|
||||||
self.scroll.setWidgetResizable(True)
|
self.scroll.setWidgetResizable(True)
|
||||||
self.scroll.setFrameShape(QFrame.NoFrame)
|
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
content = QWidget()
|
content = QWidget()
|
||||||
self.scroll.setWidget(content)
|
self.scroll.setWidget(content)
|
||||||
outer.addWidget(self.scroll)
|
outer.addWidget(self.scroll)
|
||||||
@@ -135,7 +135,7 @@ class CalPanel(QFrame):
|
|||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.setHorizontalSpacing(10)
|
form.setHorizontalSpacing(10)
|
||||||
form.setVerticalSpacing(6)
|
form.setVerticalSpacing(6)
|
||||||
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
self._weight_label = QLabel()
|
self._weight_label = QLabel()
|
||||||
self._weight_label.setObjectName("fieldLabel")
|
self._weight_label.setObjectName("fieldLabel")
|
||||||
self.weight_spin = QDoubleSpinBox()
|
self.weight_spin = QDoubleSpinBox()
|
||||||
@@ -182,16 +182,16 @@ class CalPanel(QFrame):
|
|||||||
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
|
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
|
||||||
# stability measure and the point's state.
|
# stability measure and the point's state.
|
||||||
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
|
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
|
||||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
self.table.setAlternatingRowColors(True)
|
self.table.setAlternatingRowColors(True)
|
||||||
self.table.setShowGrid(False)
|
self.table.setShowGrid(False)
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
# Every column takes the width its own header and cells need: eliding a
|
# Every column takes the width its own header and cells need: eliding a
|
||||||
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
|
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
|
||||||
# panel scrolls instead.
|
# panel scrolls instead.
|
||||||
header.setSectionResizeMode(QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||||
header.setMinimumSectionSize(64)
|
header.setMinimumSectionSize(64)
|
||||||
self.table.verticalHeader().setVisible(False)
|
self.table.verticalHeader().setVisible(False)
|
||||||
self.table.verticalHeader().setDefaultSectionSize(26)
|
self.table.verticalHeader().setDefaultSectionSize(26)
|
||||||
@@ -235,7 +235,7 @@ class CalPanel(QFrame):
|
|||||||
):
|
):
|
||||||
# The action row must survive a narrow three-panel layout.
|
# The action row must survive a narrow three-panel layout.
|
||||||
button.setProperty("compact", "true")
|
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_excluded.clicked.connect(self._on_toggle_excluded)
|
||||||
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
|
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
|
||||||
self.btn_delete.clicked.connect(self._on_delete_row)
|
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||||
@@ -523,11 +523,11 @@ class CalPanel(QFrame):
|
|||||||
)
|
)
|
||||||
for column, text in enumerate(values):
|
for column, text in enumerate(values):
|
||||||
item = QTableWidgetItem(text)
|
item = QTableWidgetItem(text)
|
||||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||||
if column in (0, 1, 2, 3):
|
if column in (0, 1, 2, 3):
|
||||||
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
|
item.setTextAlignment(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
|
||||||
else:
|
else:
|
||||||
item.setTextAlignment(int(Qt.AlignCenter))
|
item.setTextAlignment(int(Qt.AlignmentFlag.AlignCenter))
|
||||||
if point.excluded:
|
if point.excluded:
|
||||||
item.setForeground(QColor(color(self._theme, "text_muted")))
|
item.setForeground(QColor(color(self._theme, "text_muted")))
|
||||||
elif point.is_validation:
|
elif point.is_validation:
|
||||||
@@ -611,7 +611,7 @@ class CalPanel(QFrame):
|
|||||||
"""Double click a row to inspect that point's three-axis curve."""
|
"""Double click a row to inspect that point's three-axis curve."""
|
||||||
dialog = self.point_dialog(self._current_point())
|
dialog = self.point_dialog(self._current_point())
|
||||||
if dialog is not None:
|
if dialog is not None:
|
||||||
dialog.exec_()
|
dialog.exec()
|
||||||
|
|
||||||
def _on_toggle_excluded(self) -> None:
|
def _on_toggle_excluded(self) -> None:
|
||||||
point = self._current_point()
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PyQt5.QtGui import QFont, QFontDatabase
|
from PySide6.QtGui import QFont, QFontDatabase
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFormLayout,
|
QFormLayout,
|
||||||
@@ -103,7 +103,7 @@ class CalibrationPointDialog(QDialog):
|
|||||||
root.addWidget(self.plot_widget, 1)
|
root.addWidget(self.plot_widget, 1)
|
||||||
self._build_traces()
|
self._build_traces()
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
buttons.rejected.connect(self.reject)
|
buttons.rejected.connect(self.reject)
|
||||||
buttons.accepted.connect(self.accept)
|
buttons.accepted.connect(self.accept)
|
||||||
root.addWidget(buttons)
|
root.addWidget(buttons)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ clips the text and the tooltip remains the way to read it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize
|
from PySide6.QtCore import QSize
|
||||||
from PyQt5.QtWidgets import QLabel, QSizePolicy
|
from PySide6.QtWidgets import QLabel, QSizePolicy
|
||||||
|
|
||||||
|
|
||||||
class ShrinkableLabel(QLabel):
|
class ShrinkableLabel(QLabel):
|
||||||
@@ -18,7 +18,7 @@ class ShrinkableLabel(QLabel):
|
|||||||
|
|
||||||
def __init__(self, text: str = "", parent=None) -> None:
|
def __init__(self, text: str = "", parent=None) -> None:
|
||||||
super().__init__(text, parent)
|
super().__init__(text, parent)
|
||||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||||
self.setMinimumWidth(0)
|
self.setMinimumWidth(0)
|
||||||
|
|
||||||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
|
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtCore import Qt, QSize, pyqtSignal
|
from PySide6.QtCore import Qt, QSize, Signal
|
||||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -18,6 +18,7 @@ from PyQt5.QtWidgets import (
|
|||||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||||
from orisys_app_shared.force import MetricHistory
|
from orisys_app_shared.force import MetricHistory
|
||||||
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
|
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
|
||||||
|
from orisys_app_shared.force.presentation import ForcePresentation
|
||||||
from orisys_app_shared.force.source import StoreCurveSource
|
from orisys_app_shared.force.source import StoreCurveSource
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.theme import color
|
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)]
|
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."""
|
"""Render one force reading for the value cards."""
|
||||||
|
if presentation is not None:
|
||||||
|
return presentation.format_force(value, unit=unit)
|
||||||
if unit == "N":
|
if unit == "N":
|
||||||
return f"{value:.3f}"
|
return f"{value:.3f}"
|
||||||
return f"{value:.1f}"
|
return f"{value:.1f}"
|
||||||
@@ -111,18 +116,18 @@ class _Sync:
|
|||||||
|
|
||||||
class ForcePlotsPanel(QFrame):
|
class ForcePlotsPanel(QFrame):
|
||||||
#: The visible time window changed while browsing history.
|
#: 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.
|
#: 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).
|
#: "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.
|
#: 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).
|
#: 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
|
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
|
||||||
#: or moved the overview marker): playback stops where it is.
|
#: 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:
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -137,6 +142,11 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
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?
|
#: Per axis: does ``_last_values`` hold a real value in the active unit?
|
||||||
self._available: tuple[bool, bool, bool] = (True, True, True)
|
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 -------------------------------------------------
|
# -- browsing state -------------------------------------------------
|
||||||
#: Where the curves read from: the in-memory session store here, a
|
#: Where the curves read from: the in-memory session store here, a
|
||||||
#: recording on disk in the replay. One interface either way.
|
#: recording on disk in the replay. One interface either way.
|
||||||
@@ -207,7 +217,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.unit_badge = QPushButton()
|
self.unit_badge = QPushButton()
|
||||||
self.unit_badge.setObjectName("unitBadge")
|
self.unit_badge.setObjectName("unitBadge")
|
||||||
self.unit_badge.setProperty("compact", "true")
|
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)
|
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
|
||||||
header.addWidget(self.unit_badge)
|
header.addWidget(self.unit_badge)
|
||||||
root.addLayout(header)
|
root.addLayout(header)
|
||||||
@@ -319,7 +329,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.window_label.setObjectName("panelMeta")
|
self.window_label.setObjectName("panelMeta")
|
||||||
self.stats_label = QLabel()
|
self.stats_label = QLabel()
|
||||||
self.stats_label.setObjectName("panelMeta")
|
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_caption)
|
||||||
browse_row.addWidget(self.window_label)
|
browse_row.addWidget(self.window_label)
|
||||||
browse_row.addStretch(1)
|
browse_row.addStretch(1)
|
||||||
@@ -338,7 +348,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
button.setProperty("iconOnly", "true")
|
button.setProperty("iconOnly", "true")
|
||||||
button.setIcon(icons.icon(icon_name, self._theme))
|
button.setIcon(icons.icon(icon_name, self._theme))
|
||||||
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
|
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
|
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
|
||||||
return button
|
return button
|
||||||
|
|
||||||
@@ -379,7 +389,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
value_row.setSpacing(5)
|
value_row.setSpacing(5)
|
||||||
value_label = QLabel()
|
value_label = QLabel()
|
||||||
value_label.setObjectName("valueCard")
|
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))
|
value_label.setFont(_mono_font(16, bold=True))
|
||||||
# Never a fixed height: the assertion that a value is not clipped is
|
# Never a fixed height: the assertion that a value is not clipped is
|
||||||
# derived from the font it is actually drawn with.
|
# derived from the font it is actually drawn with.
|
||||||
@@ -484,7 +494,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self.history.reset()
|
self.history.reset()
|
||||||
self._clear_curves()
|
self._clear_curves()
|
||||||
self._last_values = (0.0, 0.0, 0.0)
|
self._last_values = (0.0, 0.0, 0.0)
|
||||||
|
self._display_values = (0.0, 0.0, 0.0)
|
||||||
self._available = (True, True, True)
|
self._available = (True, True, True)
|
||||||
|
self.presentation.reset_session(unit=self._unit)
|
||||||
self._unit = "raw"
|
self._unit = "raw"
|
||||||
self._mode = "follow"
|
self._mode = "follow"
|
||||||
self._window = (0.0, 0.0)
|
self._window = (0.0, 0.0)
|
||||||
@@ -500,6 +512,49 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._apply_axis_mode()
|
self._apply_axis_mode()
|
||||||
self._refresh_mode_label()
|
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(
|
def push(
|
||||||
self,
|
self,
|
||||||
t_rel_s: float,
|
t_rel_s: float,
|
||||||
@@ -509,6 +564,9 @@ class ForcePlotsPanel(QFrame):
|
|||||||
*,
|
*,
|
||||||
unit: str = "raw",
|
unit: str = "raw",
|
||||||
available: tuple[bool, bool, bool] | None = None,
|
available: tuple[bool, bool, bool] | None = None,
|
||||||
|
flow_p95: float | None = None,
|
||||||
|
contact_found: bool = False,
|
||||||
|
forces_valid: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Append one live sample.
|
"""Append one live sample.
|
||||||
|
|
||||||
@@ -532,6 +590,18 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._last_values = (float(fn), float(fx), float(fy))
|
self._last_values = (float(fn), float(fx), float(fy))
|
||||||
if available is not None:
|
if available is not None:
|
||||||
self._available = tuple(bool(flag) for flag in available)
|
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:
|
if unit != self._unit:
|
||||||
# A unit change mid-stream is a *switch*, never a reinterpretation:
|
# A unit change mid-stream is a *switch*, never a reinterpretation:
|
||||||
# the buffered values were measured in the old unit and must not be
|
# 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])
|
peak = float(selecting.peaks[index])
|
||||||
available = bool(np.isfinite(peak))
|
available = bool(np.isfinite(peak))
|
||||||
value = peak
|
value = peak
|
||||||
|
elif self.presentation.enabled and self.presentation.ready:
|
||||||
|
value = self._display_values[index]
|
||||||
if not available:
|
if not available:
|
||||||
# An axis with no curve in this unit: a dash, never a raw count
|
# An axis with no curve in this unit: a dash, never a raw count
|
||||||
# presented as a newton value.
|
# presented as a newton value.
|
||||||
@@ -1192,14 +1264,30 @@ class ForcePlotsPanel(QFrame):
|
|||||||
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
|
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
value_label.setText(fmt.format(value=value))
|
value_label.setText(
|
||||||
value_label.setToolTip(
|
format_force(value, self._unit, presentation=self.presentation)
|
||||||
t("chart.selection_peak_tip") if selecting is not None else ""
|
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))
|
unit_label.setText(unit_display(self._unit))
|
||||||
detail_label.setText(
|
if selecting is not None:
|
||||||
self._selection_detail(index) if selecting is not None else ""
|
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))
|
self.unit_badge.setText(unit_display(self._unit))
|
||||||
|
|
||||||
def _selection_detail(self, index: int) -> str:
|
def _selection_detail(self, index: int) -> str:
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from PyQt5.QtCore import QRectF, Qt
|
from PySide6.QtCore import QRectF, Qt
|
||||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||||
|
|
||||||
from orisys_app_shared.theme import color
|
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:
|
def _paint_box_zoom(painter: QPainter, size: int) -> None:
|
||||||
"""A dashed frame: drag a region to zoom into it."""
|
"""A dashed frame: drag a region to zoom into it."""
|
||||||
pen = painter.pen()
|
pen = painter.pen()
|
||||||
pen.setStyle(Qt.DashLine)
|
pen.setStyle(Qt.PenStyle.DashLine)
|
||||||
pen.setDashPattern([2.2, 1.6])
|
pen.setDashPattern([2.2, 1.6])
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
|
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:
|
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
|
||||||
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
|
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
|
||||||
canvas = QPixmap(size, size)
|
canvas = QPixmap(size, size)
|
||||||
canvas.fill(Qt.transparent)
|
canvas.fill(Qt.GlobalColor.transparent)
|
||||||
paint = _PAINTERS.get(str(name))
|
paint = _PAINTERS.get(str(name))
|
||||||
if paint is None:
|
if paint is None:
|
||||||
return canvas
|
return canvas
|
||||||
painter = QPainter(canvas)
|
painter = QPainter(canvas)
|
||||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
pen = QPen()
|
pen = QPen()
|
||||||
pen.setWidthF(_PEN_WIDTH)
|
pen.setWidthF(_PEN_WIDTH)
|
||||||
pen.setCapStyle(Qt.RoundCap)
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
pen.setJoinStyle(Qt.RoundJoin)
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
pen.setColor(QColor(tint))
|
pen.setColor(QColor(tint))
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
painter.setBrush(Qt.NoBrush)
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
try:
|
try:
|
||||||
paint(painter, size)
|
paint(painter, size)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Callable, Optional
|
from typing import TYPE_CHECKING, Callable, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
from PySide6.QtCore import QSize, Qt, Signal
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -78,7 +78,7 @@ class Mesh3dPanel(QFrame):
|
|||||||
|
|
||||||
#: Emitted when the operator turns the pane on. The window wires the packet sink
|
#: 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.
|
#: from this, so no packet is adapted while the pane is off.
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
|
||||||
@@ -128,14 +128,14 @@ class Mesh3dPanel(QFrame):
|
|||||||
self.btn_reset_view = QPushButton()
|
self.btn_reset_view = QPushButton()
|
||||||
self.btn_reset_view.setProperty("compact", "true")
|
self.btn_reset_view.setProperty("compact", "true")
|
||||||
self.btn_reset_view.setProperty("iconOnly", "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)
|
self.btn_reset_view.clicked.connect(self.reset_view)
|
||||||
header.addWidget(self.btn_reset_view)
|
header.addWidget(self.btn_reset_view)
|
||||||
|
|
||||||
self.btn_settings = QPushButton()
|
self.btn_settings = QPushButton()
|
||||||
self.btn_settings.setProperty("compact", "true")
|
self.btn_settings.setProperty("compact", "true")
|
||||||
self.btn_settings.setProperty("iconOnly", "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)
|
self.btn_settings.clicked.connect(self.open_settings)
|
||||||
header.addWidget(self.btn_settings)
|
header.addWidget(self.btn_settings)
|
||||||
root.addLayout(header)
|
root.addLayout(header)
|
||||||
@@ -145,7 +145,7 @@ class Mesh3dPanel(QFrame):
|
|||||||
|
|
||||||
self.message = QLabel()
|
self.message = QLabel()
|
||||||
self.message.setObjectName("hintText")
|
self.message.setObjectName("hintText")
|
||||||
self.message.setAlignment(Qt.AlignCenter)
|
self.message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.message.setWordWrap(True)
|
self.message.setWordWrap(True)
|
||||||
self._stack.addWidget(self.message)
|
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 __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -133,7 +133,7 @@ class PlotSettingsDialog(QDialog):
|
|||||||
self.btn_reset.clicked.connect(self._reset_defaults)
|
self.btn_reset.clicked.connect(self._reset_defaults)
|
||||||
buttons_row.addWidget(self.btn_reset)
|
buttons_row.addWidget(self.btn_reset)
|
||||||
buttons_row.addStretch(1)
|
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.accepted.connect(self.accept)
|
||||||
self.button_box.rejected.connect(self.reject)
|
self.button_box.rejected.connect(self.reject)
|
||||||
buttons_row.addWidget(self.button_box)
|
buttons_row.addWidget(self.button_box)
|
||||||
@@ -384,8 +384,8 @@ class PlotSettingsDialog(QDialog):
|
|||||||
self.rate_label.setText(t("dialog.filter_rate"))
|
self.rate_label.setText(t("dialog.filter_rate"))
|
||||||
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
|
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
|
||||||
self._refresh_filter_note()
|
self._refresh_filter_note()
|
||||||
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
|
ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
cancel_btn = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
|
||||||
if ok_btn is not None:
|
if ok_btn is not None:
|
||||||
ok_btn.setText(t("dialog.ok"))
|
ok_btn.setText(t("dialog.ok"))
|
||||||
if cancel_btn is not None:
|
if cancel_btn is not None:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ timer without touching the writer thread.
|
|||||||
|
|
||||||
from __future__ import annotations
|
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.i18n import t
|
||||||
from orisys_app_shared.theme import color
|
from orisys_app_shared.theme import color
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -111,7 +111,7 @@ class SensorDialog(QDialog):
|
|||||||
def _section_rule() -> QFrame:
|
def _section_rule() -> QFrame:
|
||||||
rule = QFrame()
|
rule = QFrame()
|
||||||
rule.setObjectName("sectionSeparator")
|
rule.setObjectName("sectionSeparator")
|
||||||
rule.setFrameShape(QFrame.HLine)
|
rule.setFrameShape(QFrame.Shape.HLine)
|
||||||
rule.setFixedHeight(1)
|
rule.setFixedHeight(1)
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -29,8 +29,8 @@ BADGE_WIDTH = 46
|
|||||||
|
|
||||||
|
|
||||||
class SensorPickerWidget(QWidget):
|
class SensorPickerWidget(QWidget):
|
||||||
sensor_type_changed = pyqtSignal(str)
|
sensor_type_changed = Signal(str)
|
||||||
config_changed = pyqtSignal(str)
|
config_changed = Signal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -42,7 +42,7 @@ class SensorPickerWidget(QWidget):
|
|||||||
#: attribute: it states which sensor the whole window is configured for.
|
#: attribute: it states which sensor the whole window is configured for.
|
||||||
self.thumb_label = QLabel()
|
self.thumb_label = QLabel()
|
||||||
self.thumb_label.setObjectName("unitBadge")
|
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".
|
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
|
||||||
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
|
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
|
||||||
self.thumb_label.setProperty("rail", "true")
|
self.thumb_label.setProperty("rail", "true")
|
||||||
@@ -55,7 +55,7 @@ class SensorPickerWidget(QWidget):
|
|||||||
self.type_combo = QComboBox()
|
self.type_combo = QComboBox()
|
||||||
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
||||||
self.type_combo.setProperty("rail", "true")
|
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():
|
for name in list_sensor_types():
|
||||||
self.type_combo.addItem(name, name)
|
self.type_combo.addItem(name, name)
|
||||||
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||||
@@ -65,7 +65,7 @@ class SensorPickerWidget(QWidget):
|
|||||||
self.config_combo = QComboBox()
|
self.config_combo = QComboBox()
|
||||||
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
||||||
self.config_combo.setProperty("rail", "true")
|
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)
|
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
||||||
|
|
||||||
layout.addWidget(self.thumb_label)
|
layout.addWidget(self.thumb_label)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PyQt5.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -42,9 +42,9 @@ RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.
|
|||||||
def _separator() -> QFrame:
|
def _separator() -> QFrame:
|
||||||
line = QFrame()
|
line = QFrame()
|
||||||
line.setObjectName("sectionSeparator")
|
line.setObjectName("sectionSeparator")
|
||||||
line.setFrameShape(QFrame.VLine)
|
line.setFrameShape(QFrame.Shape.VLine)
|
||||||
line.setFixedWidth(1)
|
line.setFixedWidth(1)
|
||||||
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
line.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
|
||||||
return line
|
return line
|
||||||
|
|
||||||
|
|
||||||
@@ -70,23 +70,23 @@ def _group(*widgets: QWidget) -> QWidget:
|
|||||||
|
|
||||||
|
|
||||||
class ToolbarWidget(QWidget):
|
class ToolbarWidget(QWidget):
|
||||||
search_clicked = pyqtSignal()
|
search_clicked = Signal()
|
||||||
preview_toggled = pyqtSignal(bool)
|
preview_toggled = Signal(bool)
|
||||||
camera_id_changed = pyqtSignal(int)
|
camera_id_changed = Signal(int)
|
||||||
#: The sensor summary button: opens the sensor and calibration dialog.
|
#: 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.
|
#: "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 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).
|
#: Replay view: re-run the pipeline over that video (the only processed picture).
|
||||||
recompute_clicked = pyqtSignal()
|
recompute_clicked = Signal()
|
||||||
run_toggled = pyqtSignal(bool)
|
run_toggled = Signal(bool)
|
||||||
reset_clicked = pyqtSignal()
|
reset_clicked = Signal()
|
||||||
units_changed = pyqtSignal(str)
|
units_changed = Signal(str)
|
||||||
record_toggled = pyqtSignal(bool)
|
record_toggled = Signal(bool)
|
||||||
#: The settings area was expanded or collapsed by the user.
|
#: The settings area was expanded or collapsed by the user.
|
||||||
settings_toggled = pyqtSignal(bool)
|
settings_toggled = Signal(bool)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -124,9 +124,9 @@ class ToolbarWidget(QWidget):
|
|||||||
# narrow window.
|
# narrow window.
|
||||||
self._settings_scroll = QScrollArea()
|
self._settings_scroll = QScrollArea()
|
||||||
self._settings_scroll.setWidgetResizable(True)
|
self._settings_scroll.setWidgetResizable(True)
|
||||||
self._settings_scroll.setFrameShape(QFrame.NoFrame)
|
self._settings_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||||
self.settings_page = self._build_settings()
|
self.settings_page = self._build_settings()
|
||||||
self._settings_scroll.setWidget(self.settings_page)
|
self._settings_scroll.setWidget(self.settings_page)
|
||||||
outer.addWidget(self._settings_scroll)
|
outer.addWidget(self._settings_scroll)
|
||||||
@@ -157,7 +157,7 @@ class ToolbarWidget(QWidget):
|
|||||||
|
|
||||||
self.status_chip = QLabel()
|
self.status_chip = QLabel()
|
||||||
self.status_chip.setObjectName("statusChip")
|
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 = ShrinkableLabel()
|
||||||
self.source_summary.setObjectName("panelMeta")
|
self.source_summary.setObjectName("panelMeta")
|
||||||
self.calibration_label = ShrinkableLabel()
|
self.calibration_label = ShrinkableLabel()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import logging
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Optional, Protocol
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
from PyQt5.QtCore import QObject
|
from PySide6.QtCore import QObject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
The tests render real Qt widgets with the offscreen platform plugin, so they need
|
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
|
no display and no camera hardware. A single ``QApplication`` is shared by the
|
||||||
whole session (Qt forbids creating more than one).
|
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
|
from __future__ import annotations
|
||||||
@@ -16,11 +20,30 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
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:
|
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||||
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
||||||
if _fonts.is_dir():
|
if _fonts.is_dir():
|
||||||
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
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
|
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
|
||||||
# truth between the pytest suite and the dependency-free checker.
|
# truth between the pytest suite and the dependency-free checker.
|
||||||
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
|
_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")
|
@pytest.fixture(scope="session")
|
||||||
def qt_app():
|
def qt_app():
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
yield app
|
yield app
|
||||||
@@ -83,7 +106,7 @@ def visible_descendants(widget):
|
|||||||
|
|
||||||
def show_window(window, size):
|
def show_window(window, size):
|
||||||
"""Show a window at ``size`` and flush pending Qt events."""
|
"""Show a window at ``size`` and flush pending Qt events."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window.resize(*size)
|
window.resize(*size)
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
on a little-endian machine, with a stride that can exceed the width — so the
|
||||||
conversion and the stride trim are both load-bearing.
|
conversion and the stride trim are both load-bearing.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtGui import QImage
|
from PySide6.QtGui import QImage
|
||||||
|
|
||||||
widget.repaint()
|
widget.repaint()
|
||||||
qt_app.processEvents()
|
qt_app.processEvents()
|
||||||
image = widget.grabFramebuffer()
|
image = widget.grabFramebuffer()
|
||||||
assert not image.isNull(), "Qt returned no framebuffer"
|
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()
|
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()
|
stride = image.bytesPerLine()
|
||||||
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
|
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
|
||||||
return rows[:, : width * 3].reshape(height, width, 3).copy()
|
return rows[:, : width * 3].reshape(height, width, 3).copy()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,8 +13,8 @@ view menu / the pane's header both reach that dialog.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from PyQt5.QtCore import pyqtSignal
|
from PySide6.QtCore import Signal
|
||||||
from PyQt5.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_studio.ui import icons
|
from orisys_studio.ui import icons
|
||||||
@@ -117,8 +117,8 @@ def test_the_pane_header_offers_the_two_actions(qt_app):
|
|||||||
|
|
||||||
# Reset forwards to the view when there is one.
|
# Reset forwards to the view when there is one.
|
||||||
class _View(QWidget):
|
class _View(QWidget):
|
||||||
diagnosticRaised = pyqtSignal(object)
|
diagnosticRaised = Signal(object)
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
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):
|
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."""
|
"""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 = make_window()
|
||||||
window.show()
|
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):
|
def test_the_view_menu_opens_the_3d_settings(make_window):
|
||||||
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
|
"""视图 ▸ 三维显示设置… 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 = make_window(sensor_type="finger", show_finger_3d=True)
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import pytest
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def restore_surface_format():
|
def restore_surface_format():
|
||||||
"""The default surface format is process-wide; put it back afterwards."""
|
"""The default surface format is process-wide; put it back afterwards."""
|
||||||
from PyQt5.QtGui import QSurfaceFormat
|
from PySide6.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
previous = QSurfaceFormat.defaultFormat()
|
previous = QSurfaceFormat.defaultFormat()
|
||||||
try:
|
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):
|
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
|
from orisys_studio.scene3d.gl_backend import configure_surface_format
|
||||||
|
|
||||||
configure_surface_format("desktop33")
|
configure_surface_format("desktop33")
|
||||||
desktop = QSurfaceFormat.defaultFormat()
|
desktop = QSurfaceFormat.defaultFormat()
|
||||||
assert desktop.renderableType() == QSurfaceFormat.OpenGL
|
assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
|
||||||
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
|
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
|
||||||
assert desktop.profile() == QSurfaceFormat.CoreProfile
|
assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile
|
||||||
assert desktop.depthBufferSize() == 24
|
assert desktop.depthBufferSize() == 24
|
||||||
assert desktop.samples() == 0, "v1 does not pay for multisampling"
|
assert desktop.samples() == 0, "v1 does not pay for multisampling"
|
||||||
|
|
||||||
configure_surface_format("gles30")
|
configure_surface_format("gles30")
|
||||||
embedded = QSurfaceFormat.defaultFormat()
|
embedded = QSurfaceFormat.defaultFormat()
|
||||||
assert embedded.renderableType() == QSurfaceFormat.OpenGLES
|
assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES
|
||||||
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
|
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import ui_check
|
import ui_check
|
||||||
from PyQt5.QtCore import pyqtSignal
|
from PySide6.QtCore import Signal
|
||||||
from PyQt5.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
from orisys_app_shared.scene3d import synthetic
|
from orisys_app_shared.scene3d import synthetic
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
@@ -276,8 +276,8 @@ class _RecordingView(QWidget):
|
|||||||
a real ``QWidget`` because the container puts it in its stack.
|
a real ``QWidget`` because the container puts it in its stack.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
diagnosticRaised = pyqtSignal(object)
|
diagnosticRaised = Signal(object)
|
||||||
enabledChanged = pyqtSignal(bool)
|
enabledChanged = Signal(bool)
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -433,6 +433,12 @@ def test_the_display_settings_round_trip():
|
|||||||
deform_enabled=True,
|
deform_enabled=True,
|
||||||
housing_visible=False,
|
housing_visible=False,
|
||||||
max_fps=60,
|
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(
|
color_scale=ColorScale(
|
||||||
descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||||
lower=0.25,
|
lower=0.25,
|
||||||
@@ -446,6 +452,12 @@ def test_the_display_settings_round_trip():
|
|||||||
assert restored.deform_enabled is True
|
assert restored.deform_enabled is True
|
||||||
assert restored.housing_visible is False
|
assert restored.housing_visible is False
|
||||||
assert restored.max_fps == 60
|
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 is not None
|
||||||
assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5)
|
assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5)
|
||||||
assert restored.color_scale.mode == "fixed"
|
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 = []
|
loaded: list = []
|
||||||
failed: list = []
|
failed: list = []
|
||||||
loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
|
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:
|
try:
|
||||||
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
|
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
|
||||||
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
|
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")
|
broken.write_bytes(b"not a zip archive at all")
|
||||||
loader = SceneAssetLoader()
|
loader = SceneAssetLoader()
|
||||||
failed: list = []
|
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:
|
try:
|
||||||
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
|
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
|
||||||
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
|
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ def test_force_plots_render_demo_data(make_window):
|
|||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
for i in range(60):
|
for i in range(60):
|
||||||
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
|
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()
|
QApplication.processEvents()
|
||||||
assert window.force_plots.history.sample_count > 0
|
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")
|
assert window.toolbar.record_chip.text() == t("record.state_idle")
|
||||||
|
|
||||||
window.toolbar.btn_settings.setChecked(True)
|
window.toolbar.btn_settings.setChecked(True)
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
assert window.toolbar.settings_expanded()
|
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
|
The settings area is the one part of the top rail that may scroll: its own
|
||||||
content is exempt, everything else must fit.
|
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 = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
|
||||||
window._set_settings_expanded(True)
|
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):
|
def test_connecting_cancel_stays_filled(make_window):
|
||||||
"""Cancel is the way out of a hung device open, so it keeps the weight."""
|
"""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))
|
window = _show(make_window(), (1440, 880))
|
||||||
stuck = ui_check._StuckWorker()
|
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):
|
def test_the_charts_start_in_follow_mode(make_window):
|
||||||
"""Every live entry point resumes following; only browsing is a gesture."""
|
"""Every live entry point resumes following; only browsing is a gesture."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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):
|
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."""
|
"""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)
|
window, stuck = _running_window(make_window)
|
||||||
panel = window.force_plots
|
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):
|
def test_the_sensor_summary_opens_the_dialog(make_window):
|
||||||
"""The rail states the sensor; the pickers live in the dialog it opens."""
|
"""The rail states the sensor; the pickers live in the dialog it opens."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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):
|
def test_the_source_picker_follows_the_view(make_window):
|
||||||
"""Live offers a camera, the replay a recorded video — and no residue."""
|
"""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)
|
window, stuck = _running_window(make_window)
|
||||||
assert window.toolbar.camera_group.isVisible()
|
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):
|
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 = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
|
||||||
window.brand_bar.btn_calibration.click()
|
window.brand_bar.btn_calibration.click()
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
@@ -1437,7 +1437,7 @@ def test_the_calibration_view_does_not_clip_its_controls(make_window):
|
|||||||
cal = window.cal_panel
|
cal = window.cal_panel
|
||||||
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
|
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
|
||||||
assert (
|
assert (
|
||||||
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents
|
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents
|
||||||
), "the diagnostic columns can still be elided"
|
), "the diagnostic columns can still be elided"
|
||||||
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
|
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
|
||||||
assert button.height() >= button.fontMetrics().height(), button.text()
|
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 = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_live.click()
|
window.brand_bar.btn_live.click()
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
live_sizes = tuple(window.content_splitter.sizes())
|
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):
|
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
|
||||||
"""The replay view reads the recording: charts, 2D panel and transport."""
|
"""The replay view reads the recording: charts, 2D panel and transport."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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_app_shared.i18n import t
|
||||||
from orisys_studio.ui.force_plots import unit_display
|
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))
|
window = _show(make_window(), (1440, 880))
|
||||||
plots = window.force_plots
|
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
|
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.
|
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, stuck = _running_window(make_window)
|
||||||
window.brand_bar.btn_playback.click()
|
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
|
A replay used to state the live acquisition in cards sitting next to a chart
|
||||||
of 7.5e5 raw counts, which reads as "the replay never opened".
|
of 7.5e5 raw counts, which reads as "the replay never opened".
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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
|
for the axes it calibrated. Asking for newton anyway drew three blank charts
|
||||||
and a dash in every card, which looks exactly like "the replay did not load".
|
and a dash in every card, which looks exactly like "the replay did not load".
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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."""
|
"""Play/pause like an editor: 1× forward, and a manual gesture takes over."""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
window = _show(make_window(), (1440, 880))
|
window = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_playback.click()
|
window.brand_bar.btn_playback.click()
|
||||||
@@ -1887,7 +1887,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
|
|||||||
make_window, replay_recording
|
make_window, replay_recording
|
||||||
):
|
):
|
||||||
"""Off: the recorded CSV. On: the recorded video through the vision chain."""
|
"""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 = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_playback.click()
|
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
|
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.
|
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
|
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):
|
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."""
|
"""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 = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_playback.click()
|
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):
|
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 = _show(make_window(), (1440, 880))
|
||||||
window.brand_bar.btn_live.click()
|
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 cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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 cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
import orisys_studio.app_window as app_module
|
import orisys_studio.app_window as app_module
|
||||||
from orisys_app_shared.acquisition.image_backend import (
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
@@ -2186,7 +2186,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
|
|||||||
"""
|
"""
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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
|
make_window, record_root
|
||||||
):
|
):
|
||||||
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
|
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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(
|
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
|
||||||
make_window, replay_recording
|
make_window, replay_recording
|
||||||
):
|
):
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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 csv
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from orisys_app_shared.i18n import t
|
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
|
import time
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
import orisys_studio.app_window as app_module
|
import orisys_studio.app_window as app_module
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def check(condition: bool, message: str) -> None:
|
|||||||
|
|
||||||
def _inside_scroll_area(widget) -> bool:
|
def _inside_scroll_area(widget) -> bool:
|
||||||
"""True when ``widget`` lives inside a QScrollArea's content."""
|
"""True when ``widget`` lives inside a QScrollArea's content."""
|
||||||
from PyQt5.QtWidgets import QScrollArea
|
from PySide6.QtWidgets import QScrollArea
|
||||||
|
|
||||||
node = widget
|
node = widget
|
||||||
while node is not None:
|
while node is not None:
|
||||||
@@ -538,7 +538,7 @@ def audit_palette(verbose: bool = False) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
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.force.calibration_io import CalSample
|
||||||
from orisys_app_shared.i18n import set_locale, t
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
@@ -1491,7 +1491,7 @@ def main() -> int:
|
|||||||
window.brand_bar.btn_calibration.click()
|
window.brand_bar.btn_calibration.click()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
app.processEvents()
|
app.processEvents()
|
||||||
from PyQt5.QtWidgets import QHeaderView as _QHeaderView
|
from PySide6.QtWidgets import QHeaderView as _QHeaderView
|
||||||
|
|
||||||
sizes = window.content_splitter.sizes()
|
sizes = window.content_splitter.sizes()
|
||||||
check(
|
check(
|
||||||
@@ -2000,7 +2000,7 @@ def main() -> int:
|
|||||||
"import os, sys\n"
|
"import os, sys\n"
|
||||||
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
|
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
|
||||||
"os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\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"
|
"import orisys_studio.main as studio_main\n"
|
||||||
"show = studio_main.StudioMainWindow.show\n"
|
"show = studio_main.StudioMainWindow.show\n"
|
||||||
"def show_and_close(self):\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.ui.cal_panel as _cal_panel_module
|
||||||
import orisys_studio.calibration_service as _cal_service_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 (
|
from orisys_app_shared.force.calibration_io import (
|
||||||
build_force_calibration_v2 as _build_v2b,
|
build_force_calibration_v2 as _build_v2b,
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ def main() -> int:
|
|||||||
|
|
||||||
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
|
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.i18n import set_locale
|
||||||
from orisys_app_shared.theme import build_qss
|
from orisys_app_shared.theme import build_qss
|
||||||
|
|||||||
+143
-28
@@ -1,40 +1,84 @@
|
|||||||
# Build
|
# 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
|
## Layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
build/
|
build/
|
||||||
studio/
|
studio/
|
||||||
|
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)
|
entry.py # frozen entry (no source chdir)
|
||||||
orisys-studio.spec # PyInstaller onedir spec
|
orisys-studio.spec # PyInstaller onedir spec
|
||||||
build.ps1 # create/reuse .venv-build and build
|
|
||||||
_pyinstaller/ # PyInstaller workpath (gitignored)
|
_pyinstaller/ # PyInstaller workpath (gitignored)
|
||||||
|
_smoke/ # log of the last build's start-up smoke test
|
||||||
dist/
|
dist/
|
||||||
OrisysStudio/
|
OrisysStudio/
|
||||||
OrisysStudio.exe
|
OrisysStudio.exe
|
||||||
config/ # lean locales + sensors (no finger visualization/)
|
config/ # lean locales + sensors (no finger visualization/)
|
||||||
_internal/ # Python, PyQt5, opencv-headless, orisys, …
|
_internal/ # Python, PySide6, opencv-headless, orisys, OpenGL, …
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prerequisites
|
## Why there are two steps
|
||||||
|
|
||||||
- Windows + Python 3.10+ on PATH
|
A PyInstaller bundle depends on the *machine* as much as on the sources: which interpreter,
|
||||||
- `orisys` installable into the build venv (`pip install orisys>=0.5`)
|
whether it is Anaconda (whose `libexpat`/`libffi`/OpenSSL DLLs live outside the venv and are
|
||||||
- Do **not** install `orisys[gui]` / Open3D for this build
|
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
|
## Build
|
||||||
|
|
||||||
From `build\studio` (recommended):
|
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
build.bat
|
build.bat
|
||||||
build.bat -Clean
|
build.bat -Clean # after dependency/spec changes
|
||||||
build.bat -SkipInstall
|
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
|
||||||
powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
|
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.
|
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
|
||||||
|
|
||||||
Flags:
|
## What the build verifies, and why
|
||||||
|
|
||||||
| Flag | Meaning |
|
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:
|
||||||
| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) |
|
|
||||||
| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) |
|
|
||||||
|
|
||||||
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
|
## Dependency notes
|
||||||
|
|
||||||
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI).
|
- **PySide6** is the Qt binding (LGPLv3). PyQt5 was replaced because its free option is
|
||||||
- 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.
|
GPLv3-or-commercial; the apps are PySide6-only and their tests enforce it
|
||||||
- **Open3D is excluded** (spec + build env). Finger 3D remains a placeholder in the UI.
|
(`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
|
## Size / startup
|
||||||
|
|
||||||
- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder.
|
- **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.
|
- 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).
|
1. `setup.bat` (see its report: interpreter, conda or not, SDK source).
|
||||||
2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE.
|
2. `build.bat` — its smoke test must print "the application started" and "the packaged 3D
|
||||||
3. Camera preview + box/mini/finger Run (video) with image modes and force plots.
|
scene loaded".
|
||||||
4. Confirm no `open3d*` files under `dist\OrisysStudio`.
|
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)
|
## Editable run (unchanged)
|
||||||
|
|
||||||
|
|||||||
+230
-53
@@ -1,90 +1,134 @@
|
|||||||
#Requires -Version 5.1
|
#Requires -Version 5.1
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.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
|
.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
|
.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(
|
param(
|
||||||
[switch]$Clean,
|
[switch]$Clean,
|
||||||
[switch]$SkipInstall
|
[switch]$SkipInstall,
|
||||||
|
[switch]$RebuildVenv,
|
||||||
|
[switch]$SkipSmokeTest,
|
||||||
|
[switch]$StrictSmokeTest,
|
||||||
|
[string]$GlProfile = "desktop33"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
$StudioDir = $PSScriptRoot
|
||||||
|
$RepoRoot = (Resolve-Path (Join-Path $StudioDir "..\..")).Path
|
||||||
$VenvDir = Join-Path $RepoRoot ".venv-build"
|
$VenvDir = Join-Path $RepoRoot ".venv-build"
|
||||||
$Python = Join-Path $VenvDir "Scripts\python.exe"
|
$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"
|
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
|
||||||
$DistPath = Join-Path $RepoRoot "dist"
|
$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"
|
Write-Host "Repo: $RepoRoot"
|
||||||
|
|
||||||
if (-not (Test-Path $Python)) {
|
# --------------------------------------------------------------- 1. provisioning
|
||||||
Write-Host "Creating build venv at $VenvDir"
|
if ($RebuildVenv) {
|
||||||
python -m venv $VenvDir
|
Write-Host "Recreating the build environment ..."
|
||||||
|
& $Setup -Force
|
||||||
|
Assert-Ok "setup.ps1 -Force"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $SkipInstall) {
|
$stampRoot = ""
|
||||||
Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…"
|
if (Test-Path $StampPath) {
|
||||||
& $Python -m pip install --upgrade pip
|
$stampRoot = (Get-Content $StampPath -Raw | ConvertFrom-Json).checkout_root
|
||||||
& $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
|
|
||||||
}
|
}
|
||||||
|
$needsSetup = (-not (Test-Path $Python)) -or ($stampRoot -ne $RepoRoot)
|
||||||
# Guard: Open3D must not be present in the build env
|
if ($needsSetup) {
|
||||||
$hasOpen3d = & $Python -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
|
if ($SkipInstall) {
|
||||||
if ($hasOpen3d -eq "True") {
|
throw "the build environment is missing or was provisioned for another checkout. Run setup.bat (or drop -SkipInstall)."
|
||||||
Write-Host "Uninstalling open3d from build venv…"
|
|
||||||
& $Python -m pip uninstall -y open3d
|
|
||||||
}
|
}
|
||||||
|
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 = @(
|
$pyiArgs = @(
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
"--distpath", $DistPath,
|
"--distpath", $DistPath,
|
||||||
"--workpath", $WorkPath,
|
"--workpath", $WorkPath,
|
||||||
$Spec
|
$Spec
|
||||||
)
|
)
|
||||||
if ($Clean) {
|
if ($Clean) { $pyiArgs = @("--clean") + $pyiArgs }
|
||||||
$pyiArgs = @("--clean") + $pyiArgs
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Running PyInstaller…"
|
Write-Host "Running PyInstaller ..."
|
||||||
& $Python -m PyInstaller @pyiArgs
|
& $Python -m PyInstaller @pyiArgs
|
||||||
if ($LASTEXITCODE -ne 0) {
|
Assert-Ok "PyInstaller"
|
||||||
Write-Error "PyInstaller failed with exit code $LASTEXITCODE"
|
|
||||||
exit $LASTEXITCODE
|
|
||||||
}
|
|
||||||
|
|
||||||
$OutDir = Join-Path $DistPath "OrisysStudio"
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Build complete: $OutDir"
|
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"
|
$ConfigSrc = Join-Path $RepoRoot "apps\studio\config"
|
||||||
$ConfigDst = Join-Path $OutDir "config"
|
$ConfigDst = Join-Path $OutDir "config"
|
||||||
if (Test-Path $ConfigSrc) {
|
if (Test-Path $ConfigSrc) {
|
||||||
Write-Host "Copying lean config to $ConfigDst …"
|
Write-Host "Copying lean config to $ConfigDst ..."
|
||||||
if (Test-Path $ConfigDst) {
|
if (Test-Path $ConfigDst) { Remove-Item $ConfigDst -Recurse -Force }
|
||||||
Remove-Item $ConfigDst -Recurse -Force
|
|
||||||
}
|
|
||||||
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
|
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
|
||||||
Get-ChildItem $ConfigSrc -Force | ForEach-Object {
|
Get-ChildItem $ConfigSrc -Force | ForEach-Object {
|
||||||
Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force
|
Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force
|
||||||
@@ -96,15 +140,34 @@ 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
|
||||||
# Size summary
|
# step that removed one of them would leave a pane that explains itself instead of drawing.
|
||||||
if (Test-Path $OutDir) {
|
Write-Host "Checking the shipped 3D prerequisites ..."
|
||||||
$bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue |
|
$scenePackages = @(Get-ChildItem (Join-Path $ConfigDst "sensors") -Recurse -Filter "*.oryscene" -ErrorAction SilentlyContinue)
|
||||||
Measure-Object -Property Length -Sum).Sum
|
if ($scenePackages.Count -eq 0) {
|
||||||
$mb = [math]::Round($bytes / 1MB, 1)
|
Write-Warning "No *.oryscene package under config\sensors: the 3D pane will report 'no compiled scene package'."
|
||||||
Write-Host ("Output size: {0} MB" -f $mb)
|
} 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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- 5. no Open3D
|
||||||
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
|
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
|
||||||
Where-Object { $_.Name -match 'open3d' }
|
Where-Object { $_.Name -match 'open3d' }
|
||||||
if ($o3dHits) {
|
if ($o3dHits) {
|
||||||
@@ -113,4 +176,118 @@ if (Test-Path $OutDir) {
|
|||||||
} else {
|
} else {
|
||||||
Write-Host "Open3D check: none found under dist."
|
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)
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
||||||
@@ -13,6 +15,50 @@ SPEC_DIR = Path(SPECPATH).resolve()
|
|||||||
REPO_ROOT = SPEC_DIR.parents[1]
|
REPO_ROOT = SPEC_DIR.parents[1]
|
||||||
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
|
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
|
||||||
ENTRY = SPEC_DIR / "entry.py"
|
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]]:
|
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).
|
# Config is copied beside the EXE by build.ps1 (writable / editable).
|
||||||
# Branding + package data go into _internal via Analysis datas.
|
# Branding + package data go into _internal via Analysis datas.
|
||||||
datas = _branding_datas()
|
datas = _branding_datas()
|
||||||
binaries: list = []
|
binaries: list = _extra_runtime_binaries()
|
||||||
hiddenimports = [
|
hiddenimports = [
|
||||||
"cv2",
|
"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",
|
"pyqtgraph.graphicsItems",
|
||||||
"scipy.signal",
|
"scipy.signal",
|
||||||
"numba",
|
"numba",
|
||||||
@@ -68,29 +119,62 @@ a = Analysis(
|
|||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[
|
excludes=[
|
||||||
"open3d",
|
"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",
|
"tkinter",
|
||||||
"matplotlib",
|
"matplotlib",
|
||||||
"IPython",
|
"IPython",
|
||||||
"jupyter",
|
"jupyter",
|
||||||
"notebook",
|
"notebook",
|
||||||
"pytest",
|
"pytest",
|
||||||
"PyQt5.QtBluetooth",
|
# The app uses PySide6 only. Qt modules it never imports are excluded to keep the
|
||||||
"PyQt5.QtNfc",
|
# bundle small — but `"PySide6"` itself must NOT appear here: excluding the whole
|
||||||
"PyQt5.QtPositioning",
|
# binding produces an EXE with no Qt at all (it fails at start-up, not at build
|
||||||
"PyQt5.QtLocation",
|
# time). `PySide6.QtOpenGLWidgets` is required by the 3D pane and is deliberately
|
||||||
"PyQt5.QtMultimedia",
|
# absent from this list.
|
||||||
"PyQt5.QtMultimediaWidgets",
|
"PySide6.Qt3DAnimation",
|
||||||
"PyQt5.QtSensors",
|
"PySide6.Qt3DCore",
|
||||||
"PyQt5.QtSerialPort",
|
"PySide6.Qt3DExtras",
|
||||||
"PyQt5.QtWebChannel",
|
"PySide6.Qt3DInput",
|
||||||
"PyQt5.QtWebEngine",
|
"PySide6.Qt3DLogic",
|
||||||
"PyQt5.QtWebEngineCore",
|
"PySide6.Qt3DRender",
|
||||||
"PyQt5.QtWebEngineWidgets",
|
"PySide6.QtBluetooth",
|
||||||
"PyQt5.QtWebSockets",
|
"PySide6.QtCharts",
|
||||||
"PySide2",
|
"PySide6.QtDataVisualization",
|
||||||
"PySide6",
|
"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",
|
"PyQt6",
|
||||||
|
"PySide2",
|
||||||
],
|
],
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user