autocal layout first design
This commit is contained in:
@@ -2,18 +2,35 @@
|
|||||||
|
|
||||||
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` | Scaffold shell (theme / locale / prefs / logging) | PyQt5 |
|
||||||
| `apps/internal` | Stub for future calibration & automated testing |
|
| `apps/autocal` | Internal 5-axis calibration platform control | **PySide6** |
|
||||||
|
| `apps/internal` | Stub, superseded by `apps/autocal` | — |
|
||||||
|
|
||||||
|
The two shipped apps use different Qt bindings on purpose. PyQt5's free option
|
||||||
|
is **GPLv3-or-commercial**; PySide6 is **LGPLv3**, which is why the internal
|
||||||
|
closed-source tool uses PySide6 and why `shared/` must import neither binding.
|
||||||
|
Migrating `apps/studio` to PySide6 is a separate, build-validated change.
|
||||||
|
|
||||||
## 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,142 @@
|
|||||||
|
# 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`, which the studio app uses, is GPLv3-or-commercial.)
|
||||||
|
|
||||||
|
```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 no binding at
|
||||||
|
all, and the controller interface must stay importable headless.
|
||||||
|
|
||||||
|
## 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,254 @@
|
|||||||
|
{
|
||||||
|
"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_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_save": "Save sequence…",
|
||||||
|
"btn_load": "Load sequence…",
|
||||||
|
"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",
|
||||||
|
"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,254 @@
|
|||||||
|
{
|
||||||
|
"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_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_save": "保存序列…",
|
||||||
|
"btn_load": "载入序列…",
|
||||||
|
"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": "待测",
|
||||||
|
"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,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,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,487 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
#: 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)``.
|
||||||
|
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"),
|
||||||
|
("actions", "sequence.col_actions"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 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 = (50, 130, 130, 130, 120, 160, 90, 200)
|
||||||
|
|
||||||
|
#: Reserved controls and the phase that will fill each one.
|
||||||
|
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load")
|
||||||
|
|
||||||
|
#: Which phase each pending control belongs to (see docs/workflow.md).
|
||||||
|
PENDING_PHASES: dict[str, str] = {
|
||||||
|
"generate": "P5",
|
||||||
|
"simulate": "P6",
|
||||||
|
"dry_run": "P6",
|
||||||
|
"save": "P8",
|
||||||
|
"load": "P8",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 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()
|
||||||
|
saveRequested = Signal()
|
||||||
|
loadRequested = 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] = {}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ profile
|
||||||
|
|
||||||
|
def _build_profile(self) -> QWidget:
|
||||||
|
box = QGroupBox(self)
|
||||||
|
box.setObjectName("groupBox")
|
||||||
|
grid = QGridLayout(box)
|
||||||
|
grid.setContentsMargins(10, 8, 10, 8)
|
||||||
|
grid.setHorizontalSpacing(10)
|
||||||
|
grid.setVerticalSpacing(4)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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._pending_buttons["save"].clicked.connect(self.saveRequested)
|
||||||
|
self._pending_buttons["load"].clicked.connect(self.loadRequested)
|
||||||
|
# None of these can run: generation, simulation, the runner and the
|
||||||
|
# dataset writer belong to later phases (see docs/workflow.md).
|
||||||
|
for button in self._pending_buttons.values():
|
||||||
|
button.setEnabled(False)
|
||||||
|
|
||||||
|
self._validation_box = box
|
||||||
|
return box
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- point table
|
||||||
|
|
||||||
|
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: "—",
|
||||||
|
7: "",
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
column.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
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: "—",
|
||||||
|
7: "",
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
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.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])
|
||||||
|
)
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
|
||||||
|
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,424 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@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)))
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
"""The AutoCal main window: panels, menus, and the bridge to the poller.
|
||||||
|
|
||||||
|
The window never touches the controller itself. Every controller call goes
|
||||||
|
through :class:`~orisys_autocal.motion.poller.MotionPoller`, which owns the DLL
|
||||||
|
handle on its own thread; results arrive as signals. That keeps the vendor
|
||||||
|
library's blocking calls (a ROM write, a bus scan) off the GUI thread and keeps
|
||||||
|
the ctypes handle single-threaded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||||
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
|
from orisys_app_shared.paths import outputs_dir
|
||||||
|
from orisys_app_shared.theme import build_qss, load_colors
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
from PySide6.QtGui import QAction, QCloseEvent
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QFileDialog,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
|
QScrollArea,
|
||||||
|
QSplitter,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import __version__ as app_version
|
||||||
|
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||||
|
from ..preferences import load_preferences, save_preferences
|
||||||
|
from .axis_panel import AxisPanel
|
||||||
|
from .axis_readout import AxisReadoutPanel
|
||||||
|
from .brand_bar import BrandBar
|
||||||
|
from .bus_data_panel import BusDataPanel
|
||||||
|
from .bus_panel import BusPanel, default_bas_dir
|
||||||
|
from .connection_panel import ConnectionPanel
|
||||||
|
from .sequence_panel import SequencePanel
|
||||||
|
from .status_strip import RunBar, StatusStrip
|
||||||
|
from .workspace import TABS, WorkspaceTabs
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: The main split: setup + readout rail, then the workspace.
|
||||||
|
#:
|
||||||
|
#: Two columns, not three. Two thin rails bracketing a workspace that has to hold
|
||||||
|
#: a twelve-field form, a 3D preview, a checks list and an eight-column table
|
||||||
|
#: cost 566 px of a 1920 screen — 29% — for chrome. Merging them into one rail
|
||||||
|
#: gives the workspace that 240 px back at every window size, which is the
|
||||||
|
#: difference between a workable sequence page and a cramped one at the minimum
|
||||||
|
#: window width.
|
||||||
|
MAIN_SPLITTER_SIZES = (330, 1560)
|
||||||
|
|
||||||
|
#: Width below which the rail would clip its own controls.
|
||||||
|
MAIN_MINIMUM_WIDTH = 1440
|
||||||
|
|
||||||
|
#: Default and minimum window sizes. The default is the design target; the
|
||||||
|
#: minimum stays viable on a smaller bench monitor.
|
||||||
|
DEFAULT_WINDOW_SIZE = (1920, 1080)
|
||||||
|
MINIMUM_WINDOW_SIZE = (1440, 900)
|
||||||
|
|
||||||
|
#: The action key whose result the status bar is currently showing.
|
||||||
|
_COMMAND_NAMES = {
|
||||||
|
"connect": "connection.open",
|
||||||
|
"disconnect": "connection.close",
|
||||||
|
"scan": "connection.scan",
|
||||||
|
"bas": "bus.download",
|
||||||
|
"reinit": "bus.reinit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AutoCalMainWindow(QMainWindow):
|
||||||
|
"""Platform control, five axes, one window."""
|
||||||
|
|
||||||
|
def __init__(self, prefs: dict | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.prefs = dict(prefs or load_preferences())
|
||||||
|
self._theme = str(self.prefs.get("theme") or "dark")
|
||||||
|
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||||
|
self._last_snapshot = Snapshot()
|
||||||
|
self._pending: dict[int, str] = {}
|
||||||
|
self._last_error = ""
|
||||||
|
|
||||||
|
self.setWindowTitle(t("app.title"))
|
||||||
|
self.setMinimumSize(*MINIMUM_WINDOW_SIZE)
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._build_menus()
|
||||||
|
self._build_poller()
|
||||||
|
|
||||||
|
self._restore_geometry()
|
||||||
|
self.retranslate()
|
||||||
|
self._apply_theme()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- build UI
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
central = QWidget(self)
|
||||||
|
layout = QVBoxLayout(central)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
self.brand_bar = BrandBar(self._theme, central)
|
||||||
|
layout.addWidget(self.brand_bar)
|
||||||
|
|
||||||
|
# The status strip sits above the workspace, outside the tab widget, so
|
||||||
|
# that switching tabs cannot hide whether the platform is live. The run
|
||||||
|
# bar sits under it for the same reason: abort must be reachable from
|
||||||
|
# every tab, not only from the one that started the run.
|
||||||
|
self.status_strip = StatusStrip(self._theme, central)
|
||||||
|
layout.addWidget(self.status_strip)
|
||||||
|
self.run_bar = RunBar(self._theme, central)
|
||||||
|
layout.addWidget(self.run_bar)
|
||||||
|
|
||||||
|
body = QWidget(central)
|
||||||
|
body_layout = QHBoxLayout(body)
|
||||||
|
body_layout.setContentsMargins(8, 6, 8, 6)
|
||||||
|
body_layout.setSpacing(8)
|
||||||
|
layout.addWidget(body, stretch=1)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- rail
|
||||||
|
# One rail, not two. It holds setup, the axis readout and the collapsed
|
||||||
|
# bus data, and it scrolls: at 900 px high those three groups do not fit,
|
||||||
|
# and a rail that clips its own controls is worse than one that scrolls.
|
||||||
|
self.rail_content = QWidget()
|
||||||
|
left = QVBoxLayout(self.rail_content)
|
||||||
|
left.setContentsMargins(0, 0, 0, 0)
|
||||||
|
left.setSpacing(8)
|
||||||
|
|
||||||
|
connection = self.prefs.get("connection") or {}
|
||||||
|
self.connection_panel = ConnectionPanel(
|
||||||
|
ConnectionSpec(
|
||||||
|
mode=str(connection.get("mode") or "eth"),
|
||||||
|
ip=str(connection.get("ip") or ""),
|
||||||
|
com_id=int(connection.get("com_id") or 0),
|
||||||
|
card_num=int(connection.get("card_num") or 0),
|
||||||
|
local_string=str(connection.get("local_string") or "0"),
|
||||||
|
),
|
||||||
|
self.rail_content,
|
||||||
|
)
|
||||||
|
self.bus_panel = BusPanel(str(self.prefs.get("bas_path") or ""), self.rail_content)
|
||||||
|
self.axis_readout = AxisReadoutPanel(
|
||||||
|
MotionService().axis_count, self._theme, self.rail_content
|
||||||
|
)
|
||||||
|
left.addWidget(self.connection_panel)
|
||||||
|
left.addWidget(self.bus_panel)
|
||||||
|
left.addWidget(self.axis_readout)
|
||||||
|
|
||||||
|
# Bus data (SDO/RTEX) is commissioning-only, so it is collapsed by
|
||||||
|
# default: it was costing the bottom of the page ~270 px for a control
|
||||||
|
# used once in a while.
|
||||||
|
self.bus_data_group = QGroupBox(self.rail_content)
|
||||||
|
self.bus_data_group.setObjectName("groupBox")
|
||||||
|
self.bus_data_group.setCheckable(True)
|
||||||
|
self.bus_data_group.setChecked(False)
|
||||||
|
bus_data_layout = QVBoxLayout(self.bus_data_group)
|
||||||
|
bus_data_layout.setContentsMargins(8, 6, 8, 8)
|
||||||
|
self.bus_data_panel = BusDataPanel(
|
||||||
|
sdo_last=self.prefs.get("sdo_last") or {},
|
||||||
|
rtex_last=self.prefs.get("rtex_last") or {},
|
||||||
|
axis_count=MotionService().axis_count,
|
||||||
|
parent=self.bus_data_group,
|
||||||
|
)
|
||||||
|
bus_data_layout.addWidget(self.bus_data_panel)
|
||||||
|
self.bus_data_panel.setVisible(False)
|
||||||
|
self.bus_data_group.toggled.connect(self._on_bus_data_toggled)
|
||||||
|
left.addWidget(self.bus_data_group)
|
||||||
|
left.addStretch(1)
|
||||||
|
|
||||||
|
self.left_rail = QScrollArea(body)
|
||||||
|
self.left_rail.setObjectName("railScroll")
|
||||||
|
self.left_rail.setWidget(self.rail_content)
|
||||||
|
self.left_rail.setWidgetResizable(True)
|
||||||
|
self.left_rail.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||||
|
self.left_rail.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
self.left_rail.setMinimumWidth(300)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ workspace
|
||||||
|
params = {
|
||||||
|
axis: AxisParams(**values)
|
||||||
|
for axis, values in _safe_axis_params(self.prefs.get("axis_params"))
|
||||||
|
}
|
||||||
|
self.axis_panel = AxisPanel(
|
||||||
|
MotionService().axis_count,
|
||||||
|
params,
|
||||||
|
units_per_mm=float(self.prefs.get("units_per_mm") or 10.0),
|
||||||
|
parent=body,
|
||||||
|
)
|
||||||
|
self.sequence_panel = SequencePanel(self.prefs.get("sequence_profile") or {}, body)
|
||||||
|
self.workspace = WorkspaceTabs(self.axis_panel, self.sequence_panel, body)
|
||||||
|
|
||||||
|
self.main_splitter = QSplitter(Qt.Orientation.Horizontal, body)
|
||||||
|
self.main_splitter.addWidget(self.left_rail)
|
||||||
|
self.main_splitter.addWidget(self.workspace)
|
||||||
|
self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES))
|
||||||
|
self.main_splitter.setChildrenCollapsible(False)
|
||||||
|
self.main_splitter.setStretchFactor(0, 0)
|
||||||
|
self.main_splitter.setStretchFactor(1, 1)
|
||||||
|
self.main_splitter.setObjectName("mainSplitter")
|
||||||
|
body_layout.addWidget(self.main_splitter, 1)
|
||||||
|
|
||||||
|
self.diagnostics_box = self._build_diagnostics(central)
|
||||||
|
layout.addWidget(self.diagnostics_box)
|
||||||
|
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
self.statusBar().showMessage(t("status.ready"))
|
||||||
|
self._apply_chrome_colours()
|
||||||
|
|
||||||
|
self._wire_signals()
|
||||||
|
|
||||||
|
def _wire_signals(self) -> None:
|
||||||
|
self.connection_panel.connectRequested.connect(self._on_connect)
|
||||||
|
self.connection_panel.disconnectRequested.connect(self._on_disconnect)
|
||||||
|
self.connection_panel.scanRequested.connect(self._on_scan)
|
||||||
|
self.bus_panel.chooseRequested.connect(self._on_choose_bas)
|
||||||
|
self.bus_panel.downloadRequested.connect(self._on_download_bas)
|
||||||
|
self.bus_panel.reinitRequested.connect(self._on_reinit)
|
||||||
|
self.axis_panel.paramsApplied.connect(self._on_params_applied)
|
||||||
|
self.axis_panel.enableToggled.connect(self._on_enable_toggled)
|
||||||
|
self.axis_panel.jogRequested.connect(self._on_jog)
|
||||||
|
self.axis_panel.stopRequested.connect(self._on_stop)
|
||||||
|
self.axis_panel.stopAllRequested.connect(self._on_stop_all)
|
||||||
|
self.axis_panel.moveAbsRequested.connect(self._on_move_abs)
|
||||||
|
self.axis_panel.moveRelRequested.connect(self._on_move_rel)
|
||||||
|
self.axis_panel.homeRequested.connect(self._on_home)
|
||||||
|
self.axis_panel.clearAlarmRequested.connect(self._on_clear_alarm)
|
||||||
|
self.bus_data_panel.sdoReadRequested.connect(self._on_sdo_read)
|
||||||
|
self.bus_data_panel.sdoWriteRequested.connect(self._on_sdo_write)
|
||||||
|
self.bus_data_panel.rtexReadRequested.connect(self._on_rtex_read)
|
||||||
|
self.bus_data_panel.rtexWriteRequested.connect(self._on_rtex_write)
|
||||||
|
self.status_strip.stopAllRequested.connect(self._on_stop_all)
|
||||||
|
self.status_strip.pauseRequested.connect(self._on_pause_polling)
|
||||||
|
self.run_bar.runRequested.connect(self._on_run_requested)
|
||||||
|
self.run_bar.pauseRequested.connect(self._on_run_pause)
|
||||||
|
self.run_bar.abortRequested.connect(self._on_run_abort)
|
||||||
|
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
|
||||||
|
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
|
||||||
|
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- run bar
|
||||||
|
|
||||||
|
def _on_run_requested(self) -> None:
|
||||||
|
"""Refuse, naming the phase that owns the runner.
|
||||||
|
|
||||||
|
Connected rather than left dangling: a control that does nothing when
|
||||||
|
pressed is indistinguishable from a bug, and the point of wiring this now
|
||||||
|
is that the sooner it exists, the sooner the phase that fills it has a
|
||||||
|
seam to build against.
|
||||||
|
"""
|
||||||
|
self._report_run("run.not_implemented", ok=False)
|
||||||
|
|
||||||
|
def _on_run_pause(self) -> None:
|
||||||
|
self.run_bar.set_phase("paused" if self.run_bar.phase != "paused" else "running")
|
||||||
|
|
||||||
|
def _on_run_abort(self) -> None:
|
||||||
|
# Abort always works, even before the runner exists: it stops the axes.
|
||||||
|
self.run_bar.set_phase("failed")
|
||||||
|
self._on_stop_all()
|
||||||
|
self._report_run("run.abort_stopped", ok=True)
|
||||||
|
|
||||||
|
def _report_run(self, locale_key: str, *, ok: bool) -> None:
|
||||||
|
text = t(locale_key)
|
||||||
|
self.statusBar().showMessage(text)
|
||||||
|
self.status_strip.set_action_result(text, ok)
|
||||||
|
|
||||||
|
def _apply_chrome_colours(self) -> None:
|
||||||
|
"""Style the status bar from the theme rather than Qt's default.
|
||||||
|
|
||||||
|
``brand_bar_bg`` is a dark navy that reads well as a header and heavy as
|
||||||
|
a footer, so the bar gets its own token with the brand colour as the
|
||||||
|
fallback for a theme that predates it.
|
||||||
|
"""
|
||||||
|
colours = load_colors(self._theme)
|
||||||
|
background = colours.get("status_bar_bg") or colours.get("brand_bar_bg", "#12315D")
|
||||||
|
text = colours.get("status_bar_text") or "#ffffff"
|
||||||
|
self.statusBar().setStyleSheet(
|
||||||
|
f"QStatusBar {{ background-color: {background}; color: {text}; }}"
|
||||||
|
f"QStatusBar::item {{ border: none; }}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_bus_data_toggled(self, expanded: bool) -> None:
|
||||||
|
self.bus_data_panel.setVisible(bool(expanded))
|
||||||
|
self.bus_data_group.setMaximumHeight(16777215 if expanded else 28)
|
||||||
|
|
||||||
|
def _on_pause_polling(self) -> None:
|
||||||
|
"""Stop or resume the state poll, without touching the connection."""
|
||||||
|
paused = self.status_strip.paused
|
||||||
|
if paused:
|
||||||
|
self.poller.pause()
|
||||||
|
self.statusBar().showMessage(t("status.paused"))
|
||||||
|
else:
|
||||||
|
self.poller.resume()
|
||||||
|
self.statusBar().showMessage(t("status.ready"))
|
||||||
|
# The health chip says "paused" rather than ageing into a false alarm.
|
||||||
|
self.status_strip.refresh_health()
|
||||||
|
|
||||||
|
def _on_enable_all(self, enabled: bool) -> None:
|
||||||
|
for axis in range(self.axis_panel.axis_count):
|
||||||
|
self.poller.submit(
|
||||||
|
lambda service, a=axis, on=enabled: service.set_axis_enable(a, on)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_clear_all_alarms(self) -> None:
|
||||||
|
for axis in range(self.axis_panel.axis_count):
|
||||||
|
self.poller.submit(lambda service, a=axis: service.clear_alarm(a))
|
||||||
|
|
||||||
|
def _build_diagnostics(self, parent: QWidget) -> QGroupBox:
|
||||||
|
box = QGroupBox(parent)
|
||||||
|
box.setObjectName("groupBox")
|
||||||
|
layout = QVBoxLayout(box)
|
||||||
|
layout.setContentsMargins(10, 8, 10, 10)
|
||||||
|
layout.setSpacing(4)
|
||||||
|
self.diagnostics_label = QLabel(box)
|
||||||
|
self.diagnostics_label.setObjectName("hintLabel")
|
||||||
|
self.diagnostics_label.setWordWrap(True)
|
||||||
|
self.diagnostics_label.setTextInteractionFlags(
|
||||||
|
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||||
|
)
|
||||||
|
layout.addWidget(self.diagnostics_label)
|
||||||
|
box.setVisible(bool(self.prefs.get("show_diagnostics")))
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _build_menus(self) -> None:
|
||||||
|
menu_bar = self.menuBar()
|
||||||
|
|
||||||
|
self.menu_file = menu_bar.addMenu("")
|
||||||
|
self.act_download_bas = QAction("", self)
|
||||||
|
self.act_download_bas.triggered.connect(self._on_download_bas)
|
||||||
|
self.act_reveal_config = QAction("", self)
|
||||||
|
self.act_reveal_config.triggered.connect(self._on_reveal_config)
|
||||||
|
self.act_reveal_outputs = QAction("", self)
|
||||||
|
self.act_reveal_outputs.triggered.connect(self._on_reveal_outputs)
|
||||||
|
self.act_quit = QAction("", self)
|
||||||
|
self.act_quit.triggered.connect(self.close)
|
||||||
|
for action in (
|
||||||
|
self.act_download_bas,
|
||||||
|
self.act_reveal_config,
|
||||||
|
self.act_reveal_outputs,
|
||||||
|
):
|
||||||
|
self.menu_file.addAction(action)
|
||||||
|
self.menu_file.addSeparator()
|
||||||
|
self.menu_file.addAction(self.act_quit)
|
||||||
|
|
||||||
|
self.menu_view = menu_bar.addMenu("")
|
||||||
|
self.menu_theme = self.menu_view.addMenu("")
|
||||||
|
self.act_theme_dark = QAction("", self)
|
||||||
|
self.act_theme_dark.setCheckable(True)
|
||||||
|
self.act_theme_light = QAction("", self)
|
||||||
|
self.act_theme_light.setCheckable(True)
|
||||||
|
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
|
||||||
|
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
|
||||||
|
self.menu_theme.addAction(self.act_theme_dark)
|
||||||
|
self.menu_theme.addAction(self.act_theme_light)
|
||||||
|
|
||||||
|
self.menu_language = self.menu_view.addMenu("")
|
||||||
|
self.act_lang_zh = QAction("", self)
|
||||||
|
self.act_lang_zh.setCheckable(True)
|
||||||
|
self.act_lang_en = QAction("", self)
|
||||||
|
self.act_lang_en.setCheckable(True)
|
||||||
|
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
|
||||||
|
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
|
||||||
|
self.menu_language.addAction(self.act_lang_zh)
|
||||||
|
self.menu_language.addAction(self.act_lang_en)
|
||||||
|
|
||||||
|
self.act_diagnostics = QAction("", self)
|
||||||
|
self.act_diagnostics.setCheckable(True)
|
||||||
|
self.act_diagnostics.setChecked(bool(self.prefs.get("show_diagnostics")))
|
||||||
|
self.act_diagnostics.toggled.connect(self._set_diagnostics)
|
||||||
|
self.menu_view.addSeparator()
|
||||||
|
self.menu_view.addAction(self.act_diagnostics)
|
||||||
|
|
||||||
|
# Tab shortcuts: the workspace is the page, so reaching a tab from the
|
||||||
|
# keyboard is the navigation an operator uses most.
|
||||||
|
self.menu_tabs = self.menu_view.addMenu("")
|
||||||
|
self.act_tabs: dict[str, QAction] = {}
|
||||||
|
for index, (key, locale_key) in enumerate(TABS):
|
||||||
|
action = QAction("", self)
|
||||||
|
action.setShortcut(f"Ctrl+{index + 1}")
|
||||||
|
action.triggered.connect(lambda _checked=False, k=key: self.workspace.set_current_key(k))
|
||||||
|
self.menu_tabs.addAction(action)
|
||||||
|
self.act_tabs[key] = action
|
||||||
|
self._tab_locale_keys = dict(TABS)
|
||||||
|
|
||||||
|
self.menu_help = menu_bar.addMenu("")
|
||||||
|
self.act_about = QAction("", self)
|
||||||
|
self.act_about.triggered.connect(self._on_about)
|
||||||
|
self.menu_help.addAction(self.act_about)
|
||||||
|
|
||||||
|
def _build_poller(self) -> None:
|
||||||
|
self.poller = MotionPoller(
|
||||||
|
MotionService(language=self._language),
|
||||||
|
axes=range(self.axis_panel.axis_count),
|
||||||
|
)
|
||||||
|
self.poller.snapshotReady.connect(self._on_snapshot)
|
||||||
|
self.poller.commandFinished.connect(self._on_command_finished)
|
||||||
|
self.poller.commandFailed.connect(self._on_command_failed)
|
||||||
|
self.poller.set_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||||
|
self.status_strip.set_poll_interval_ms(int(self.prefs.get("state_poll_ms") or 100))
|
||||||
|
|
||||||
|
# Ages the poll-health chip even when no snapshot arrives. Runs on the
|
||||||
|
# GUI thread and only reads a timestamp — the controller is untouched —
|
||||||
|
# so a stopped poll becomes visible instead of leaving stale chips
|
||||||
|
# looking healthy.
|
||||||
|
self._health_timer = QTimer(self)
|
||||||
|
self._health_timer.timeout.connect(self.status_strip.refresh_health)
|
||||||
|
self._health_timer.start(500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- controller
|
||||||
|
|
||||||
|
def connect_controller(
|
||||||
|
self, spec: ConnectionSpec | None = None, *, label: str = "connect"
|
||||||
|
) -> int:
|
||||||
|
"""Ask the poller to open the controller. Returns the command id."""
|
||||||
|
target = spec or self.connection_panel.spec
|
||||||
|
token = self.poller.connect_controller(target)
|
||||||
|
self._pending[token] = label
|
||||||
|
self.connection_panel.set_status(t("connection.state_connecting"))
|
||||||
|
return token
|
||||||
|
|
||||||
|
def disconnect_controller(self, *, label: str = "disconnect") -> int:
|
||||||
|
token = self.poller.disconnect_controller()
|
||||||
|
self._pending[token] = label
|
||||||
|
return token
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- snapshot
|
||||||
|
|
||||||
|
def _on_snapshot(self, snapshot: Snapshot) -> None:
|
||||||
|
"""Signal handler for the poller's periodic state read."""
|
||||||
|
self.update_snapshot(snapshot)
|
||||||
|
|
||||||
|
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||||
|
self._last_snapshot = snapshot
|
||||||
|
self.connection_panel.set_connected(
|
||||||
|
snapshot.connected, self.poller.service.transport.source
|
||||||
|
)
|
||||||
|
self.brand_bar.set_connected(snapshot.connected)
|
||||||
|
self.bus_panel.set_connected(snapshot.connected)
|
||||||
|
self.bus_panel.set_status(snapshot.bus)
|
||||||
|
# The bus-data panel gates on the connection first and the bus family
|
||||||
|
# second, so both have to reach it or every SDO/RTEX control stays dead.
|
||||||
|
self.bus_data_panel.set_connected(snapshot.connected)
|
||||||
|
self.bus_data_panel.set_bus_type(snapshot.bus.bus_type)
|
||||||
|
self.axis_panel.update_snapshot(snapshot)
|
||||||
|
self.axis_readout.update_snapshot(snapshot)
|
||||||
|
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||||
|
# of which tab is showing.
|
||||||
|
self.status_strip.set_snapshot(snapshot)
|
||||||
|
# The run bar tracks the designed point list: before a runner exists that
|
||||||
|
# is zero, and its abort control stays correct either way.
|
||||||
|
self.run_bar.set_progress(0, len(self.sequence_panel.planned_steps()))
|
||||||
|
self.run_bar.set_run(bool(self.sequence_panel.planned_steps()))
|
||||||
|
if snapshot.error:
|
||||||
|
self._last_error = snapshot.error
|
||||||
|
self._refresh_status_bar()
|
||||||
|
if self.diagnostics_box.isVisible():
|
||||||
|
self._refresh_diagnostics()
|
||||||
|
|
||||||
|
def _refresh_status_bar(self) -> None:
|
||||||
|
snapshot = self._last_snapshot
|
||||||
|
if not snapshot.connected:
|
||||||
|
self.statusBar().showMessage(t("status.ready"))
|
||||||
|
return
|
||||||
|
parts = [
|
||||||
|
t("status.poll_paused") if self.status_strip.paused else t("status.poll_ok"),
|
||||||
|
t("status.poll_every", ms=self.poller.interval_ms),
|
||||||
|
]
|
||||||
|
if snapshot.bus.init_status == 1:
|
||||||
|
parts.append(t("bus.state_ok"))
|
||||||
|
if self._last_error:
|
||||||
|
parts.append(f"{t('status.last_error')}: {self._last_error}")
|
||||||
|
self.statusBar().showMessage(" | ".join(parts))
|
||||||
|
|
||||||
|
def _refresh_diagnostics(self) -> None:
|
||||||
|
from ..preferences import path as preference_path
|
||||||
|
|
||||||
|
service = self.poller.service
|
||||||
|
self.diagnostics_label.setText(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
f"{t('diagnostics.binding')}: PySide6 {_pyside_version()}",
|
||||||
|
f"{t('diagnostics.dll_dir')}: {service.transport.source}",
|
||||||
|
f"{t('diagnostics.prefs')}: {preference_path()}",
|
||||||
|
f"{t('diagnostics.outputs')}: {outputs_dir()}",
|
||||||
|
f"{t('diagnostics.bas_loaded')}: "
|
||||||
|
f"{t('diagnostics.yes') if service.bas_loaded else t('diagnostics.no')}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------- command completion
|
||||||
|
|
||||||
|
def _on_command_finished(self, token: int, result) -> None:
|
||||||
|
"""Report a completed command, whatever shape its result has.
|
||||||
|
|
||||||
|
Commands return different things: a ``MotionResult``, a list of them
|
||||||
|
(stop-all), a ``(result, value)`` pair (SDO/RTEX reads), a list of IPs
|
||||||
|
(scan), or ``None``. Rather than a convention each caller must remember,
|
||||||
|
the shape is detected here.
|
||||||
|
"""
|
||||||
|
label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "")
|
||||||
|
name = t(label_key) if label_key else ""
|
||||||
|
|
||||||
|
message, ok = _interpret_result(result)
|
||||||
|
if ok:
|
||||||
|
self._last_error = ""
|
||||||
|
if message:
|
||||||
|
self.bus_data_panel.set_result(message)
|
||||||
|
text = f"{name} {t('action.ok')}".strip()
|
||||||
|
self.statusBar().showMessage(text)
|
||||||
|
# The strip keeps the outcome visible after a tab switch, so a
|
||||||
|
# command that was refused while the operator was elsewhere is not
|
||||||
|
# lost the moment the status bar is overwritten.
|
||||||
|
self.status_strip.set_action_result(text, True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_error = message or t("action.failed")
|
||||||
|
self.bus_data_panel.set_result(self._last_error)
|
||||||
|
text = f"{name} {t('action.refused')}: {self._last_error}"
|
||||||
|
self.statusBar().showMessage(text)
|
||||||
|
self.status_strip.set_action_result(text, False)
|
||||||
|
logger.warning("Command refused/failed: %s", self._last_error)
|
||||||
|
|
||||||
|
def _on_command_failed(self, token: int, message: str) -> None:
|
||||||
|
label_key = _COMMAND_NAMES.get(self._pending.pop(token, ""), "")
|
||||||
|
self._last_error = message
|
||||||
|
name = t(label_key) if label_key else ""
|
||||||
|
text = f"{name} {t('action.failed')}: {message}"
|
||||||
|
self.statusBar().showMessage(text)
|
||||||
|
self.status_strip.set_action_result(text, False)
|
||||||
|
logger.error("Command raised: %s", message)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- handlers
|
||||||
|
|
||||||
|
def _on_connect(self, spec: ConnectionSpec) -> None:
|
||||||
|
self.connect_controller(spec)
|
||||||
|
|
||||||
|
def _on_disconnect(self) -> None:
|
||||||
|
if not self._last_snapshot.connected:
|
||||||
|
return
|
||||||
|
moving = [
|
||||||
|
state.axis
|
||||||
|
for state in self._last_snapshot.axes
|
||||||
|
if state.enabled and not state.idle
|
||||||
|
]
|
||||||
|
if moving:
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
t("connection.close"),
|
||||||
|
t("axes.motion") + f" {moving}",
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
self.disconnect_controller()
|
||||||
|
|
||||||
|
def _on_scan(self) -> None:
|
||||||
|
def _scan(service: MotionService) -> object:
|
||||||
|
return service.transport.search_eth()
|
||||||
|
|
||||||
|
token = self.poller.submit(_scan)
|
||||||
|
self._pending[token] = "scan"
|
||||||
|
|
||||||
|
def _on_choose_bas(self) -> None:
|
||||||
|
start = self.bus_panel.bas_path or str(default_bas_dir())
|
||||||
|
path, _selected = QFileDialog.getOpenFileName(
|
||||||
|
self, t("bus.dialog_title"), start, "BAS (*.bas);;All files (*)"
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
self.bus_panel.set_bas_path(path)
|
||||||
|
self.prefs["bas_path"] = path
|
||||||
|
|
||||||
|
def _on_download_bas(self) -> None:
|
||||||
|
path = self.bus_panel.bas_path
|
||||||
|
if not path:
|
||||||
|
self._on_choose_bas()
|
||||||
|
path = self.bus_panel.bas_path
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
token = self.poller.submit(lambda service: service.download_bas(path, 1))
|
||||||
|
self._pending[token] = "bas"
|
||||||
|
self.statusBar().showMessage(t("bus.downloaded"))
|
||||||
|
|
||||||
|
def _on_reinit(self) -> None:
|
||||||
|
token = self.poller.submit(lambda service: service.reinit_bus())
|
||||||
|
self._pending[token] = "reinit"
|
||||||
|
|
||||||
|
def _on_params_applied(self, axis: int, params: AxisParams) -> None:
|
||||||
|
token = self.poller.submit(lambda service: service.set_axis_params(axis, params))
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
|
def _on_enable_toggled(self, axis: int, enabled: bool) -> None:
|
||||||
|
token = self.poller.submit(lambda service: service.set_axis_enable(axis, enabled))
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
|
def _on_jog(self, axis: int, forward: bool) -> None:
|
||||||
|
self.poller.submit(lambda service: service.jog(axis, forward))
|
||||||
|
|
||||||
|
def _on_stop(self, axis: int) -> None:
|
||||||
|
self.poller.submit(lambda service: service.stop(axis))
|
||||||
|
|
||||||
|
def _on_stop_all(self) -> None:
|
||||||
|
def _stop_all(service: MotionService) -> object:
|
||||||
|
return service.stop_all()
|
||||||
|
|
||||||
|
self.poller.submit(_stop_all)
|
||||||
|
|
||||||
|
def _on_move_abs(self, axis: int, target: float) -> None:
|
||||||
|
self.poller.submit(lambda service: service.move_abs(axis, target))
|
||||||
|
|
||||||
|
def _on_move_rel(self, axis: int, distance: float) -> None:
|
||||||
|
self.poller.submit(lambda service: service.move_rel(axis, distance))
|
||||||
|
|
||||||
|
def _on_home(self, axis: int) -> None:
|
||||||
|
self.poller.submit(lambda service: service.home(axis))
|
||||||
|
|
||||||
|
def _on_clear_alarm(self, axis: int) -> None:
|
||||||
|
self.poller.submit(lambda service: service.clear_alarm(axis))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- bus data
|
||||||
|
|
||||||
|
def _on_sdo_read(self, node: int, index: int, subindex: int, data_type: int) -> None:
|
||||||
|
def _read(service: MotionService):
|
||||||
|
result, value = service.sdo_read(node, index, subindex, data_type)
|
||||||
|
return result, value
|
||||||
|
|
||||||
|
token = self.poller.submit(_read)
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
|
def _on_sdo_write(
|
||||||
|
self, node: int, index: int, subindex: int, data_type: int, value: int
|
||||||
|
) -> None:
|
||||||
|
token = self.poller.submit(
|
||||||
|
lambda service: service.sdo_write(node, index, subindex, data_type, value)
|
||||||
|
)
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
|
def _on_rtex_read(self, axis: int, parameter: int) -> None:
|
||||||
|
token = self.poller.submit(lambda service: service.rtex_read(axis, parameter))
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
|
def _on_rtex_write(self, axis: int, parameter: int, value: float) -> None:
|
||||||
|
token = self.poller.submit(
|
||||||
|
lambda service: service.rtex_write(axis, parameter, value)
|
||||||
|
)
|
||||||
|
self._pending[token] = ""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ menus
|
||||||
|
|
||||||
|
def _set_theme(self, theme: str) -> None:
|
||||||
|
self._theme = theme
|
||||||
|
self.prefs["theme"] = theme
|
||||||
|
self._apply_theme()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _apply_theme(self) -> None:
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
app.setStyleSheet(build_qss(self._theme))
|
||||||
|
self.brand_bar.set_theme(self._theme)
|
||||||
|
self.status_strip.set_theme(self._theme)
|
||||||
|
self.run_bar.set_theme(self._theme)
|
||||||
|
self.axis_readout.set_theme(self._theme)
|
||||||
|
self._apply_chrome_colours()
|
||||||
|
self.act_theme_dark.setChecked(self._theme == "dark")
|
||||||
|
self.act_theme_light.setChecked(self._theme == "light")
|
||||||
|
|
||||||
|
def _set_language(self, language: str) -> None:
|
||||||
|
self._language = language
|
||||||
|
self.prefs["language"] = language
|
||||||
|
set_locale(language)
|
||||||
|
self.poller.set_language(language)
|
||||||
|
self.retranslate()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _set_diagnostics(self, visible: bool) -> None:
|
||||||
|
"""Show or hide the diagnostics box.
|
||||||
|
|
||||||
|
It used to be a splitter pane, which meant toggling it resized the whole
|
||||||
|
page. Now it is a fixed footer: the layout above it does not move, so an
|
||||||
|
operator cannot lose their place by turning the diagnostics on.
|
||||||
|
"""
|
||||||
|
self.prefs["show_diagnostics"] = bool(visible)
|
||||||
|
self.diagnostics_box.setVisible(bool(visible))
|
||||||
|
if visible:
|
||||||
|
self._refresh_diagnostics()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_reveal_config(self) -> None:
|
||||||
|
from orisys_app_shared.paths import config_dir
|
||||||
|
|
||||||
|
self._reveal(config_dir())
|
||||||
|
|
||||||
|
def _on_reveal_outputs(self) -> None:
|
||||||
|
self._reveal(outputs_dir())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reveal(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.Popen(["explorer", str(path)])
|
||||||
|
else: # pragma: no cover - the platform is Windows
|
||||||
|
subprocess.Popen(["xdg-open", str(path)])
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Could not open %s", path)
|
||||||
|
|
||||||
|
def _on_about(self) -> None:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
t("app.about_title"),
|
||||||
|
t(
|
||||||
|
"app.about_body",
|
||||||
|
app_version=app_version,
|
||||||
|
shared_version=shared_version,
|
||||||
|
api_version=API_VERSION,
|
||||||
|
binding=f"PySide6 {_pyside_version()}",
|
||||||
|
dll_dir=self.poller.service.transport.source,
|
||||||
|
prefs_path=self.prefs_path(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def prefs_path(self) -> str:
|
||||||
|
from ..preferences import path as preference_path
|
||||||
|
|
||||||
|
return str(preference_path())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- translations
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(f"{t('app.title')} {app_version}")
|
||||||
|
self.brand_bar.retranslate()
|
||||||
|
self.status_strip.retranslate()
|
||||||
|
self.run_bar.retranslate()
|
||||||
|
self.connection_panel.retranslate()
|
||||||
|
self.bus_panel.retranslate()
|
||||||
|
self.bus_data_group.setTitle(t("busdata.group"))
|
||||||
|
self.bus_data_panel.retranslate()
|
||||||
|
self.axis_panel.retranslate()
|
||||||
|
self.axis_readout.retranslate()
|
||||||
|
self.sequence_panel.retranslate()
|
||||||
|
self.workspace.retranslate()
|
||||||
|
|
||||||
|
self.menu_file.setTitle(t("menu.file"))
|
||||||
|
self.act_download_bas.setText(t("menu.open_bas"))
|
||||||
|
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||||
|
self.act_reveal_outputs.setText(t("menu.outputs"))
|
||||||
|
self.act_quit.setText(t("menu.quit"))
|
||||||
|
self.menu_view.setTitle(t("menu.view"))
|
||||||
|
self.menu_theme.setTitle(t("menu.theme"))
|
||||||
|
self.act_theme_dark.setText(t("menu.theme_dark"))
|
||||||
|
self.act_theme_light.setText(t("menu.theme_light"))
|
||||||
|
self.menu_language.setTitle(t("menu.language"))
|
||||||
|
self.act_lang_zh.setText(t("menu.lang_zh"))
|
||||||
|
self.act_lang_en.setText(t("menu.lang_en"))
|
||||||
|
self.act_diagnostics.setText(t("menu.show_diagnostics"))
|
||||||
|
self.menu_tabs.setTitle(t("menu.tabs"))
|
||||||
|
for key, locale_key in self._tab_locale_keys.items():
|
||||||
|
self.act_tabs[key].setText(t(locale_key))
|
||||||
|
self.menu_help.setTitle(t("menu.help"))
|
||||||
|
self.act_about.setText(t("menu.about"))
|
||||||
|
self.act_lang_zh.setChecked(self._language == "zh_CN")
|
||||||
|
self.act_lang_en.setChecked(self._language == "en_US")
|
||||||
|
self.diagnostics_box.setTitle(t("diagnostics.title"))
|
||||||
|
self._refresh_status_bar()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ persistence
|
||||||
|
|
||||||
|
def _restore_geometry(self) -> None:
|
||||||
|
geometry = self.prefs.get("window_geometry")
|
||||||
|
if isinstance(geometry, list) and len(geometry) == 4:
|
||||||
|
try:
|
||||||
|
self.setGeometry(*(int(value) for value in geometry))
|
||||||
|
self._apply_splitter_sizes()
|
||||||
|
return
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("Ignoring malformed window_geometry: %r", geometry)
|
||||||
|
self.resize(*DEFAULT_WINDOW_SIZE)
|
||||||
|
self._apply_splitter_sizes()
|
||||||
|
|
||||||
|
def _apply_splitter_sizes(self) -> None:
|
||||||
|
"""Restore the column split, or fall back to the designed proportions.
|
||||||
|
|
||||||
|
The previous layout stored three column widths in ``splitter_sizes`` for
|
||||||
|
a different splitter; that key no longer describes anything, so it is
|
||||||
|
ignored rather than misapplied.
|
||||||
|
"""
|
||||||
|
stored = self.prefs.get("main_splitter_sizes")
|
||||||
|
if isinstance(stored, list) and len(stored) == self.main_splitter.count():
|
||||||
|
try:
|
||||||
|
self.main_splitter.setSizes([int(value) for value in stored])
|
||||||
|
return
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("Ignoring malformed main_splitter_sizes: %r", stored)
|
||||||
|
self.main_splitter.setSizes(list(MAIN_SPLITTER_SIZES))
|
||||||
|
|
||||||
|
def _persist_prefs(self) -> None:
|
||||||
|
"""Write the current UI state to AutoCal's preference file."""
|
||||||
|
geometry = self.geometry()
|
||||||
|
self.prefs["window_geometry"] = [
|
||||||
|
geometry.x(),
|
||||||
|
geometry.y(),
|
||||||
|
geometry.width(),
|
||||||
|
geometry.height(),
|
||||||
|
]
|
||||||
|
self.prefs["main_splitter_sizes"] = [int(v) for v in self.main_splitter.sizes()]
|
||||||
|
self.prefs["bas_path"] = self.bus_panel.bas_path
|
||||||
|
self.prefs["units_per_mm"] = self.axis_panel._units_per_mm
|
||||||
|
self.prefs["axis_params"] = {
|
||||||
|
str(axis): _params_to_dict(params)
|
||||||
|
for axis, params in self.axis_panel.stored_params().items()
|
||||||
|
}
|
||||||
|
self.prefs.update(self.bus_data_panel.remember_state())
|
||||||
|
# The sequence profile is the P5 design artefact: whatever is on screen is
|
||||||
|
# what a later run would use, so it is persisted with everything else.
|
||||||
|
self.prefs["sequence_profile"] = self.sequence_panel.profile()
|
||||||
|
try:
|
||||||
|
save_preferences(self.prefs)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Could not write the preference file")
|
||||||
|
|
||||||
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override
|
||||||
|
"""Stop the platform, close the controller, then persist and leave."""
|
||||||
|
logger.info("Closing: stopping axes and shutting the poller down")
|
||||||
|
try:
|
||||||
|
self.poller.submit(lambda service: service.stop_all())
|
||||||
|
self.poller.disconnect_controller()
|
||||||
|
self.poller.shutdown()
|
||||||
|
except Exception: # noqa: BLE001 - closing must always finish
|
||||||
|
logger.exception("Shutdown reported a problem")
|
||||||
|
self._persist_prefs()
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
def _pyside_version() -> str:
|
||||||
|
"""The installed PySide6 version, for the About box and diagnostics."""
|
||||||
|
import PySide6
|
||||||
|
|
||||||
|
return getattr(PySide6, "__version__", "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_axis_params(raw: object) -> list[tuple[int, dict]]:
|
||||||
|
"""Turn the stored per-axis dict into ``AxisParams`` keyword groups.
|
||||||
|
|
||||||
|
A preference file written by an older build (or edited by hand) may be the
|
||||||
|
wrong shape; a bad entry is dropped rather than crashing the window.
|
||||||
|
"""
|
||||||
|
known = set(AxisParams.__dataclass_fields__)
|
||||||
|
result: list[tuple[int, dict]] = []
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return result
|
||||||
|
for key, values in raw.items():
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
axis = int(key)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
clean = {name: value for name, value in values.items() if name in known}
|
||||||
|
try:
|
||||||
|
result.append((axis, clean))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning("Ignoring malformed axis_params[%r]: %r", key, values)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _params_to_dict(params: AxisParams) -> dict:
|
||||||
|
return {
|
||||||
|
name: getattr(params, name) for name in AxisParams.__dataclass_fields__
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _interpret_result(result) -> tuple[str, bool]:
|
||||||
|
"""Reduce a command's result to ``(message, ok)``.
|
||||||
|
|
||||||
|
See :meth:`AutoCalMainWindow._on_command_finished` for why this is needed
|
||||||
|
rather than each caller knowing its own result shape.
|
||||||
|
"""
|
||||||
|
if result is None:
|
||||||
|
return "", True
|
||||||
|
|
||||||
|
# A single MotionResult (most commands).
|
||||||
|
if hasattr(result, "ok"):
|
||||||
|
return str(getattr(result, "message", "") or ""), bool(result.ok)
|
||||||
|
|
||||||
|
# A (MotionResult, value) pair from the SDO/RTEX reads. Checked before the
|
||||||
|
# plain-list case, because a tuple's first element is a MotionResult here
|
||||||
|
# while a stop-all list's items are.
|
||||||
|
if isinstance(result, tuple) and len(result) == 2 and hasattr(result[0], "ok"):
|
||||||
|
head, value = result
|
||||||
|
if not head.ok:
|
||||||
|
return str(getattr(head, "message", "") or ""), False
|
||||||
|
return t("busdata.read_ok", value=value), True
|
||||||
|
|
||||||
|
# A list of MotionResults (stop-all): ok when every one succeeded, and the
|
||||||
|
# first refusal's reason is the useful thing to show.
|
||||||
|
if isinstance(result, (list, tuple)) and result and hasattr(result[0], "ok"):
|
||||||
|
failures = [item for item in result if not item.ok]
|
||||||
|
if failures:
|
||||||
|
return str(getattr(failures[0], "message", "") or ""), False
|
||||||
|
return "", True
|
||||||
|
|
||||||
|
# A scanned address list.
|
||||||
|
if isinstance(result, (list, tuple)):
|
||||||
|
return ", ".join(str(item) for item in result), True
|
||||||
|
|
||||||
|
return str(result), True
|
||||||
@@ -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,137 @@
|
|||||||
|
"""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 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,185 @@
|
|||||||
|
"""Binding policy for AutoCal.
|
||||||
|
|
||||||
|
AutoCal is **PySide6 only**, and this file is the 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/`` — which serves both this app and the PyQt5-based studio — must
|
||||||
|
import no Qt binding at all;
|
||||||
|
* 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 an internal
|
||||||
|
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 the app must never 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.
|
||||||
|
_SCOPED_ENUMS = {
|
||||||
|
"Qt": (
|
||||||
|
"AlignmentFlag",
|
||||||
|
"ItemDataRole",
|
||||||
|
"Orientation",
|
||||||
|
"ConnectionType",
|
||||||
|
"TextInteractionFlag",
|
||||||
|
"TransformationMode",
|
||||||
|
"ToolButtonStyle",
|
||||||
|
"FocusPolicy",
|
||||||
|
"ScrollBarPolicy",
|
||||||
|
),
|
||||||
|
"QTableWidget": ("SelectionMode", "EditTrigger"),
|
||||||
|
"QSizePolicy": ("Policy",),
|
||||||
|
"QHeaderView": ("ResizeMode",),
|
||||||
|
"QMessageBox": ("StandardButton", "Icon"),
|
||||||
|
"QDialog": ("DialogCode",),
|
||||||
|
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 scoped spelling gets
|
||||||
|
*explained*), 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_qt_binding():
|
||||||
|
"""Shared code serves both products, so it must not pick a side."""
|
||||||
|
offenders = []
|
||||||
|
for path in _python_files(SHARED_SRC):
|
||||||
|
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
|
||||||
|
if qt_imports:
|
||||||
|
offenders.append(f"{path.name}: {sorted(qt_imports)}")
|
||||||
|
assert not offenders, (
|
||||||
|
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
|
||||||
|
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,469 @@
|
|||||||
|
"""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.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",
|
||||||
|
"save",
|
||||||
|
"load",
|
||||||
|
]
|
||||||
|
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_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_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,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 imports
|
||||||
|
PyQt5, and AutoCal's environment has none.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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() == 8,
|
||||||
|
f"the point list has {sequence.table.columnCount()} columns, expected 8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|||||||
@@ -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.
@@ -925,6 +925,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 +1178,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"))
|
||||||
@@ -1886,6 +1900,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 +2150,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 +2184,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 +2277,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 +2291,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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -514,6 +573,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 +592,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 +668,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 +790,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 +839,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))
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
Windows packaging for the studio desktop app: **one provisioning step per machine, then
|
Windows packaging for the studio desktop app: **one provisioning step per machine, then
|
||||||
repeatable builds.**
|
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
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Finger 显示一致性与低噪声读数实施方案
|
||||||
|
|
||||||
|
日期:2026-09-16。检查基线:orisys-app 07d7c3b。本文是实施计划,未修改运行代码;未连接实机,未验证目标 ARM 图形驱动。文中新增接口均为建议契约。
|
||||||
|
|
||||||
|
## 1. 决策
|
||||||
|
|
||||||
|
保留 Studio 的 QOpenGLWidget/PyOpenGL,移植旧示例的视觉计算和交互:深度热图、表面光流箭头、黄色接触法向箭头、相机和灯光预设。旧 Open3D 界面保留作对照工具。先证明同一输入能产生相同的接触区域、箭头和色标,再优化画质。
|
||||||
|
|
||||||
|
旧效果的主要实现不在 finger_pipeline_3dview.py 入口,而在 examples/viz/contact_arrow.py、flow_arrow.py、offscreen_mesh.py,以及 src/orisys/finger/mesh.py。几何计算大部分使用 NumPy;Open3D 部分负责网格对象、渲染和窗口。因此无需把整个旧 UI 移入 Studio。
|
||||||
|
|
||||||
|
如近期必须恢复桌面演示,可采用单独可选的 Open3D 进程,通过有界最新帧通道返回 RGB,Qt 显示图像并转发相机操作。旧 OffscreenFingerMeshRenderer 已有 RGB 输出接口,但还要处理进程生命周期、帧身份、超时、复制成本和平台支持。这是备选过渡方案,不作为默认 ARM 发布依赖;不要直接跨平台嵌入原生窗口句柄。
|
||||||
|
|
||||||
|
Open3D 官方 ARM 支持仍标为实验性,Linux OpenGL ES 的 Visualizer/GUI 不支持。现代渲染路径还要求 OpenGL 4.1;此要求不可无差别套到 legacy Visualizer。Jetson Orin Nano、QCS6490 都要按实际 Ubuntu/BSP/桌面环境分别验证,不能只依据 CPU 架构推断可用性。
|
||||||
|
|
||||||
|
参考:https://www.open3d.org/docs/release/arm.html ,https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html
|
||||||
|
|
||||||
|
## 2. 已确认的差异与待验证事项
|
||||||
|
|
||||||
|
|问题|代码证据|处理|
|
||||||
|
|---|---|---|
|
||||||
|
|箭头响应而三维不明显|scene3d/adapters.py 的 FingerFrameAdapter 默认 depth;二维 arrows.py 使用 flow,Finger 阈值 0.4、间距 10。旧二维间距 20、阈值也为 0.4|不能认定灵敏度提高来自阈值降低;先比较相同数据、网格密度、显示缩放和算法配置|
|
||||||
|
|三维看起来不动|当前 adapter 明确不输出 displacement;只原位着色|区分颜色、光流箭头与几何形变。先恢复前两者;几何形变另以“相对形变示意”实现|
|
||||||
|
|旧热图对比明显|SDK scalar_map_vertex_colors 对活动顶点采用每帧 P5/P95 范围|增加探索用稳健自动范围,以及比较用固定范围;始终显示实际范围|
|
||||||
|
|黄色箭头消失|旧 ContactArrowOverlay.compute 使用 centroid、局部法向、fnormal;当前 SdkPacket 没有接触中心字段|补接触观测契约和渲染叠加层|
|
||||||
|
|静止数字跳动|Finger sensor.json 默认中值窗口 1、低通 0;旧 CLI 默认曲线低通 5 Hz;当前数字 raw 一位、N 三位小数|先核对运行时覆盖设置,再比较滤波;未标定不是噪声的充分解释|
|
||||||
|
|纯色背景不是手指形状|Studio draw_arrow_display 将整个矩形填同色;旧 _arrow_background 用 valid_mask 区分内外颜色|用有效 UV 掩膜绘制轮廓,箭头使用同一坐标域|
|
||||||
|
|可能不同帧/过期字段|三维已有字段有效性与 frame_id 检查;AppWindow._render_run_image 构造 ImageFrame 未传入 flow_valid/depth_valid 等信息|二维补同等有效性;记录两视图呈现帧号,区分延迟与量不同|
|
||||||
|
|
||||||
|
没有硬件录制输入,尚不能确定“深度无响应”是深度算法输出小、色标压缩、场失效或呈现延迟。检查 process_chain:Studio 使用 check_motion=False;旧 pipeline 使用 check_motion=True, threshold=0。对照实验要固定这类参数,不能把算法差异归因于渲染库。
|
||||||
|
|
||||||
|
## 3. 数据流与模块边界
|
||||||
|
|
||||||
|
```text
|
||||||
|
SDK 每帧计算
|
||||||
|
→ SdkPacket(测量值、UV 域、字段有效性、接触中心、帧身份)
|
||||||
|
├─ 现有完整记录通道
|
||||||
|
├─ 现有全程力历史
|
||||||
|
└─ Finger 展示构建器(有界最新帧通道)
|
||||||
|
→ 同帧 VisualBundle
|
||||||
|
├─ 二维背景/箭头
|
||||||
|
├─ 三维标量/箭头几何 → GL
|
||||||
|
└─ 接触状态与诊断
|
||||||
|
|
||||||
|
力历史 → ForcePresentation → 曲线显示副本/数字卡
|
||||||
|
```
|
||||||
|
|
||||||
|
测量、展示政策、渲染后端单向依赖。Qt/GL 不进入 SDK;SDK 不导入 examples;shared 不导入 Studio。旧 NumPy 几何函数提取到正式模块并让旧示例复用,禁止 Studio 通过 sys.path 导入 examples。
|
||||||
|
|
||||||
|
先扩展现有 contracts、adapters、composer,不重建第二套设备/会话/回放系统。新增模块按责任合并为 visual_policy.py、overlays.py、force/presentation.py;只有实时同步需要时再增加 visual_presenter.py。文件数不是主要指标,修改阈值不应要求同时修改 worker、GL shader 和 app_window。
|
||||||
|
|
||||||
|
### 帧和有效性
|
||||||
|
|
||||||
|
复用 FrameIdentity,传播 source/session/generation/frame/time。新增字段接入已有数据类时使用可选值和兼容默认值;旧录制缺少接触信息时不伪造黄色箭头。
|
||||||
|
|
||||||
|
- `ContactObservation`: `centroid_uv_xy: tuple[float,float] | None`、`found: bool`、`valid: bool`、`frame_id: int`、`domain_id: str`。中心单位是 UV 像素,顺序 x,y。
|
||||||
|
- `VisualBundle`: identity、image_frame、surface_frame、contact、overlay_instances、policy_revision。每个数组须是不可变快照或有明确所有权;不能引用 SDK 下一帧会覆写的缓冲。
|
||||||
|
- `ArrowInstances`: origins[M,3]、directions[M,3]、lengths[M]、colors[M,4]、kind。位置与长度在 scene 的统一坐标单位下,方向归一化,数组有限且 M 有上限。
|
||||||
|
- 字段无效/过期只隐藏对应层;有深度无力时仍可显示深度,没有有效 FN 时隐藏法向强弱箭头。无效不等于 0。
|
||||||
|
|
||||||
|
默认实时保持低延迟,不让三维阻塞采集/记录。增加“同步比较”模式:同一 bundle 的二维和三维均准备完成后,在同一个 GUI 更新周期提交;保留至多一个工作中任务和一个最新等待帧,整体跳过中间展示帧。曲线保存全量,比较游标和数字取 bundle 对应时间。不能宣称不同 QWidget 在物理显示扫描上严格原子同步。
|
||||||
|
|
||||||
|
## 4. 阈值与空间一致性
|
||||||
|
|
||||||
|
不要设置一个数值同时作用于 flow(px)、depth_response(相对值)、force(raw/N)。统一的是配置入口、帧身份、空间域和视觉规则。
|
||||||
|
|
||||||
|
`VisualPolicy` 含 flow_on_px/flow_off_px、depth_on/depth_off、色标策略、箭头网格间距、长度上限、接触箭头样式。on > off 构成迟滞。数值由无载样本及轻触检出实验确定,不照搬不同设备的参数。
|
||||||
|
|
||||||
|
三维光流箭头与二维光流箭头共享同一 flow 阈值和采样点。深度色彩保留自己的有效区及阈值;轻微切向滑动可能只有光流响应,不应为了“同步”伪造深度。也不应以深度掩膜裁掉所有切向响应。
|
||||||
|
|
||||||
|
几何有效区、纹理有效区、接触区分开:
|
||||||
|
|
||||||
|
- geometry_mask:UV 映射有效且对应真实传感表面。
|
||||||
|
- texture_roi:确实需要排除无纹理边缘时,离线指定/人工确认并随几何资产版本保存。
|
||||||
|
- display_mask = geometry_mask ∩ texture_roi(无 texture_roi 时仅 geometry_mask)。
|
||||||
|
- contact_mask:随帧变化,只决定接触相关叠加,不改变静态手指轮廓。
|
||||||
|
|
||||||
|
禁止每帧通过非黑非白 RGB 判定有效区;光照、黑色标记、反光会改变它。掩膜形状或 domain 不匹配应报错,不通过任意 resize 掩盖问题。绘制纯色背景时分别设置内外颜色;箭头端点也裁到有效区。UV 翻转、裁剪、缩放只在明确的域转换处做一次,叠加显示边界以供验收。
|
||||||
|
|
||||||
|
## 5. 三维效果恢复
|
||||||
|
|
||||||
|
1. 表面热图:探索模式以有效活动值的稳健分位范围增强对比,设置最低色标跨度,缓慢更新范围;空场保持基底色。对比模式锁定范围。不要默认用每帧极小噪声铺满色谱。
|
||||||
|
2. 表面光流箭头:复用旧 flow_arrow 的采样与映射语义;初版保持相同点集和尺度。若另改成切向映射,须单独验收。原点加小量法向偏移避免闪烁。
|
||||||
|
3. 黄色法向箭头:恢复旧接触中心及局部法向,长度按 FN 的对数/幂律显示映射并限幅;注明“法向响应示意”,不声称长度线性对应牛顿。中心落在无效区时隐藏,不无条件吸附到远处顶点。
|
||||||
|
4. 三轴合力箭头是后续独立功能:只有统一轴定义、标定比例、力的参考坐标系及到 scene 的旋转确认后才生成。不能直接把未标定 FN/FX/FY 当成空间三维向量。
|
||||||
|
5. 外壳保持静态中性色,只给传感表面着色;坐标轴、相机、光照预设可复用旧版设计。
|
||||||
|
|
||||||
|
GL 初版用缓存圆柱/圆锥模板和每帧实例参数;也可用有限数量箭头合并网格,先测性能再引入 instancing。不要每帧新建数百个 Python/OpenGL 网格对象。隐藏三维时停组合和上传。
|
||||||
|
|
||||||
|
## 6. 数值稳定、零漂与曲线
|
||||||
|
|
||||||
|
先记录实际生效的 SDK FilterSettings。滤波只由一个层负责,沿用现有 worker 下发 SDK 的路径;shared ForceFilterChain 当前是对照实现,不应又在 UI 加一遍。
|
||||||
|
|
||||||
|
可用旧版 5 Hz 作为 A/B 起点,而非固定验收参数;截止频率必须低于实际采样率的一半,并测试短促接触峰值和延迟。中值窗口大于 1 仅在确认脉冲异常时启用。
|
||||||
|
|
||||||
|
区分四件事:
|
||||||
|
|
||||||
|
- 滤波:减少高频噪声,存在延迟。
|
||||||
|
- 无载归零:在用户确认无载、采样有效且稳定的窗口估计偏置,冻结到本次会话。慢漂不能靠固定死区无限修正;不要在持续静载时自动追零。
|
||||||
|
- 显示死区:只作用于展示副本;正负对称、带迟滞,不截掉负 FN。raw/N 各自保存阈值和偏置版本。切换单位/标定/设备/滤波时重置或重新验证政策。
|
||||||
|
- 数字格式:优先按噪声分辨率量化和稳定的小数位;raw 可起步 3–4 位有效数字或整数步长,N 依标定分辨率确定。固定宽度等宽数字,数字卡可降到 5–10 Hz,曲线仍用完整时间序列。处理负零及 NaN。
|
||||||
|
|
||||||
|
死区阈值可从无载残差的 MAD/分位数估计,必须给出噪声窗口、单位和适用配置。展示 0 时显示“零区”标识,可查看未抑制值。曲线默认显示滤波后的连续信号和淡色零区带;“零区压平”是明确开关,不建议默认把真实弱信号压没。
|
||||||
|
|
||||||
|
选区统计和导出使用测量序列,不能统计数字卡量化值;如支持展示信号统计,必须另行命名。核实 SDK raw 通道到底是滤波前还是仅标定前,不把现有 raw 列宣传成未滤波原始量。所有新增展示政策不回写现有记录值;政策/归零变更写带时间的事件和版本以便回放解释。
|
||||||
|
|
||||||
|
## 7. 文件及函数修改清单
|
||||||
|
|
||||||
|
以下路径以仓库根为起点。existing 表示改现有函数,new 表示建议新增接口;详细输入/输出规则见上文。
|
||||||
|
|
||||||
|
|路径|接口|职责及输入输出|
|
||||||
|
|---|---|---|
|
||||||
|
|orisys-app/shared/src/orisys_app_shared/acquisition/models.py|SdkPacket(existing)|增加可选 ContactObservation;帧字段与 UV 域一致|
|
||||||
|
|同目录 finger_worker.py|采集循环构造 SdkPacket 处(existing)|在 compute_contact 后读中心及 found;读失败标记不可用,复制后发布,不能由 UI 重新计算 SDK|
|
||||||
|
|同目录 image_backend.py|ImageFrame、plan_image、render_image(existing)|携带各字段有效性与身份;失效箭头/深度显示不可用|
|
||||||
|
|同目录 arrows.py|draw_arrow_display(existing)|增加 policy/内外色参数;背景、采样点和箭头裁剪共用 domain_mask|
|
||||||
|
|orisys-app/shared/src/orisys_app_shared/scene3d/visual_policy.py(new)|VisualPolicy; build_domain_mask(geometry_mask, texture_roi) -> bool[H,W]; select_flow_samples(flow, mask, policy) -> UVSamples|无 Qt/GL;严格检查形状、有限值和域;采样点两视图共用|
|
||||||
|
|同目录 overlays.py(new)|build_flow_overlay(samples, surface_mapping, style) -> ArrowInstances; build_normal_overlay(contact, fn, surface_mapping, style) -> ArrowInstances|只做 NumPy 几何;无接触/无有效力返回零实例,非法坐标返回诊断|
|
||||||
|
|同目录 contracts.py|SurfaceFieldFrame、RenderFrame(existing)|扩展可选向量/接触/箭头及描述信息,保持无 overlay 的旧调用兼容|
|
||||||
|
|同目录 composer.py|SceneComposer.compose(existing)|构建同帧表面和叠加;缓存静态映射/法向/模板,不读 SDK|
|
||||||
|
|orisys-app/apps/studio/src/orisys_studio/scene3d/adapters.py|FingerFrameAdapter.snapshot(existing)|同时验证需要的 flow/depth/contact 身份;选择上色场不应删除箭头所需 flow;保留显式降级提示|
|
||||||
|
|同目录 gl_backend.py|现有上传/绘制/资源释放路径(existing)|上传 ArrowInstances 或合并网格;只有此层依赖 GL;随上下文重建资源|
|
||||||
|
|同目录 view.py、coordinator.py|submit_packet、tick(existing)|携带 bundle 身份与策略版本;过期结果不覆盖新设置,提供已呈现帧号|
|
||||||
|
|同目录 panel.py|ThreeDPanel(existing)|上色量、光流箭头、接触法向箭头、固定/自动色标;诊断折叠|
|
||||||
|
|orisys-app/shared/src/orisys_app_shared/force/presentation.py(new)|ForcePresentation.configure(policy) -> None; accept(sample) -> DisplayForce; fit_zero(samples, context) -> ZeroEstimate; format_force(value, resolution, unit) -> str|独立展示状态;fit_zero 不隐式应用,返回偏置/噪声/样本数/质量;accept 不修改 sample|
|
||||||
|
|orisys-app/apps/studio/src/orisys_studio/ui/force_plots.py|format_force、_render_value_cards 及曲线展示入口(existing)|统一调用展示模型;删除两处重复格式规则;历史/导出不量化;选区统计明确口径|
|
||||||
|
|orisys-app/apps/studio/src/orisys_studio/app_window.py|_render_run_image、_show_image_frame(existing)|仅路由字段和政策,不加入阈值、滤波、中心计算算法|
|
||||||
|
|orisys-sdk/examples/viz/contact_arrow.py、flow_arrow.py|compute/几何构建函数(reference)|作为金标准验证移植;不复制 Open3D 生命周期到 Studio|
|
||||||
|
|
||||||
|
`ForcePresentation` 状态按设备/SN、session、单位、calibration revision、filter revision 分开。历史随机 seek 不得从上一 seek 的迟滞状态继续:从记录的政策边界和足够前导样本重放,或选择无状态死区并明确模式。事件不能混入常规力 CSV 数值列。
|
||||||
|
|
||||||
|
## 8. 顺序与验收
|
||||||
|
|
||||||
|
### A:先建立可比较输入,修正误导
|
||||||
|
|
||||||
|
用同一原视频、配置、初始化/预热步骤、SDK 版本分别驱动旧/新界面;最好捕获一次中间场 bundle 再供两套渲染器消费,排除算法与滤波差异。记录 flow/depth 范围、有效比例、中心、原始/输出力、实际滤波参数、帧号和呈现时间。
|
||||||
|
|
||||||
|
先交付 UV 轮廓、二维有效性、实际色标、集中数字格式及噪声诊断。这一阶段不换渲染器,也不引入全局阈值。
|
||||||
|
|
||||||
|
### B:恢复旧版关键视觉
|
||||||
|
|
||||||
|
交付同源 2D/3D 光流箭头、黄色法向箭头、相机/灯光;先 CPU 几何对比,再检查 GL 图像。UI 将三维画布作为主体,设置折叠;三维上色默认深度响应,叠加光流方向以解释切向滑动。提供二维/三维切换和并排诊断。
|
||||||
|
|
||||||
|
### C:显示抑制与同步比较
|
||||||
|
|
||||||
|
交付无载评估、显示死区、零区带、数字卡刷新率、同步比较模式及对应政策事件。先按实验数据选择阈值,再考虑产品预设“灵敏/稳定”;保留明确的参数查看入口。
|
||||||
|
|
||||||
|
### D:目标板验证
|
||||||
|
|
||||||
|
分别测试 Jetson Orin Nano、QCS6490 实际 GL/ES 上下文、shader、DPI、窗口隐藏/恢复、资产重载、长时间显存和内存、关闭中断、30 fps 预算。CPU 组合与 GPU 上传/绘制分别计时,记录 p50/p95 延迟和展示跳帧;硬件实测后再承诺帧率。
|
||||||
|
|
||||||
|
共同验收用例:无载、轻滑、法向压入、持续静载、释放、边缘接触、无效帧、断连、切换 raw/N、回放 seek。
|
||||||
|
|
||||||
|
- 同帧输入的二维/三维采样点、接触中心、掩膜边界符合几何映射,黄色箭头局部法向正确。
|
||||||
|
- 测量记录在开关显示死区/数字精度前后逐值一致;异常仍为无效,负值仍可检查。
|
||||||
|
- 稳定模式减少静止读数变化,但轻触检出、峰值偏差、端到端延迟符合团队事先约定指标;不能只用“看起来不跳”验收。
|
||||||
|
- 色标自动模式可见实际上下限;固定模式相同数值保持同色;空场不被放大成强接触。
|
||||||
|
- 同步比较 mode 的 frame_id 一致;常规实时 mode 的延迟可见且队列有界。
|
||||||
|
|
||||||
|
工作量按交付拆分评估:背景与数字格式较小;接触字段贯通及箭头几何为中等;同帧比较、回放状态和 ARM 实测为主要不确定项。先完成 B 的单段录制验证,再估算整体工期。直接加入 Open3D 也需要后一组工作,并不自动省掉它们。
|
||||||
+4
-3
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "orisys-app"
|
name = "orisys-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Orisys desktop applications (studio + future internal)"
|
description = "Orisys desktop applications (studio + internal autocal)"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Orisys-Tech" }]
|
authors = [{ name = "Orisys-Tech" }]
|
||||||
@@ -14,6 +14,7 @@ authors = [{ name = "Orisys-Tech" }]
|
|||||||
dev = [
|
dev = [
|
||||||
"orisys-app-shared @ file:./shared",
|
"orisys-app-shared @ file:./shared",
|
||||||
"orisys-studio @ file:./apps/studio",
|
"orisys-studio @ file:./apps/studio",
|
||||||
|
"orisys-autocal @ file:./apps/autocal",
|
||||||
"pytest>=7.4",
|
"pytest>=7.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -21,6 +22,6 @@ dev = [
|
|||||||
packages = []
|
packages = []
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["shared/tests", "apps/studio/tests"]
|
testpaths = ["shared/tests", "apps/studio/tests", "apps/autocal/tests"]
|
||||||
pythonpath = ["shared/src", "apps/studio/src"]
|
pythonpath = ["shared/src", "apps/studio/src", "apps/autocal/src"]
|
||||||
addopts = "-q"
|
addopts = "-q"
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Vendored ZMotion runtime (internal use)
|
||||||
|
|
||||||
|
These files are copied verbatim from the ZMotion SDK that ships with the
|
||||||
|
controller's `ZDevelop` install, as unpacked from the vendor example bundle:
|
||||||
|
|
||||||
|
D:\orisys_ws\zmotion\Python64\dll库文件\
|
||||||
|
|
||||||
|
| File | Bytes | SHA-256 (first 16) |
|
||||||
|
|---|---|---|
|
||||||
|
| `zauxdll.dll` | 4410880 | `282369258733A0D6` |
|
||||||
|
| `zmotion.dll` | 3596288 | `30DC519539783EE9` |
|
||||||
|
| `zauxdll2.h` | 179106 | `BC8788DE71A7900B` |
|
||||||
|
|
||||||
|
`zauxdll2.h` is kept only as the parameter contract of record for the wrappers
|
||||||
|
in `orisys_app_shared/zmotion/client.py` (register values, argument order,
|
||||||
|
return codes). It is not loaded at run time.
|
||||||
|
|
||||||
|
## Why they are vendored
|
||||||
|
|
||||||
|
`zmcdll/zauxdllPython.py` in the vendor example resolves its DLLs relative to
|
||||||
|
its own module location, plus a current-working-directory fallback. That makes
|
||||||
|
`import` depend on where the process happens to be started from. Copying the
|
||||||
|
two runtime DLLs next to the app and resolving them explicitly (see
|
||||||
|
`orisys_app_shared/zmotion/loader.py`) removes that, at the cost of ~8 MB in
|
||||||
|
the repo.
|
||||||
|
|
||||||
|
## Overriding
|
||||||
|
|
||||||
|
Set `ORISYS_ZMOTION_DLL_DIR` to point at a different directory — for example a
|
||||||
|
newer controller-support CD — and it wins over this folder:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ORISYS_ZMOTION_DLL_DIR = "D:\Zmotion\bin"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Redistribution
|
||||||
|
|
||||||
|
Internal use only. The ZMotion SDK is vendor-supplied hardware support
|
||||||
|
software with its own terms; these DLLs must **not** be redistributed inside
|
||||||
|
the customer-facing Orisys Studio installer. AutoCal is a separate, internal
|
||||||
|
application and is deliberately not packaged by `build/studio` (see
|
||||||
|
`build/README.md`).
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -125,6 +125,72 @@ def _ensure_bgr(img: np.ndarray) -> np.ndarray:
|
|||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_from_style(
|
||||||
|
draw_style: str,
|
||||||
|
*,
|
||||||
|
flow_on_px: float | None = None,
|
||||||
|
flow_off_px: float | None = None,
|
||||||
|
flow_grid_spacing: int | None = None,
|
||||||
|
max_arrows: int | None = None,
|
||||||
|
):
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import VisualPolicy
|
||||||
|
|
||||||
|
finger = str(draw_style).lower().startswith("f")
|
||||||
|
defaults = _FINGER_ARROW_DRAW_KW if finger else _ARROW_DRAW_KW
|
||||||
|
return VisualPolicy(
|
||||||
|
flow_on_px=float(defaults["threshold"] if flow_on_px is None else flow_on_px),
|
||||||
|
flow_off_px=float(
|
||||||
|
(0.3 if finger else max(0.0, float(defaults["threshold"]) * 0.75))
|
||||||
|
if flow_off_px is None
|
||||||
|
else flow_off_px
|
||||||
|
),
|
||||||
|
flow_grid_spacing=int(
|
||||||
|
defaults["grid_spacing"] if flow_grid_spacing is None else flow_grid_spacing
|
||||||
|
),
|
||||||
|
flow_arrow_scale=float(defaults["arrow_scale"]),
|
||||||
|
flow_edge_margin=8 if finger else 15,
|
||||||
|
max_arrows=400 if max_arrows is None else int(max_arrows),
|
||||||
|
).normalised()
|
||||||
|
|
||||||
|
|
||||||
|
def _paint_shared_flow_arrows(
|
||||||
|
canvas: np.ndarray,
|
||||||
|
flow: np.ndarray,
|
||||||
|
*,
|
||||||
|
valid_mask: Optional[np.ndarray],
|
||||||
|
policy,
|
||||||
|
colormap: Optional[np.ndarray],
|
||||||
|
below_threshold_color: tuple[int, int, int],
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Draw arrows from :func:`select_flow_samples` so 2D matches 3D sample points."""
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import select_flow_samples
|
||||||
|
|
||||||
|
image = canvas.copy()
|
||||||
|
samples = select_flow_samples(flow, valid_mask, policy)
|
||||||
|
if samples.rows.size == 0:
|
||||||
|
return image
|
||||||
|
mag = samples.magnitude
|
||||||
|
mag_range = float(mag.max() - mag.min()) if mag.size else 0.0
|
||||||
|
if mag_range > 0:
|
||||||
|
norm = ((mag - mag.min()) / mag_range * 255.0).astype(np.uint8)
|
||||||
|
else:
|
||||||
|
norm = np.zeros(mag.shape, dtype=np.uint8)
|
||||||
|
cmap = cv2.COLORMAP_JET if colormap is None else colormap
|
||||||
|
colors = cv2.applyColorMap(norm.reshape(-1, 1), cmap)
|
||||||
|
for i in range(samples.rows.size):
|
||||||
|
pt1 = (int(samples.cols[i]), int(samples.rows[i]))
|
||||||
|
pt2 = (
|
||||||
|
int(round(samples.cols[i] + samples.dx[i])),
|
||||||
|
int(round(samples.rows[i] + samples.dy[i])),
|
||||||
|
)
|
||||||
|
color = tuple(int(c) for c in colors[i, 0])
|
||||||
|
if mag[i] < policy.flow_on_px:
|
||||||
|
cv2.circle(image, pt1, radius=1, color=below_threshold_color, thickness=-1)
|
||||||
|
else:
|
||||||
|
cv2.arrowedLine(image, pt1, pt2, color, thickness=1, tipLength=0.2)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
def draw_arrow_display(
|
def draw_arrow_display(
|
||||||
processed_frame: np.ndarray,
|
processed_frame: np.ndarray,
|
||||||
flow: Optional[np.ndarray],
|
flow: Optional[np.ndarray],
|
||||||
@@ -134,25 +200,81 @@ def draw_arrow_display(
|
|||||||
draw_style: str = "box",
|
draw_style: str = "box",
|
||||||
valid_mask: Optional[np.ndarray] = None,
|
valid_mask: Optional[np.ndarray] = None,
|
||||||
colormap: Optional[np.ndarray] = None,
|
colormap: Optional[np.ndarray] = None,
|
||||||
|
exterior_bgr: Sequence[int] | None = None,
|
||||||
|
island_bgr: Sequence[int] | None = None,
|
||||||
|
flow_on_px: float | None = None,
|
||||||
|
flow_off_px: float | None = None,
|
||||||
|
flow_grid_spacing: int | None = None,
|
||||||
|
max_arrows: int | None = None,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Draw flow arrows on a solid canvas or over the processed frame."""
|
"""Draw flow arrows on a solid canvas or over the processed frame.
|
||||||
|
|
||||||
|
For ``pure_color``, the UV ``valid_mask`` paints the finger island in
|
||||||
|
``island_bgr`` and the exterior in ``exterior_bgr`` (defaults: theme canvas
|
||||||
|
inside, slightly darker outside). Without a mask the whole rectangle uses
|
||||||
|
``canvas_bgr`` / ``island_bgr`` for backward compatibility.
|
||||||
|
|
||||||
|
Finger style uses the shared :mod:`visual_policy` sampler so 2D and 3D arrows
|
||||||
|
share the same UV sample points and thresholds. Box style keeps the SDK path.
|
||||||
|
"""
|
||||||
base = _ensure_bgr(processed_frame)
|
base = _ensure_bgr(processed_frame)
|
||||||
if flow is None:
|
if flow is None:
|
||||||
return base
|
return base
|
||||||
draw_kw = dict(_FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW)
|
finger = str(draw_style).lower().startswith("f")
|
||||||
|
draw_kw = dict(_FINGER_ARROW_DRAW_KW if finger else _ARROW_DRAW_KW)
|
||||||
if colormap is not None:
|
if colormap is not None:
|
||||||
draw_kw["colormap"] = colormap
|
draw_kw["colormap"] = colormap
|
||||||
try:
|
try:
|
||||||
|
if background == "pure_color":
|
||||||
|
color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
|
||||||
|
inside = color_bgr if island_bgr is None else tuple(int(v) for v in island_bgr)
|
||||||
|
outside = (
|
||||||
|
tuple(max(0, int(v) - 12) for v in inside)
|
||||||
|
if exterior_bgr is None
|
||||||
|
else tuple(int(v) for v in exterior_bgr)
|
||||||
|
)
|
||||||
|
if valid_mask is not None:
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import (
|
||||||
|
build_domain_mask,
|
||||||
|
paint_domain_background,
|
||||||
|
)
|
||||||
|
|
||||||
|
domain, _ = build_domain_mask(
|
||||||
|
valid_mask, shape_hw=base.shape[:2]
|
||||||
|
)
|
||||||
|
canvas = paint_domain_background(
|
||||||
|
base.shape[:2],
|
||||||
|
domain,
|
||||||
|
island_bgr=inside, # type: ignore[arg-type]
|
||||||
|
exterior_bgr=outside, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
valid_mask = domain
|
||||||
|
else:
|
||||||
|
canvas = np.empty_like(base)
|
||||||
|
canvas[:, :] = inside
|
||||||
|
else:
|
||||||
|
canvas = base
|
||||||
|
|
||||||
|
if finger:
|
||||||
|
policy = _policy_from_style(
|
||||||
|
draw_style,
|
||||||
|
flow_on_px=flow_on_px,
|
||||||
|
flow_off_px=flow_off_px,
|
||||||
|
flow_grid_spacing=flow_grid_spacing,
|
||||||
|
max_arrows=max_arrows,
|
||||||
|
)
|
||||||
|
return _paint_shared_flow_arrows(
|
||||||
|
canvas,
|
||||||
|
np.asarray(flow),
|
||||||
|
valid_mask=valid_mask,
|
||||||
|
policy=policy,
|
||||||
|
colormap=colormap,
|
||||||
|
below_threshold_color=tuple(draw_kw["below_threshold_color"]),
|
||||||
|
)
|
||||||
|
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
if background == "pure_color":
|
return orisys.util.draw_arrows(canvas, flow, valid_mask=valid_mask, **draw_kw)
|
||||||
canvas = np.empty_like(base)
|
|
||||||
color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
|
|
||||||
canvas[:, :] = color_bgr
|
|
||||||
return orisys.util.draw_arrows(
|
|
||||||
canvas, flow, valid_mask=valid_mask, **draw_kw
|
|
||||||
)
|
|
||||||
return orisys.util.draw_arrows(base, flow, valid_mask=valid_mask, **draw_kw)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from orisys_app_shared.acquisition.sinks import RecordSinkMixin
|
|||||||
# The packet and the geometry come from ``models``, so the finger worker does not
|
# The packet and the geometry come from ``models``, so the finger worker does not
|
||||||
# depend on the box worker; only genuinely shared helpers are imported from it.
|
# depend on the box worker; only genuinely shared helpers are imported from it.
|
||||||
from orisys_app_shared.acquisition.models import (
|
from orisys_app_shared.acquisition.models import (
|
||||||
|
ContactObservation,
|
||||||
FixedCameraMode,
|
FixedCameraMode,
|
||||||
SdkPacket,
|
SdkPacket,
|
||||||
ensure_bgr as _ensure_bgr,
|
ensure_bgr as _ensure_bgr,
|
||||||
@@ -539,6 +540,11 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
depth_valid = (depth is not None) and not compute_error
|
depth_valid = (depth is not None) and not compute_error
|
||||||
if depth_valid:
|
if depth_valid:
|
||||||
last_depth_frame_id = frame_id
|
last_depth_frame_id = frame_id
|
||||||
|
contact = _contact_from_sensor(
|
||||||
|
tactile,
|
||||||
|
frame_id=frame_id,
|
||||||
|
compute_error=compute_error,
|
||||||
|
)
|
||||||
packet = SdkPacket(
|
packet = SdkPacket(
|
||||||
raw_frame=raw_frame,
|
raw_frame=raw_frame,
|
||||||
processed_frame=_ensure_bgr(unrolled),
|
processed_frame=_ensure_bgr(unrolled),
|
||||||
@@ -573,6 +579,7 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
depth_valid=depth_valid,
|
depth_valid=depth_valid,
|
||||||
depth_frame_id=frame_id if depth_valid else last_depth_frame_id,
|
depth_frame_id=frame_id if depth_valid else last_depth_frame_id,
|
||||||
forces_valid=bool(forces.get("valid", True)) and not compute_error,
|
forces_valid=bool(forces.get("valid", True)) and not compute_error,
|
||||||
|
contact=contact,
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._latest = packet
|
self._latest = packet
|
||||||
@@ -593,3 +600,30 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
self._set_end_reason("stopped")
|
self._set_end_reason("stopped")
|
||||||
self._status("Finger acquisition stopped")
|
self._status("Finger acquisition stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_from_sensor(sensor, *, frame_id: int, compute_error: str) -> ContactObservation:
|
||||||
|
"""Copy the SDK contact centre after ``compute_contact``; never invent one."""
|
||||||
|
if compute_error:
|
||||||
|
return ContactObservation(found=False, valid=False, frame_id=int(frame_id))
|
||||||
|
found = bool(getattr(sensor, "centroid_found", False))
|
||||||
|
contact_ok = bool(getattr(sensor, "compute_contact_valid", True))
|
||||||
|
centroid = getattr(sensor, "centroid", None)
|
||||||
|
xy: tuple[float, float] | None = None
|
||||||
|
if found and centroid is not None:
|
||||||
|
try:
|
||||||
|
arr = np.asarray(centroid, dtype=np.float64).reshape(-1)
|
||||||
|
if arr.size >= 2 and np.all(np.isfinite(arr[:2])):
|
||||||
|
xy = (float(arr[0]), float(arr[1]))
|
||||||
|
else:
|
||||||
|
found = False
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
found = False
|
||||||
|
xy = None
|
||||||
|
return ContactObservation(
|
||||||
|
centroid_uv_xy=xy,
|
||||||
|
found=bool(found and xy is not None),
|
||||||
|
valid=bool(contact_ok and found and xy is not None),
|
||||||
|
frame_id=int(frame_id),
|
||||||
|
domain_id="",
|
||||||
|
)
|
||||||
|
|||||||
@@ -83,12 +83,25 @@ class ImageFrame:
|
|||||||
#: True when the producer knows this frame's measurement is not current (the
|
#: True when the producer knows this frame's measurement is not current (the
|
||||||
#: coordinator's "stale" state); the panel can say so under the picture.
|
#: coordinator's "stale" state); the panel can say so under the picture.
|
||||||
stale: bool = False
|
stale: bool = False
|
||||||
|
#: Per-field provenance mirroring ``SdkPacket``; ``None`` means unstated.
|
||||||
|
flow_valid: Optional[bool] = None
|
||||||
|
flow_frame_id: Optional[int] = None
|
||||||
|
depth_valid: Optional[bool] = None
|
||||||
|
depth_frame_id: Optional[int] = None
|
||||||
|
|
||||||
def has_flow(self) -> bool:
|
def has_flow(self) -> bool:
|
||||||
return self.flow is not None
|
if self.flow is None:
|
||||||
|
return False
|
||||||
|
if self.flow_valid is False:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def has_depth(self) -> bool:
|
def has_depth(self) -> bool:
|
||||||
return self.depth is not None
|
if self.depth is None:
|
||||||
|
return False
|
||||||
|
if self.depth_valid is False:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -195,6 +208,8 @@ def render_image(
|
|||||||
if wanted == DEPTH:
|
if wanted == DEPTH:
|
||||||
# Never substitute another picture for a missing depth map: that made "no
|
# Never substitute another picture for a missing depth map: that made "no
|
||||||
# depth" look like a depth reading. The panel says so instead.
|
# depth" look like a depth reading. The panel says so instead.
|
||||||
|
if frame.depth_valid is False:
|
||||||
|
return None
|
||||||
return depth_map_to_bgr(
|
return depth_map_to_bgr(
|
||||||
frame.depth,
|
frame.depth,
|
||||||
flow_colormap(theme),
|
flow_colormap(theme),
|
||||||
@@ -204,7 +219,7 @@ def render_image(
|
|||||||
if frame.image_bgr is None:
|
if frame.image_bgr is None:
|
||||||
return None
|
return None
|
||||||
if wanted in (ARROW_PURE, ARROW_STITCHED):
|
if wanted in (ARROW_PURE, ARROW_STITCHED):
|
||||||
if frame.flow is None:
|
if frame.flow is None or frame.flow_valid is False:
|
||||||
# The base picture with no arrows, and the plan's ``missing`` says why.
|
# The base picture with no arrows, and the plan's ``missing`` says why.
|
||||||
return ensure_bgr(np.asarray(frame.image_bgr))
|
return ensure_bgr(np.asarray(frame.image_bgr))
|
||||||
return draw_arrow_display(
|
return draw_arrow_display(
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ class FixedCameraMode:
|
|||||||
return f"{self.width}x{self.height} @{self.fps:g}"
|
return f"{self.width}x{self.height} @{self.fps:g}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContactObservation:
|
||||||
|
"""Contact centre in the UV domain, copied from the SDK after ``compute_contact``.
|
||||||
|
|
||||||
|
Units are UV pixels with ``(x, y)`` order. Missing or failed contact is
|
||||||
|
``found=False`` / ``valid=False`` — never a fabricated centre. Old recordings
|
||||||
|
without this field do not invent a yellow contact arrow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
centroid_uv_xy: Optional[tuple[float, float]] = None
|
||||||
|
found: bool = False
|
||||||
|
valid: bool = False
|
||||||
|
frame_id: int = 0
|
||||||
|
domain_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SdkPacket:
|
class SdkPacket:
|
||||||
"""One acquired frame: the images, the force readout and its provenance."""
|
"""One acquired frame: the images, the force readout and its provenance."""
|
||||||
@@ -103,6 +119,8 @@ class SdkPacket:
|
|||||||
#: Whether the force triplet belongs to this frame (False when the chain or the
|
#: Whether the force triplet belongs to this frame (False when the chain or the
|
||||||
#: readout failed, whatever ``valid`` says about the record).
|
#: readout failed, whatever ``valid`` says about the record).
|
||||||
forces_valid: Optional[bool] = None
|
forces_valid: Optional[bool] = None
|
||||||
|
#: Contact centre from ``compute_contact``; optional so old call sites stay valid.
|
||||||
|
contact: Optional[ContactObservation] = None
|
||||||
|
|
||||||
|
|
||||||
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
|
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ from orisys_app_shared.force.session import (
|
|||||||
summarise,
|
summarise,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.force.units import DisplayForces, select_display_forces
|
from orisys_app_shared.force.units import DisplayForces, select_display_forces
|
||||||
|
from orisys_app_shared.force.presentation import (
|
||||||
|
DisplayForce,
|
||||||
|
ForcePresentation,
|
||||||
|
ZeroEstimate,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CHANNEL_KEYS",
|
"CHANNEL_KEYS",
|
||||||
@@ -39,15 +44,18 @@ __all__ = [
|
|||||||
"CalSessionStore",
|
"CalSessionStore",
|
||||||
"CalibrationPoint",
|
"CalibrationPoint",
|
||||||
"CalibrationRecorder",
|
"CalibrationRecorder",
|
||||||
|
"DisplayForce",
|
||||||
"DisplayForces",
|
"DisplayForces",
|
||||||
"FilterSettings",
|
"FilterSettings",
|
||||||
"ForceFilterChain",
|
"ForceFilterChain",
|
||||||
|
"ForcePresentation",
|
||||||
"MetricHistory",
|
"MetricHistory",
|
||||||
"PlotState",
|
"PlotState",
|
||||||
"SelectionStats",
|
"SelectionStats",
|
||||||
"SessionSample",
|
"SessionSample",
|
||||||
"SessionSlice",
|
"SessionSlice",
|
||||||
"SessionStore",
|
"SessionStore",
|
||||||
|
"ZeroEstimate",
|
||||||
"build_force_calibration_v2",
|
"build_force_calibration_v2",
|
||||||
"describe_state",
|
"describe_state",
|
||||||
"effective_filters",
|
"effective_filters",
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Display-only force presentation: baseline, deadband, and formatting.
|
||||||
|
|
||||||
|
Never mutates recorded samples. Curves, statistics and CSV keep the measurement
|
||||||
|
values; value cards (and optional display copies) go through this layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZeroEstimate:
|
||||||
|
"""Outcome of an idle-window bias / noise estimate."""
|
||||||
|
|
||||||
|
offsets: tuple[float, float, float]
|
||||||
|
deadbands: tuple[float, float, float]
|
||||||
|
sample_count: int
|
||||||
|
quality: str # "ok" | "insufficient" | "unstable" | "contaminated"
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DisplayForce:
|
||||||
|
"""One displayed triplet after offset + deadband (measurement unchanged)."""
|
||||||
|
|
||||||
|
values: tuple[float, float, float]
|
||||||
|
suppressed: tuple[bool, bool, bool]
|
||||||
|
unit: str
|
||||||
|
zero_zone: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForcePresentation:
|
||||||
|
"""Stateful display policy for force value cards.
|
||||||
|
|
||||||
|
Startup auto-baseline collects quiet samples, freezes offsets and deadbands,
|
||||||
|
then applies a symmetric hysteretic deadband only to the display copy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
unit: str = "raw"
|
||||||
|
baseline_seconds: float = 3.0
|
||||||
|
min_samples: int = 30
|
||||||
|
max_flow_p95: float = 0.4
|
||||||
|
require_no_contact: bool = True
|
||||||
|
#: Residual percentile used as the per-axis deadband after offset removal.
|
||||||
|
deadband_percentile: float = 99.0
|
||||||
|
#: Extra margin on top of the percentile residual.
|
||||||
|
deadband_margin: float = 1.05
|
||||||
|
#: Floor so tiny MAD windows do not produce a zero deadband.
|
||||||
|
min_deadband: float = 50.0
|
||||||
|
hysteresis_ratio: float = 0.8
|
||||||
|
raw_significant_digits: int = 4
|
||||||
|
newton_decimals: int = 3
|
||||||
|
|
||||||
|
_collecting: bool = field(default=True, init=False, repr=False)
|
||||||
|
_samples: list[tuple[float, float, float]] = field(
|
||||||
|
default_factory=list, init=False, repr=False
|
||||||
|
)
|
||||||
|
_sample_times: list[float] = field(default_factory=list, init=False, repr=False)
|
||||||
|
_offsets: Optional[tuple[float, float, float]] = field(
|
||||||
|
default=None, init=False, repr=False
|
||||||
|
)
|
||||||
|
_deadbands: Optional[tuple[float, float, float]] = field(
|
||||||
|
default=None, init=False, repr=False
|
||||||
|
)
|
||||||
|
_suppressed: tuple[bool, bool, bool] = field(
|
||||||
|
default=(False, False, False), init=False, repr=False
|
||||||
|
)
|
||||||
|
_last_estimate: Optional[ZeroEstimate] = field(
|
||||||
|
default=None, init=False, repr=False
|
||||||
|
)
|
||||||
|
_t0: Optional[float] = field(default=None, init=False, repr=False)
|
||||||
|
|
||||||
|
def reset_session(self, *, unit: str = "raw") -> None:
|
||||||
|
"""Start a new idle estimate for a session."""
|
||||||
|
self.unit = "N" if str(unit).upper().startswith("N") else "raw"
|
||||||
|
self._collecting = self.enabled and self.unit == "raw"
|
||||||
|
self._samples = []
|
||||||
|
self._sample_times = []
|
||||||
|
self._offsets = None
|
||||||
|
self._deadbands = None
|
||||||
|
self._suppressed = (False, False, False)
|
||||||
|
self._last_estimate = None
|
||||||
|
self._t0 = None
|
||||||
|
|
||||||
|
def request_reestimate(self) -> None:
|
||||||
|
"""Operator-triggered restart of the idle window."""
|
||||||
|
self.reset_session(unit=self.unit)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return self._offsets is not None and self._deadbands is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_estimate(self) -> Optional[ZeroEstimate]:
|
||||||
|
return self._last_estimate
|
||||||
|
|
||||||
|
def observe(
|
||||||
|
self,
|
||||||
|
values: Sequence[float],
|
||||||
|
*,
|
||||||
|
t_rel_s: float,
|
||||||
|
valid: bool = True,
|
||||||
|
flow_p95: Optional[float] = None,
|
||||||
|
contact_found: bool = False,
|
||||||
|
) -> Optional[ZeroEstimate]:
|
||||||
|
"""Feed one live sample into the startup estimator.
|
||||||
|
|
||||||
|
Returns a :class:`ZeroEstimate` when the window closes (ok or failed).
|
||||||
|
"""
|
||||||
|
if not self._collecting or self.unit != "raw":
|
||||||
|
return None
|
||||||
|
if self._t0 is None:
|
||||||
|
self._t0 = float(t_rel_s)
|
||||||
|
if not valid:
|
||||||
|
return None
|
||||||
|
if self.require_no_contact and contact_found:
|
||||||
|
# Contaminated window — keep collecting but mark later if needed.
|
||||||
|
return None
|
||||||
|
if flow_p95 is not None and float(flow_p95) > self.max_flow_p95:
|
||||||
|
return None
|
||||||
|
sample = tuple(float(v) for v in values[:3])
|
||||||
|
if len(sample) < 3 or not all(np.isfinite(sample)):
|
||||||
|
return None
|
||||||
|
self._samples.append(sample) # type: ignore[arg-type]
|
||||||
|
self._sample_times.append(float(t_rel_s))
|
||||||
|
elapsed = float(t_rel_s) - float(self._t0)
|
||||||
|
if elapsed < self.baseline_seconds and len(self._samples) < self.min_samples * 2:
|
||||||
|
return None
|
||||||
|
if len(self._samples) < self.min_samples:
|
||||||
|
estimate = ZeroEstimate(
|
||||||
|
offsets=(0.0, 0.0, 0.0),
|
||||||
|
deadbands=(0.0, 0.0, 0.0),
|
||||||
|
sample_count=len(self._samples),
|
||||||
|
quality="insufficient",
|
||||||
|
message=f"only {len(self._samples)} quiet samples in {elapsed:.2f}s",
|
||||||
|
)
|
||||||
|
self._last_estimate = estimate
|
||||||
|
self._collecting = False
|
||||||
|
return estimate
|
||||||
|
estimate = self.fit_zero(self._samples)
|
||||||
|
self._last_estimate = estimate
|
||||||
|
self._collecting = False
|
||||||
|
if estimate.quality == "ok":
|
||||||
|
self._offsets = estimate.offsets
|
||||||
|
self._deadbands = estimate.deadbands
|
||||||
|
return estimate
|
||||||
|
|
||||||
|
def fit_zero(self, samples: Sequence[Sequence[float]]) -> ZeroEstimate:
|
||||||
|
"""Estimate offsets and deadbands from quiet samples (does not apply them)."""
|
||||||
|
block = np.asarray(samples, dtype=np.float64)
|
||||||
|
if block.ndim != 2 or block.shape[1] < 3 or block.shape[0] < 3:
|
||||||
|
return ZeroEstimate(
|
||||||
|
offsets=(0.0, 0.0, 0.0),
|
||||||
|
deadbands=(0.0, 0.0, 0.0),
|
||||||
|
sample_count=int(block.shape[0]) if block.ndim == 2 else 0,
|
||||||
|
quality="insufficient",
|
||||||
|
message="not enough samples to estimate a baseline",
|
||||||
|
)
|
||||||
|
offsets: list[float] = []
|
||||||
|
deadbands: list[float] = []
|
||||||
|
for axis in range(3):
|
||||||
|
col = block[:, axis]
|
||||||
|
finite = col[np.isfinite(col)]
|
||||||
|
if finite.size < 3:
|
||||||
|
return ZeroEstimate(
|
||||||
|
offsets=(0.0, 0.0, 0.0),
|
||||||
|
deadbands=(0.0, 0.0, 0.0),
|
||||||
|
sample_count=int(block.shape[0]),
|
||||||
|
quality="insufficient",
|
||||||
|
message=f"axis {axis} lacks finite samples",
|
||||||
|
)
|
||||||
|
med = float(np.median(finite))
|
||||||
|
residual = np.abs(finite - med)
|
||||||
|
p = float(np.percentile(residual, self.deadband_percentile))
|
||||||
|
db = max(self.min_deadband, p * self.deadband_margin)
|
||||||
|
# Reject wildly unstable windows (p99 >> median magnitude scale).
|
||||||
|
mad = float(np.median(residual))
|
||||||
|
if mad > 0 and p > 20.0 * mad and p > 2000.0:
|
||||||
|
return ZeroEstimate(
|
||||||
|
offsets=(0.0, 0.0, 0.0),
|
||||||
|
deadbands=(0.0, 0.0, 0.0),
|
||||||
|
sample_count=int(block.shape[0]),
|
||||||
|
quality="unstable",
|
||||||
|
message=f"axis {axis} residual p{self.deadband_percentile:g}={p:.1f}",
|
||||||
|
)
|
||||||
|
offsets.append(med)
|
||||||
|
deadbands.append(db)
|
||||||
|
return ZeroEstimate(
|
||||||
|
offsets=(offsets[0], offsets[1], offsets[2]),
|
||||||
|
deadbands=(deadbands[0], deadbands[1], deadbands[2]),
|
||||||
|
sample_count=int(block.shape[0]),
|
||||||
|
quality="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_estimate(self, estimate: ZeroEstimate) -> None:
|
||||||
|
"""Explicitly freeze an estimate (e.g. after user confirmation)."""
|
||||||
|
if estimate.quality != "ok":
|
||||||
|
return
|
||||||
|
self._offsets = estimate.offsets
|
||||||
|
self._deadbands = estimate.deadbands
|
||||||
|
self._collecting = False
|
||||||
|
self._last_estimate = estimate
|
||||||
|
|
||||||
|
def accept(
|
||||||
|
self,
|
||||||
|
values: Sequence[float],
|
||||||
|
*,
|
||||||
|
unit: Optional[str] = None,
|
||||||
|
available: tuple[bool, bool, bool] = (True, True, True),
|
||||||
|
) -> DisplayForce:
|
||||||
|
"""Return a display copy; never mutates ``values``."""
|
||||||
|
unit = self.unit if unit is None else (
|
||||||
|
"N" if str(unit).upper().startswith("N") else "raw"
|
||||||
|
)
|
||||||
|
raw = [float(v) for v in values[:3]]
|
||||||
|
while len(raw) < 3:
|
||||||
|
raw.append(float("nan"))
|
||||||
|
if (
|
||||||
|
not self.enabled
|
||||||
|
or unit != "raw"
|
||||||
|
or self._offsets is None
|
||||||
|
or self._deadbands is None
|
||||||
|
):
|
||||||
|
return DisplayForce(
|
||||||
|
values=(raw[0], raw[1], raw[2]),
|
||||||
|
suppressed=(False, False, False),
|
||||||
|
unit=unit,
|
||||||
|
zero_zone=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
out: list[float] = []
|
||||||
|
suppressed: list[bool] = []
|
||||||
|
prev = self._suppressed
|
||||||
|
for i in range(3):
|
||||||
|
if not available[i] or not np.isfinite(raw[i]):
|
||||||
|
out.append(raw[i])
|
||||||
|
suppressed.append(False)
|
||||||
|
continue
|
||||||
|
centered = raw[i] - self._offsets[i]
|
||||||
|
on = self._deadbands[i]
|
||||||
|
off = on * self.hysteresis_ratio
|
||||||
|
was = prev[i]
|
||||||
|
if was:
|
||||||
|
quiet = abs(centered) <= on
|
||||||
|
else:
|
||||||
|
quiet = abs(centered) <= off
|
||||||
|
if quiet:
|
||||||
|
out.append(0.0)
|
||||||
|
suppressed.append(True)
|
||||||
|
else:
|
||||||
|
out.append(centered)
|
||||||
|
suppressed.append(False)
|
||||||
|
self._suppressed = (suppressed[0], suppressed[1], suppressed[2])
|
||||||
|
return DisplayForce(
|
||||||
|
values=(out[0], out[1], out[2]),
|
||||||
|
suppressed=self._suppressed,
|
||||||
|
unit=unit,
|
||||||
|
zero_zone=any(self._suppressed),
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_force(self, value: float, unit: Optional[str] = None) -> str:
|
||||||
|
"""Stable digit formatting for value cards."""
|
||||||
|
unit = self.unit if unit is None else (
|
||||||
|
"N" if str(unit).upper().startswith("N") else "raw"
|
||||||
|
)
|
||||||
|
if not np.isfinite(value):
|
||||||
|
return "—"
|
||||||
|
# Avoid signed zero after deadband.
|
||||||
|
if value == 0.0 or value == -0.0:
|
||||||
|
value = 0.0
|
||||||
|
if unit == "N":
|
||||||
|
return f"{value:.{self.newton_decimals}f}"
|
||||||
|
digits = max(2, int(self.raw_significant_digits))
|
||||||
|
abs_v = abs(value)
|
||||||
|
if abs_v >= 1000:
|
||||||
|
return f"{value:.0f}"
|
||||||
|
if abs_v >= 100:
|
||||||
|
return f"{value:.1f}"
|
||||||
|
if abs_v >= 10:
|
||||||
|
return f"{value:.2f}"
|
||||||
|
# Significant figures for small residual readings.
|
||||||
|
return f"{value:.{digits}g}"
|
||||||
@@ -1,34 +1,80 @@
|
|||||||
"""Resolve app, config, and output paths for editable and frozen runs."""
|
"""Resolve app, config, and output paths for editable and frozen runs.
|
||||||
|
|
||||||
|
Several desktop products share this package. Which one a process belongs to is
|
||||||
|
decided once, at start-up:
|
||||||
|
|
||||||
|
* ``ORISYS_APP_ID`` — the directory name under ``apps/`` (e.g. ``studio``,
|
||||||
|
``autocal``). Set it **before** importing anything from this package; an app
|
||||||
|
that changes it later gets whichever root was resolved first, because these
|
||||||
|
helpers are called during import of logging/prefs.
|
||||||
|
* Otherwise the historic behaviour applies, which resolves ``apps/studio``.
|
||||||
|
That keeps existing callers working without them passing anything.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
#: Environment variable naming the app directory under ``apps/``.
|
||||||
|
APP_ID_ENV = "ORISYS_APP_ID"
|
||||||
|
|
||||||
|
#: Fallback app when ``ORISYS_APP_ID`` is unset — the first product built here.
|
||||||
|
DEFAULT_APP_ID = "studio"
|
||||||
|
|
||||||
|
|
||||||
def is_frozen() -> bool:
|
def is_frozen() -> bool:
|
||||||
return bool(getattr(sys, "frozen", False))
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
def _studio_root_from_shared() -> Path:
|
def app_id() -> str:
|
||||||
"""Repo layout: shared/src/orisys_app_shared -> apps/studio."""
|
"""The app directory name for this process."""
|
||||||
|
return os.environ.get(APP_ID_ENV, "").strip() or DEFAULT_APP_ID
|
||||||
|
|
||||||
|
|
||||||
|
def apps_dir() -> Path:
|
||||||
|
"""The ``apps/`` directory of the source tree."""
|
||||||
here = Path(__file__).resolve()
|
here = Path(__file__).resolve()
|
||||||
# .../orisys-app/shared/src/orisys_app_shared/paths.py
|
# .../orisys-app/shared/src/orisys_app_shared/paths.py -> .../orisys-app
|
||||||
repo_root = here.parents[3]
|
repo_root = here.parents[3]
|
||||||
return repo_root / "apps" / "studio"
|
return repo_root / "apps"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_app_root_from_shared(app_id_value: str) -> Path:
|
||||||
|
"""Repo layout: shared/src/orisys_app_shared -> apps/<app>."""
|
||||||
|
return apps_dir() / app_id_value
|
||||||
|
|
||||||
|
|
||||||
def app_root() -> Path:
|
def app_root() -> Path:
|
||||||
"""Return the studio app root (editable) or EXE directory (frozen)."""
|
"""Return this app's root (editable) or the EXE directory (frozen)."""
|
||||||
if is_frozen():
|
if is_frozen():
|
||||||
return Path(sys.executable).resolve().parent
|
return Path(sys.executable).resolve().parent
|
||||||
# Prefer cwd override when launched from apps/studio
|
|
||||||
|
wanted = app_id()
|
||||||
|
|
||||||
|
# Launched from inside an app directory (``python apps/<app>/run.py``).
|
||||||
cwd = Path.cwd().resolve()
|
cwd = Path.cwd().resolve()
|
||||||
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
|
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
|
||||||
return cwd
|
return cwd
|
||||||
if (cwd / "apps" / "studio" / "config").is_dir():
|
|
||||||
return cwd / "apps" / "studio"
|
# Launched from the repo root.
|
||||||
return _studio_root_from_shared()
|
candidate = cwd / "apps" / wanted
|
||||||
|
if (candidate / "config").is_dir():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# A named app that is not where we expected must not silently fall back to
|
||||||
|
# another product's config: that would write one app's preferences into
|
||||||
|
# another's tree.
|
||||||
|
if wanted == DEFAULT_APP_ID:
|
||||||
|
return _default_app_root_from_shared(wanted)
|
||||||
|
unresolved = _default_app_root_from_shared(wanted)
|
||||||
|
if (unresolved / "config").is_dir():
|
||||||
|
return unresolved
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"{APP_ID_ENV}={wanted!r} does not name an app with a config directory "
|
||||||
|
f"(looked in {cwd / 'apps' / wanted} and {unresolved})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def config_dir() -> Path:
|
def config_dir() -> Path:
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
"""Load/save preference.json with defaults merge."""
|
"""Load/save preference.json with defaults merge.
|
||||||
|
|
||||||
|
The default schema here is the **studio's**. A second product passes its own
|
||||||
|
schema to :func:`load_preferences` / :func:`save_preferences` instead of
|
||||||
|
inheriting camera and 3D-pane keys it has no use for; omitting the argument
|
||||||
|
keeps the historic behaviour, so existing callers are unaffected.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -44,6 +50,7 @@ _DEFAULTS: dict[str, Any] = {
|
|||||||
"image_view_mode": "arrows_pure",
|
"image_view_mode": "arrows_pure",
|
||||||
"arrow_rotation_deg": 90,
|
"arrow_rotation_deg": 90,
|
||||||
"show_fps": False,
|
"show_fps": False,
|
||||||
|
"raw_auto_zero": True,
|
||||||
#: The GL profile the 3D view is started with. One per process: the surface
|
#: The GL profile the 3D view is started with. One per process: the surface
|
||||||
#: format is chosen before ``QApplication`` exists and cannot be switched later.
|
#: format is chosen before ``QApplication`` exists and cannot be switched later.
|
||||||
"gl_profile": "desktop33",
|
"gl_profile": "desktop33",
|
||||||
@@ -74,22 +81,31 @@ def _load_json(path: Path) -> dict[str, Any]:
|
|||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
def default_preferences() -> dict[str, Any]:
|
def default_preferences(schema: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
merged = deepcopy(_DEFAULTS)
|
"""The schema's defaults, overlaid with any shipped ``preferences.default.json``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: the app's own defaults; ``None`` means this package's (studio's).
|
||||||
|
"""
|
||||||
|
merged = deepcopy(_DEFAULTS if schema is None else schema)
|
||||||
merged.update(_load_json(preferences_default_path()))
|
merged.update(_load_json(preferences_default_path()))
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def load_preferences() -> dict[str, Any]:
|
def load_preferences(schema: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
prefs = default_preferences()
|
"""Stored preferences, with schema defaults filling anything absent."""
|
||||||
|
prefs = default_preferences(schema)
|
||||||
prefs.update(_load_json(preference_path()))
|
prefs.update(_load_json(preference_path()))
|
||||||
return prefs
|
return prefs
|
||||||
|
|
||||||
|
|
||||||
def save_preferences(prefs: dict[str, Any]) -> Path:
|
def save_preferences(
|
||||||
|
prefs: dict[str, Any], schema: dict[str, Any] | None = None
|
||||||
|
) -> Path:
|
||||||
|
"""Persist preferences, writing the schema's defaults for absent keys."""
|
||||||
path = preference_path()
|
path = preference_path()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
payload = default_preferences()
|
payload = default_preferences(schema)
|
||||||
payload.update(prefs)
|
payload.update(prefs)
|
||||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
LENGTH_UNIT_M,
|
LENGTH_UNIT_M,
|
||||||
SENSOR_FRAME,
|
SENSOR_FRAME,
|
||||||
UNAVAILABLE_DESCRIPTOR,
|
UNAVAILABLE_DESCRIPTOR,
|
||||||
|
ArrowInstances,
|
||||||
CameraMatrices,
|
CameraMatrices,
|
||||||
CameraPreset,
|
CameraPreset,
|
||||||
ColorScale,
|
ColorScale,
|
||||||
@@ -41,6 +42,7 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
SceneState,
|
SceneState,
|
||||||
SurfaceBinding,
|
SurfaceBinding,
|
||||||
SurfaceFieldFrame,
|
SurfaceFieldFrame,
|
||||||
|
SurfaceMapping,
|
||||||
ThreeDError,
|
ThreeDError,
|
||||||
U32,
|
U32,
|
||||||
VertexFields,
|
VertexFields,
|
||||||
@@ -49,6 +51,7 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
freeze,
|
freeze,
|
||||||
rest_descriptor,
|
rest_descriptor,
|
||||||
summarize_parts,
|
summarize_parts,
|
||||||
|
validate_arrow_instances,
|
||||||
validate_assets,
|
validate_assets,
|
||||||
validate_binding,
|
validate_binding,
|
||||||
validate_display_settings,
|
validate_display_settings,
|
||||||
@@ -62,6 +65,7 @@ __all__ = [
|
|||||||
"LENGTH_UNIT_M",
|
"LENGTH_UNIT_M",
|
||||||
"SENSOR_FRAME",
|
"SENSOR_FRAME",
|
||||||
"UNAVAILABLE_DESCRIPTOR",
|
"UNAVAILABLE_DESCRIPTOR",
|
||||||
|
"ArrowInstances",
|
||||||
"CameraMatrices",
|
"CameraMatrices",
|
||||||
"CameraPreset",
|
"CameraPreset",
|
||||||
"ColorScale",
|
"ColorScale",
|
||||||
@@ -88,6 +92,7 @@ __all__ = [
|
|||||||
"SceneState",
|
"SceneState",
|
||||||
"SurfaceBinding",
|
"SurfaceBinding",
|
||||||
"SurfaceFieldFrame",
|
"SurfaceFieldFrame",
|
||||||
|
"SurfaceMapping",
|
||||||
"ThreeDError",
|
"ThreeDError",
|
||||||
"U32",
|
"U32",
|
||||||
"VertexFields",
|
"VertexFields",
|
||||||
@@ -96,6 +101,7 @@ __all__ = [
|
|||||||
"freeze",
|
"freeze",
|
||||||
"rest_descriptor",
|
"rest_descriptor",
|
||||||
"summarize_parts",
|
"summarize_parts",
|
||||||
|
"validate_arrow_instances",
|
||||||
"validate_assets",
|
"validate_assets",
|
||||||
"validate_binding",
|
"validate_binding",
|
||||||
"validate_display_settings",
|
"validate_display_settings",
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
validate_surface_frame,
|
validate_surface_frame,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.scene3d.legacy import vertex_normals
|
from orisys_app_shared.scene3d.legacy import vertex_normals
|
||||||
|
from orisys_app_shared.scene3d.overlays import (
|
||||||
|
build_flow_overlay,
|
||||||
|
build_normal_overlay,
|
||||||
|
mesh_span_from_mapping,
|
||||||
|
)
|
||||||
from orisys_app_shared.scene3d.sampling import SurfaceSampler
|
from orisys_app_shared.scene3d.sampling import SurfaceSampler
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
|
||||||
|
|
||||||
#: Reported when a display gain is applied to a displacement that is only
|
#: Reported when a display gain is applied to a displacement that is only
|
||||||
#: approximate. Informational: the picture is legitimate as an illustration, and
|
#: approximate. Informational: the picture is legitimate as an illustration, and
|
||||||
@@ -75,6 +81,8 @@ class SceneComposer:
|
|||||||
#: draw, the picture is the asset's own geometry, and recomputing its normals
|
#: draw, the picture is the asset's own geometry, and recomputing its normals
|
||||||
#: every frame was pure cost (measured as the bulk of a 20k-vertex compose).
|
#: every frame was pure cost (measured as the bulk of a 20k-vertex compose).
|
||||||
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
self._rest_geometry: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
||||||
|
#: Last UV grid of "arrow was on" samples for flow hysteresis.
|
||||||
|
self._flow_previously_on: Optional[np.ndarray] = None
|
||||||
|
|
||||||
def compose(
|
def compose(
|
||||||
self,
|
self,
|
||||||
@@ -134,6 +142,9 @@ class SceneComposer:
|
|||||||
|
|
||||||
# Measured before the frame is built, so the frame carries *its own* cost: the
|
# Measured before the frame is built, so the frame carries *its own* cost: the
|
||||||
# shared ``last_stats`` object is rewritten by the next task.
|
# shared ``last_stats`` object is rewritten by the next task.
|
||||||
|
flow_arrows, contact_arrow = self._build_overlays(
|
||||||
|
assets, frame, settings, diagnostics
|
||||||
|
)
|
||||||
total_ms = (time.perf_counter() - started) * 1000.0
|
total_ms = (time.perf_counter() - started) * 1000.0
|
||||||
self.last_stats.total_ms = total_ms
|
self.last_stats.total_ms = total_ms
|
||||||
|
|
||||||
@@ -162,10 +173,89 @@ class SceneComposer:
|
|||||||
# ``last_stats`` object is rewritten by the next task, so a reader on the
|
# ``last_stats`` object is rewritten by the next task, so a reader on the
|
||||||
# GUI thread would report another frame's cost.
|
# GUI thread would report another frame's cost.
|
||||||
compose_ms=total_ms,
|
compose_ms=total_ms,
|
||||||
|
flow_arrows=flow_arrows,
|
||||||
|
contact_arrow=contact_arrow,
|
||||||
)
|
)
|
||||||
validate_render_frame(result, vertices=int(rest_positions.shape[0]))
|
validate_render_frame(result, vertices=int(rest_positions.shape[0]))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _build_overlays(
|
||||||
|
self,
|
||||||
|
assets: SceneAssets,
|
||||||
|
frame: SurfaceFieldFrame,
|
||||||
|
settings: DisplaySettings,
|
||||||
|
diagnostics: list[Diagnostic],
|
||||||
|
):
|
||||||
|
flow_arrows = None
|
||||||
|
contact_arrow = None
|
||||||
|
mapping = assets.surface_mapping
|
||||||
|
if mapping is None:
|
||||||
|
if settings.flow_arrows_enabled or settings.contact_arrow_enabled:
|
||||||
|
diagnostics.append(
|
||||||
|
diagnostic(
|
||||||
|
"field_unavailable",
|
||||||
|
"this scene package has no UV surface mapping; overlays are hidden",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return flow_arrows, contact_arrow
|
||||||
|
|
||||||
|
policy = VisualPolicy(
|
||||||
|
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),
|
||||||
|
).normalised()
|
||||||
|
if (
|
||||||
|
settings.flow_arrows_enabled
|
||||||
|
and frame.flow_uv is not None
|
||||||
|
and frame.flow_valid_mask is not None
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
flow = np.asarray(frame.flow_uv)
|
||||||
|
h, w = int(flow.shape[0]), int(flow.shape[1])
|
||||||
|
previously_on = self._flow_previously_on
|
||||||
|
if previously_on is not None and previously_on.shape != (h, w):
|
||||||
|
previously_on = None
|
||||||
|
samples = select_flow_samples(
|
||||||
|
flow,
|
||||||
|
frame.flow_valid_mask,
|
||||||
|
policy,
|
||||||
|
previously_on=previously_on,
|
||||||
|
)
|
||||||
|
on_mask = np.zeros((h, w), dtype=bool)
|
||||||
|
if samples.rows.size:
|
||||||
|
on_mask[samples.rows, samples.cols] = True
|
||||||
|
self._flow_previously_on = on_mask
|
||||||
|
flow_arrows, extra = build_flow_overlay(
|
||||||
|
samples,
|
||||||
|
mapping,
|
||||||
|
style=policy,
|
||||||
|
normal_offset_m=mesh_span_from_mapping(mapping) * 0.002,
|
||||||
|
)
|
||||||
|
diagnostics.extend(extra)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
diagnostics.append(
|
||||||
|
diagnostic(
|
||||||
|
"field_unavailable",
|
||||||
|
f"flow overlay failed: {type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
flow_arrows = None
|
||||||
|
self._flow_previously_on = None
|
||||||
|
else:
|
||||||
|
self._flow_previously_on = None
|
||||||
|
|
||||||
|
if settings.contact_arrow_enabled and frame.contact_valid:
|
||||||
|
contact_arrow, extra = build_normal_overlay(
|
||||||
|
contact_uv_xy=frame.contact_uv_xy,
|
||||||
|
contact_found=frame.contact_found,
|
||||||
|
contact_valid=frame.contact_valid,
|
||||||
|
fn=float(frame.contact_fn),
|
||||||
|
mapping=mapping,
|
||||||
|
)
|
||||||
|
diagnostics.extend(extra)
|
||||||
|
return flow_arrows, contact_arrow
|
||||||
|
|
||||||
def _rest(self, assets: SceneAssets) -> tuple[np.ndarray, np.ndarray]:
|
def _rest(self, assets: SceneAssets) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""The asset's own positions and normals, computed once per asset.
|
"""The asset's own positions and normals, computed once per asset.
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,51 @@ class SurfaceFieldFrame:
|
|||||||
#: only claim). When it is older than ``identity.frame_id`` the array is a stale
|
#: only claim). When it is older than ``identity.frame_id`` the array is a stale
|
||||||
#: measurement: a consumer must not colour it as the current instant.
|
#: measurement: a consumer must not colour it as the current instant.
|
||||||
computed_frame_id: Optional[int] = None
|
computed_frame_id: Optional[int] = None
|
||||||
|
#: Optional flow field kept alongside a depth scalar so overlays can draw arrows
|
||||||
|
#: without forcing the colour channel to switch away from depth.
|
||||||
|
flow_uv: Optional[F32] = None # [H,W,2]
|
||||||
|
flow_valid_mask: Optional[Mask] = None # [H,W]
|
||||||
|
#: Contact centre in the same UV domain as ``scalar``; absent on older packets.
|
||||||
|
contact_uv_xy: Optional[tuple[float, float]] = None
|
||||||
|
contact_found: bool = False
|
||||||
|
contact_valid: bool = False
|
||||||
|
#: Normal-force reading used only for the contact-arrow length mapping.
|
||||||
|
contact_fn: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
ArrowKind = Literal["flow", "contact_normal"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ArrowInstances:
|
||||||
|
"""Per-frame arrow overlay geometry in the sensor frame (metres).
|
||||||
|
|
||||||
|
Directions are unit vectors; lengths are in the same units as origins.
|
||||||
|
``M`` is bounded by the visual policy so the GL layer never uploads unbounded
|
||||||
|
Python mesh objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
origins: F32 # [M,3]
|
||||||
|
directions: F32 # [M,3], unit
|
||||||
|
lengths: F32 # [M]
|
||||||
|
colors: F32 # [M,4], RGBA 0–1
|
||||||
|
kind: ArrowKind
|
||||||
|
#: Optional per-arrow thickness scale relative to a template (contact arrows).
|
||||||
|
thickness_scales: Optional[F32] = None # [M]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SurfaceMapping:
|
||||||
|
"""UV-domain positions and normals in the sensor frame, for overlay geometry.
|
||||||
|
|
||||||
|
Optional on older packages: without it the composer draws the scalar surface
|
||||||
|
only and reports a soft overlay degradation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
shape_hw: tuple[int, int]
|
||||||
|
positions_sensor_m: F32 # [H,W,3]
|
||||||
|
normals_sensor: F32 # [H,W,3], unit where valid
|
||||||
|
valid: Mask # [H,W]
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------- assets
|
# -------------------------------------------------------------------- assets
|
||||||
@@ -489,6 +534,9 @@ class SceneAssets:
|
|||||||
#: Where this package may be displayed (stage 5). ``None`` means "not locked",
|
#: Where this package may be displayed (stage 5). ``None`` means "not locked",
|
||||||
#: which is what every package written before the lock existed carries.
|
#: which is what every package written before the lock existed carries.
|
||||||
platform_lock: Optional[PlatformLock] = None
|
platform_lock: Optional[PlatformLock] = None
|
||||||
|
#: UV→scene positions/normals for overlays. Optional so older packages degrade
|
||||||
|
#: to scalar-only rendering without failing to load.
|
||||||
|
surface_mapping: Optional[SurfaceMapping] = None
|
||||||
|
|
||||||
def part(self, part_id: str) -> Optional[MeshPart]:
|
def part(self, part_id: str) -> Optional[MeshPart]:
|
||||||
for item in self.parts:
|
for item in self.parts:
|
||||||
@@ -530,6 +578,9 @@ class SceneCapabilities:
|
|||||||
displacement_available: bool = False
|
displacement_available: bool = False
|
||||||
displacement_quality: Optional[Quality] = None
|
displacement_quality: Optional[Quality] = None
|
||||||
source_kind: str = "live"
|
source_kind: str = "live"
|
||||||
|
#: Whether flow / contact overlays can be built for this source.
|
||||||
|
flow_arrows_available: bool = False
|
||||||
|
contact_arrow_available: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -548,6 +599,17 @@ class DisplaySettings:
|
|||||||
#: producer did not claim for this frame is refused before either is coloured (see
|
#: producer did not claim for this frame is refused before either is coloured (see
|
||||||
#: ``orisys_studio.scene3d.adapters``).
|
#: ``orisys_studio.scene3d.adapters``).
|
||||||
field: str = "depth"
|
field: str = "depth"
|
||||||
|
#: Draw optical-flow arrows on the sensing surface (independent of ``field``).
|
||||||
|
flow_arrows_enabled: bool = True
|
||||||
|
#: Draw the yellow contact-normal arrow at the contact centre.
|
||||||
|
contact_arrow_enabled: bool = True
|
||||||
|
#: Flow magnitude (px) at which arrows appear / disappear (hysteresis: on > off).
|
||||||
|
flow_on_px: float = 0.4
|
||||||
|
flow_off_px: float = 0.3
|
||||||
|
#: UV grid spacing shared by 2D and 3D flow arrows.
|
||||||
|
flow_grid_spacing: int = 10
|
||||||
|
#: Cap on arrows uploaded per overlay kind.
|
||||||
|
max_arrows: int = 400
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -579,6 +641,9 @@ class RenderFrame:
|
|||||||
#: What composing this frame cost, measured where it was composed. Carried per frame
|
#: What composing this frame cost, measured where it was composed. Carried per frame
|
||||||
#: rather than read from the composer's shared counter, which the next task rewrites.
|
#: rather than read from the composer's shared counter, which the next task rewrites.
|
||||||
compose_ms: float = 0.0
|
compose_ms: float = 0.0
|
||||||
|
#: Optional overlays; absent on older packages / frames without mapping.
|
||||||
|
flow_arrows: Optional[ArrowInstances] = None
|
||||||
|
contact_arrow: Optional[ArrowInstances] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -1031,6 +1096,37 @@ def validate_render_frame(frame: RenderFrame, *, vertices: int) -> None:
|
|||||||
raise error("field_domain_mismatch", "render scalar is float32 and valid is bool")
|
raise error("field_domain_mismatch", "render scalar is float32 and valid is bool")
|
||||||
if not frame.asset_hash.strip() or not frame.geometry_id.strip():
|
if not frame.asset_hash.strip() or not frame.geometry_id.strip():
|
||||||
raise error("geometry_mismatch", "a render frame must name its asset and geometry")
|
raise error("geometry_mismatch", "a render frame must name its asset and geometry")
|
||||||
|
for label, arrows in (
|
||||||
|
("flow_arrows", frame.flow_arrows),
|
||||||
|
("contact_arrow", frame.contact_arrow),
|
||||||
|
):
|
||||||
|
if arrows is None:
|
||||||
|
continue
|
||||||
|
validate_arrow_instances(arrows, name=label)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_arrow_instances(arrows: ArrowInstances, *, name: str = "arrows") -> None:
|
||||||
|
"""Shapes and finiteness for one overlay batch."""
|
||||||
|
for field_name, array, width in (
|
||||||
|
("origins", arrows.origins, 3),
|
||||||
|
("directions", arrows.directions, 3),
|
||||||
|
("lengths", arrows.lengths, None),
|
||||||
|
("colors", arrows.colors, 4),
|
||||||
|
):
|
||||||
|
_readonly_c_contiguous(f"{name}.{field_name}", array)
|
||||||
|
count = int(arrows.origins.shape[0])
|
||||||
|
_shape(f"{name}.origins", arrows.origins, (count, 3))
|
||||||
|
_shape(f"{name}.directions", arrows.directions, (count, 3))
|
||||||
|
_shape(f"{name}.lengths", arrows.lengths, (count,))
|
||||||
|
_shape(f"{name}.colors", arrows.colors, (count, 4))
|
||||||
|
_finite(f"{name}.origins", arrows.origins)
|
||||||
|
_finite(f"{name}.directions", arrows.directions)
|
||||||
|
_finite(f"{name}.lengths", arrows.lengths)
|
||||||
|
if arrows.thickness_scales is not None:
|
||||||
|
_readonly_c_contiguous(f"{name}.thickness_scales", arrows.thickness_scales)
|
||||||
|
_shape(f"{name}.thickness_scales", arrows.thickness_scales, (count,))
|
||||||
|
if arrows.kind not in ("flow", "contact_normal"):
|
||||||
|
raise error("field_domain_mismatch", f"{name}: unknown arrow kind {arrows.kind!r}")
|
||||||
|
|
||||||
|
|
||||||
#: The descriptor a reference frame carries: nothing is measured, and the UI has
|
#: The descriptor a reference frame carries: nothing is measured, and the UI has
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from orisys_app_shared.scene3d.digest import (
|
|||||||
geometry_id,
|
geometry_id,
|
||||||
source_geometry_digest,
|
source_geometry_digest,
|
||||||
)
|
)
|
||||||
|
from orisys_app_shared.scene3d.overlays import build_surface_mapping
|
||||||
|
|
||||||
#: Keys the importer needs, and what it does when they are missing.
|
#: Keys the importer needs, and what it does when they are missing.
|
||||||
MESH_KEYS = ("vertices", "faces", "uv_data", "loop_to_vertex")
|
MESH_KEYS = ("vertices", "faces", "uv_data", "loop_to_vertex")
|
||||||
@@ -573,6 +574,16 @@ class LegacyFingerImporter:
|
|||||||
# source vertex, and an operator asking "which triangle is this?"
|
# source vertex, and an operator asking "which triangle is this?"
|
||||||
# deserves an answer rather than a guess.
|
# deserves an answer rather than a guess.
|
||||||
source_vertex_of_render=freeze(source_of_render, dtype=np.int32),
|
source_vertex_of_render=freeze(source_of_render, dtype=np.int32),
|
||||||
|
surface_mapping=(
|
||||||
|
build_surface_mapping(
|
||||||
|
domain.world_xyz,
|
||||||
|
domain.valid_mask,
|
||||||
|
meters_per_source_unit=units,
|
||||||
|
transform_sensor_from_source=transform,
|
||||||
|
)
|
||||||
|
if domain.world_xyz is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return assets
|
return assets
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"""NumPy overlay geometry for 3D flow and contact-normal arrows.
|
||||||
|
|
||||||
|
Ported from the old Open3D example semantics without importing Open3D or the
|
||||||
|
examples tree. Outputs :class:`ArrowInstances` for the GL backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
ArrowInstances,
|
||||||
|
Diagnostic,
|
||||||
|
SurfaceMapping,
|
||||||
|
diagnostic,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy
|
||||||
|
|
||||||
|
ForceStrengthMode = Literal["log", "power", "linear"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContactArrowStyle:
|
||||||
|
"""Visual length/thickness mapping for the yellow contact-normal arrow."""
|
||||||
|
|
||||||
|
normal_force_scale: float = 1.0
|
||||||
|
min_length_fraction: float = 0.02
|
||||||
|
max_length_fraction: float = 0.30
|
||||||
|
min_thickness_scale: float = 0.20
|
||||||
|
max_thickness_scale: float = 1.0
|
||||||
|
reference_normal_force: float = 10000.0
|
||||||
|
force_strength_mode: ForceStrengthMode = "log"
|
||||||
|
force_strength_gamma: float = 0.5
|
||||||
|
color_rgba: tuple[float, float, float, float] = (1.0, 0.92, 0.10, 1.0)
|
||||||
|
#: Lift the origin slightly along the outward normal to avoid z-fighting.
|
||||||
|
normal_offset_fraction: float = 0.005
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONTACT_ARROW_STYLE = ContactArrowStyle()
|
||||||
|
|
||||||
|
FLOW_TAIL_RGBA = (38 / 255.0, 235 / 255.0, 89 / 255.0, 0.85)
|
||||||
|
FLOW_HEAD_RGBA = (13 / 255.0, 115 / 255.0, 31 / 255.0, 0.95)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_arrows(kind: str) -> ArrowInstances:
|
||||||
|
z3 = freeze(np.zeros((0, 3), dtype=np.float32), dtype=np.float32)
|
||||||
|
z1 = freeze(np.zeros((0,), dtype=np.float32), dtype=np.float32)
|
||||||
|
z4 = freeze(np.zeros((0, 4), dtype=np.float32), dtype=np.float32)
|
||||||
|
return ArrowInstances(
|
||||||
|
origins=z3,
|
||||||
|
directions=z3,
|
||||||
|
lengths=z1,
|
||||||
|
colors=z4,
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
thickness_scales=z1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def force_visual_strength(
|
||||||
|
force: float,
|
||||||
|
*,
|
||||||
|
style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE,
|
||||||
|
) -> float:
|
||||||
|
"""Map normal force Fn to a unitless strength in [0, 1] for length/thickness."""
|
||||||
|
reference = style.reference_normal_force
|
||||||
|
if force <= 0.0 or reference <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
ratio = force / reference
|
||||||
|
if style.force_strength_mode == "linear":
|
||||||
|
strength = ratio
|
||||||
|
elif style.force_strength_mode == "power":
|
||||||
|
strength = ratio ** style.force_strength_gamma
|
||||||
|
else:
|
||||||
|
strength = np.log1p(ratio) / np.log1p(1.0)
|
||||||
|
return float(np.clip(strength, 0.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def length_from_force(
|
||||||
|
force: float,
|
||||||
|
mesh_span: float,
|
||||||
|
*,
|
||||||
|
style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE,
|
||||||
|
) -> float:
|
||||||
|
strength = force_visual_strength(force, style=style)
|
||||||
|
frac = style.min_length_fraction + (
|
||||||
|
style.max_length_fraction - style.min_length_fraction
|
||||||
|
) * strength
|
||||||
|
return float(mesh_span * frac * style.normal_force_scale)
|
||||||
|
|
||||||
|
|
||||||
|
def thickness_scale_from_force(
|
||||||
|
force: float,
|
||||||
|
*,
|
||||||
|
style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE,
|
||||||
|
) -> float:
|
||||||
|
strength = force_visual_strength(force, style=style)
|
||||||
|
return float(
|
||||||
|
style.min_thickness_scale
|
||||||
|
+ (style.max_thickness_scale - style.min_thickness_scale) * strength
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mesh_span_from_mapping(mapping: SurfaceMapping) -> float:
|
||||||
|
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
|
||||||
|
valid = np.asarray(mapping.valid, dtype=bool)
|
||||||
|
if not np.any(valid):
|
||||||
|
return 1e-3
|
||||||
|
pts = positions[valid]
|
||||||
|
extent = np.ptp(pts, axis=0)
|
||||||
|
return float(max(np.max(extent), 1e-6))
|
||||||
|
|
||||||
|
|
||||||
|
def build_flow_overlay(
|
||||||
|
samples: FlowSamples,
|
||||||
|
mapping: SurfaceMapping,
|
||||||
|
*,
|
||||||
|
style: Optional[VisualPolicy] = None,
|
||||||
|
normal_offset_m: float = 0.0,
|
||||||
|
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
|
||||||
|
"""Map shared UV flow samples onto the sensing surface."""
|
||||||
|
del style # lengths come from UV tip deltas projected into scene metres
|
||||||
|
if samples.rows.size == 0:
|
||||||
|
return _empty_arrows("flow"), ()
|
||||||
|
|
||||||
|
h, w = mapping.shape_hw
|
||||||
|
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
|
||||||
|
normals = np.asarray(mapping.normals_sensor, dtype=np.float64)
|
||||||
|
valid = np.asarray(mapping.valid, dtype=bool)
|
||||||
|
if positions.shape[:2] != (h, w) or normals.shape[:2] != (h, w):
|
||||||
|
return _empty_arrows("flow"), (
|
||||||
|
diagnostic(
|
||||||
|
"geometry_mismatch",
|
||||||
|
"surface mapping shape disagrees with its declared domain",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = np.asarray(samples.rows, dtype=np.int32)
|
||||||
|
cols = np.asarray(samples.cols, dtype=np.int32)
|
||||||
|
end_x = np.clip(np.round(cols + samples.dx).astype(np.int32), 0, w - 1)
|
||||||
|
end_y = np.clip(np.round(rows + samples.dy).astype(np.int32), 0, h - 1)
|
||||||
|
|
||||||
|
inside = (
|
||||||
|
(rows >= 0)
|
||||||
|
& (rows < h)
|
||||||
|
& (cols >= 0)
|
||||||
|
& (cols < w)
|
||||||
|
& valid[rows, cols]
|
||||||
|
& valid[end_y, end_x]
|
||||||
|
)
|
||||||
|
if not np.any(inside):
|
||||||
|
return _empty_arrows("flow"), ()
|
||||||
|
|
||||||
|
rows = rows[inside]
|
||||||
|
cols = cols[inside]
|
||||||
|
end_x = end_x[inside]
|
||||||
|
end_y = end_y[inside]
|
||||||
|
origins = positions[rows, cols].copy()
|
||||||
|
ends = positions[end_y, end_x]
|
||||||
|
normals_o = normals[rows, cols]
|
||||||
|
if normal_offset_m:
|
||||||
|
origins = origins + normal_offset_m * normals_o
|
||||||
|
|
||||||
|
vectors = ends - origins
|
||||||
|
lengths = np.linalg.norm(vectors, axis=1)
|
||||||
|
keep = lengths > 1e-9
|
||||||
|
if not np.any(keep):
|
||||||
|
return _empty_arrows("flow"), ()
|
||||||
|
origins = origins[keep]
|
||||||
|
vectors = vectors[keep]
|
||||||
|
lengths = lengths[keep]
|
||||||
|
directions = vectors / lengths[:, None]
|
||||||
|
|
||||||
|
colors = np.tile(np.asarray(FLOW_TAIL_RGBA, dtype=np.float32), (len(origins), 1))
|
||||||
|
# Stronger arrows tip toward the head colour.
|
||||||
|
t = np.clip(lengths / max(float(np.percentile(lengths, 95)), 1e-9), 0.0, 1.0)
|
||||||
|
head = np.asarray(FLOW_HEAD_RGBA, dtype=np.float32)
|
||||||
|
colors = colors * (1.0 - t)[:, None] + head * t[:, None]
|
||||||
|
|
||||||
|
return (
|
||||||
|
ArrowInstances(
|
||||||
|
origins=freeze(origins, dtype=np.float32),
|
||||||
|
directions=freeze(directions, dtype=np.float32),
|
||||||
|
lengths=freeze(lengths, dtype=np.float32),
|
||||||
|
colors=freeze(colors, dtype=np.float32),
|
||||||
|
kind="flow",
|
||||||
|
),
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_normal_overlay(
|
||||||
|
*,
|
||||||
|
contact_uv_xy: Optional[tuple[float, float]],
|
||||||
|
contact_found: bool,
|
||||||
|
contact_valid: bool,
|
||||||
|
fn: float,
|
||||||
|
mapping: SurfaceMapping,
|
||||||
|
style: ContactArrowStyle = DEFAULT_CONTACT_ARROW_STYLE,
|
||||||
|
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
|
||||||
|
"""Yellow arrow at the contact centre along the local surface normal.
|
||||||
|
|
||||||
|
Length is a *display* mapping of FN (log/power), not a calibrated newton
|
||||||
|
vector. The arrow points opposite the outward normal (into the contact).
|
||||||
|
"""
|
||||||
|
if not contact_found or not contact_valid or contact_uv_xy is None:
|
||||||
|
return _empty_arrows("contact_normal"), ()
|
||||||
|
force = max(float(fn), 0.0)
|
||||||
|
if force <= 0.0 or not np.isfinite(force):
|
||||||
|
return _empty_arrows("contact_normal"), ()
|
||||||
|
|
||||||
|
h, w = mapping.shape_hw
|
||||||
|
cx = int(round(float(contact_uv_xy[0])))
|
||||||
|
cy = int(round(float(contact_uv_xy[1])))
|
||||||
|
if not (0 <= cy < h and 0 <= cx < w):
|
||||||
|
return _empty_arrows("contact_normal"), (
|
||||||
|
diagnostic(
|
||||||
|
"field_unavailable",
|
||||||
|
"contact centre lies outside the UV domain",
|
||||||
|
x=str(cx),
|
||||||
|
y=str(cy),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
valid = np.asarray(mapping.valid, dtype=bool)
|
||||||
|
if not bool(valid[cy, cx]):
|
||||||
|
return _empty_arrows("contact_normal"), (
|
||||||
|
diagnostic(
|
||||||
|
"field_unavailable",
|
||||||
|
"contact centre is outside the valid sensing mask",
|
||||||
|
x=str(cx),
|
||||||
|
y=str(cy),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
origin = np.asarray(mapping.positions_sensor_m[cy, cx], dtype=np.float64)
|
||||||
|
normal = np.asarray(mapping.normals_sensor[cy, cx], dtype=np.float64)
|
||||||
|
if not (np.all(np.isfinite(origin)) and np.all(np.isfinite(normal))):
|
||||||
|
return _empty_arrows("contact_normal"), ()
|
||||||
|
n_norm = float(np.linalg.norm(normal))
|
||||||
|
if n_norm < 1e-8:
|
||||||
|
return _empty_arrows("contact_normal"), ()
|
||||||
|
direction = -normal / n_norm
|
||||||
|
span = mesh_span_from_mapping(mapping)
|
||||||
|
length = length_from_force(force, span, style=style)
|
||||||
|
length = float(
|
||||||
|
np.clip(
|
||||||
|
length,
|
||||||
|
span * style.min_length_fraction,
|
||||||
|
span * style.max_length_fraction,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if length <= 0.0:
|
||||||
|
return _empty_arrows("contact_normal"), ()
|
||||||
|
offset = span * style.normal_offset_fraction
|
||||||
|
origin = origin + offset * (normal / n_norm)
|
||||||
|
thickness = thickness_scale_from_force(force, style=style)
|
||||||
|
color = np.asarray(style.color_rgba, dtype=np.float32).reshape(1, 4)
|
||||||
|
|
||||||
|
return (
|
||||||
|
ArrowInstances(
|
||||||
|
origins=freeze(origin.reshape(1, 3), dtype=np.float32),
|
||||||
|
directions=freeze(direction.reshape(1, 3), dtype=np.float32),
|
||||||
|
lengths=freeze(np.asarray([length], dtype=np.float32), dtype=np.float32),
|
||||||
|
colors=freeze(color, dtype=np.float32),
|
||||||
|
kind="contact_normal",
|
||||||
|
thickness_scales=freeze(
|
||||||
|
np.asarray([thickness], dtype=np.float32), dtype=np.float32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_surface_frames(
|
||||||
|
world_xyz: np.ndarray,
|
||||||
|
valid_mask: np.ndarray,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""Estimate tangent_x, tangent_y and unit normals on a UV grid.
|
||||||
|
|
||||||
|
Mirrors the SDK ``compute_uv_surface_frames`` contract without importing it.
|
||||||
|
"""
|
||||||
|
xyz = np.asarray(world_xyz, dtype=np.float64)
|
||||||
|
mask = np.asarray(valid_mask, dtype=bool)
|
||||||
|
h, w = mask.shape
|
||||||
|
tangent_x = np.zeros((h, w, 3), dtype=np.float64)
|
||||||
|
tangent_y = np.zeros((h, w, 3), dtype=np.float64)
|
||||||
|
normals = np.zeros((h, w, 3), dtype=np.float64)
|
||||||
|
|
||||||
|
# Forward differences where possible, else backward.
|
||||||
|
dx = np.zeros_like(xyz)
|
||||||
|
dy = np.zeros_like(xyz)
|
||||||
|
dx[:, :-1] = xyz[:, 1:] - xyz[:, :-1]
|
||||||
|
dx[:, -1] = xyz[:, -1] - xyz[:, -2] if w > 1 else 0.0
|
||||||
|
dy[:-1, :] = xyz[1:, :] - xyz[:-1, :]
|
||||||
|
dy[-1, :] = xyz[-1, :] - xyz[-2, :] if h > 1 else 0.0
|
||||||
|
|
||||||
|
# Invalidate differences that leave the mask.
|
||||||
|
right_ok = np.zeros((h, w), dtype=bool)
|
||||||
|
left_ok = np.zeros((h, w), dtype=bool)
|
||||||
|
down_ok = np.zeros((h, w), dtype=bool)
|
||||||
|
up_ok = np.zeros((h, w), dtype=bool)
|
||||||
|
right_ok[:, :-1] = mask[:, :-1] & mask[:, 1:]
|
||||||
|
left_ok[:, 1:] = mask[:, 1:] & mask[:, :-1]
|
||||||
|
down_ok[:-1, :] = mask[:-1, :] & mask[1:, :]
|
||||||
|
up_ok[1:, :] = mask[1:, :] & mask[:-1, :]
|
||||||
|
|
||||||
|
use_fwd_x = right_ok
|
||||||
|
use_back_x = (~right_ok) & left_ok
|
||||||
|
dx_back = np.zeros_like(xyz)
|
||||||
|
dx_back[:, 1:] = xyz[:, 1:] - xyz[:, :-1]
|
||||||
|
tx = np.where(use_fwd_x[..., None], dx, 0.0)
|
||||||
|
tx = np.where(use_back_x[..., None], dx_back, tx)
|
||||||
|
# Fallback axis for isolated pixels.
|
||||||
|
isolated_x = mask & ~(use_fwd_x | use_back_x)
|
||||||
|
tx[isolated_x] = np.array([1.0, 0.0, 0.0])
|
||||||
|
|
||||||
|
use_fwd_y = down_ok
|
||||||
|
use_back_y = (~down_ok) & up_ok
|
||||||
|
dy_back = np.zeros_like(xyz)
|
||||||
|
dy_back[1:, :] = xyz[1:, :] - xyz[:-1, :]
|
||||||
|
ty = np.where(use_fwd_y[..., None], dy, 0.0)
|
||||||
|
ty = np.where(use_back_y[..., None], dy_back, ty)
|
||||||
|
isolated_y = mask & ~(use_fwd_y | use_back_y)
|
||||||
|
ty[isolated_y] = np.array([0.0, 1.0, 0.0])
|
||||||
|
|
||||||
|
n = np.cross(tx, ty)
|
||||||
|
n_norm = np.linalg.norm(n, axis=-1, keepdims=True)
|
||||||
|
bad = n_norm[..., 0] < 1e-12
|
||||||
|
n = np.divide(n, np.maximum(n_norm, 1e-12), out=np.zeros_like(n))
|
||||||
|
n[bad] = np.array([0.0, 0.0, 1.0])
|
||||||
|
|
||||||
|
tx_norm = np.linalg.norm(tx, axis=-1, keepdims=True)
|
||||||
|
ty_norm = np.linalg.norm(ty, axis=-1, keepdims=True)
|
||||||
|
tangent_x = np.divide(tx, np.maximum(tx_norm, 1e-12), out=np.zeros_like(tx))
|
||||||
|
tangent_y = np.divide(ty, np.maximum(ty_norm, 1e-12), out=np.zeros_like(ty))
|
||||||
|
normals = n
|
||||||
|
tangent_x[~mask] = 0.0
|
||||||
|
tangent_y[~mask] = 0.0
|
||||||
|
normals[~mask] = 0.0
|
||||||
|
return tangent_x, tangent_y, normals
|
||||||
|
|
||||||
|
|
||||||
|
def build_surface_mapping(
|
||||||
|
world_xyz: np.ndarray,
|
||||||
|
valid_mask: np.ndarray,
|
||||||
|
*,
|
||||||
|
meters_per_source_unit: float,
|
||||||
|
transform_sensor_from_source: np.ndarray,
|
||||||
|
) -> SurfaceMapping:
|
||||||
|
"""Convert a legacy UV ``world_xyz`` grid into sensor-metre ``SurfaceMapping``."""
|
||||||
|
units = float(meters_per_source_unit)
|
||||||
|
if not np.isfinite(units) or units <= 0.0:
|
||||||
|
raise ValueError("meters_per_source_unit must be finite and positive")
|
||||||
|
transform = np.asarray(transform_sensor_from_source, dtype=np.float64).reshape(4, 4)
|
||||||
|
xyz = np.asarray(world_xyz, dtype=np.float64) * units
|
||||||
|
mask = np.asarray(valid_mask, dtype=bool)
|
||||||
|
h, w = mask.shape
|
||||||
|
flat = xyz.reshape(-1, 3)
|
||||||
|
ones = np.ones((flat.shape[0], 1), dtype=np.float64)
|
||||||
|
homo = np.concatenate([flat, ones], axis=1)
|
||||||
|
sensor = (homo @ transform.T)[:, :3].reshape(h, w, 3)
|
||||||
|
_, _, normals = compute_surface_frames(sensor, mask)
|
||||||
|
# Rotate is already baked into sensor positions; recompute normals in sensor frame.
|
||||||
|
return SurfaceMapping(
|
||||||
|
shape_hw=(h, w),
|
||||||
|
positions_sensor_m=freeze(sensor, dtype=np.float32),
|
||||||
|
normals_sensor=freeze(normals, dtype=np.float32),
|
||||||
|
valid=freeze(mask, dtype=np.bool_),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rotation_matrix_z_to(direction: np.ndarray) -> np.ndarray:
|
||||||
|
direction = np.asarray(direction, dtype=np.float64).reshape(3)
|
||||||
|
norm = np.linalg.norm(direction)
|
||||||
|
if norm < 1e-8:
|
||||||
|
return np.eye(3, dtype=np.float64)
|
||||||
|
direction = direction / norm
|
||||||
|
z_axis = np.array([0.0, 0.0, 1.0], dtype=np.float64)
|
||||||
|
cross = np.cross(z_axis, direction)
|
||||||
|
dot = float(np.dot(z_axis, direction))
|
||||||
|
if dot > 0.9999:
|
||||||
|
return np.eye(3, dtype=np.float64)
|
||||||
|
if dot < -0.9999:
|
||||||
|
return np.diag([1.0, -1.0, -1.0]).astype(np.float64)
|
||||||
|
skew = np.array(
|
||||||
|
[
|
||||||
|
[0.0, -cross[2], cross[1]],
|
||||||
|
[cross[2], 0.0, -cross[0]],
|
||||||
|
[-cross[1], cross[0], 0.0],
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
return np.eye(3, dtype=np.float64) + skew + skew @ skew * (1.0 / (1.0 + dot))
|
||||||
|
|
||||||
|
|
||||||
|
def _cylinder_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Unit-radius, unit-height cylinder along +Z, centred on the origin."""
|
||||||
|
angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False)
|
||||||
|
ring = np.column_stack([np.cos(angles), np.sin(angles), np.zeros(resolution)])
|
||||||
|
bottom = ring.copy()
|
||||||
|
bottom[:, 2] = -0.5
|
||||||
|
top = ring.copy()
|
||||||
|
top[:, 2] = 0.5
|
||||||
|
vertices = np.vstack([bottom, top])
|
||||||
|
triangles = []
|
||||||
|
for i in range(resolution):
|
||||||
|
j = (i + 1) % resolution
|
||||||
|
triangles.append([i, j, resolution + i])
|
||||||
|
triangles.append([j, resolution + j, resolution + i])
|
||||||
|
return (
|
||||||
|
np.asarray(vertices, dtype=np.float64),
|
||||||
|
np.asarray(triangles, dtype=np.int32),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cone_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Unit-radius cone with apex at z=1 and base at z=0."""
|
||||||
|
angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False)
|
||||||
|
base = np.column_stack([np.cos(angles), np.sin(angles), np.zeros(resolution)])
|
||||||
|
apex = np.array([[0.0, 0.0, 1.0]], dtype=np.float64)
|
||||||
|
vertices = np.vstack([base, apex])
|
||||||
|
apex_i = resolution
|
||||||
|
triangles = [[i, (i + 1) % resolution, apex_i] for i in range(resolution)]
|
||||||
|
return (
|
||||||
|
np.asarray(vertices, dtype=np.float64),
|
||||||
|
np.asarray(triangles, dtype=np.int32),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def arrow_instances_to_mesh(
|
||||||
|
arrows: ArrowInstances,
|
||||||
|
*,
|
||||||
|
default_radius_fraction: float = 0.02,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""Merge arrow instances into one triangle mesh (positions, triangles, colors)."""
|
||||||
|
empty_v = np.zeros((0, 3), dtype=np.float32)
|
||||||
|
empty_t = np.zeros((0, 3), dtype=np.uint32)
|
||||||
|
empty_c = np.zeros((0, 4), dtype=np.float32)
|
||||||
|
count = int(arrows.origins.shape[0])
|
||||||
|
if count == 0:
|
||||||
|
return empty_v, empty_t, empty_c
|
||||||
|
|
||||||
|
cyl_v, cyl_t = _cylinder_template()
|
||||||
|
cone_v, cone_t = _cone_template()
|
||||||
|
origins = np.asarray(arrows.origins, dtype=np.float64)
|
||||||
|
directions = np.asarray(arrows.directions, dtype=np.float64)
|
||||||
|
lengths = np.asarray(arrows.lengths, dtype=np.float64)
|
||||||
|
colors = np.asarray(arrows.colors, dtype=np.float64)
|
||||||
|
thickness = (
|
||||||
|
np.asarray(arrows.thickness_scales, dtype=np.float64)
|
||||||
|
if arrows.thickness_scales is not None
|
||||||
|
else np.ones(count, dtype=np.float64)
|
||||||
|
)
|
||||||
|
|
||||||
|
mean_length = float(np.mean(lengths)) if count else 1.0
|
||||||
|
base_radius = max(mean_length * default_radius_fraction, 1e-5)
|
||||||
|
|
||||||
|
parts_v: list[np.ndarray] = []
|
||||||
|
parts_t: list[np.ndarray] = []
|
||||||
|
parts_c: list[np.ndarray] = []
|
||||||
|
offset = 0
|
||||||
|
for i in range(count):
|
||||||
|
length = float(lengths[i])
|
||||||
|
if length <= 1e-9:
|
||||||
|
continue
|
||||||
|
direction = directions[i]
|
||||||
|
origin = origins[i]
|
||||||
|
radius = base_radius * float(max(thickness[i], 0.05))
|
||||||
|
rotation = _rotation_matrix_z_to(direction)
|
||||||
|
# Tail: 75% cylinder, head: 25% cone.
|
||||||
|
tail_len = length * 0.75
|
||||||
|
head_len = length * 0.25
|
||||||
|
# Cylinder from 0..tail_len along +Z then rotate.
|
||||||
|
scaled = cyl_v.copy()
|
||||||
|
scaled[:, 0] *= radius
|
||||||
|
scaled[:, 1] *= radius
|
||||||
|
scaled[:, 2] = (scaled[:, 2] + 0.5) * tail_len
|
||||||
|
tail = scaled @ rotation.T + origin
|
||||||
|
# Cone from tail_len..length.
|
||||||
|
scaled_c = cone_v.copy()
|
||||||
|
scaled_c[:, 0] *= radius * 1.8
|
||||||
|
scaled_c[:, 1] *= radius * 1.8
|
||||||
|
scaled_c[:, 2] = scaled_c[:, 2] * head_len + tail_len
|
||||||
|
head = scaled_c @ rotation.T + origin
|
||||||
|
|
||||||
|
for verts, tris in ((tail, cyl_t), (head, cone_t)):
|
||||||
|
parts_v.append(verts)
|
||||||
|
parts_t.append(tris + offset)
|
||||||
|
parts_c.append(np.tile(colors[i], (len(verts), 1)))
|
||||||
|
offset += len(verts)
|
||||||
|
|
||||||
|
if not parts_v:
|
||||||
|
return empty_v, empty_t, empty_c
|
||||||
|
return (
|
||||||
|
np.vstack(parts_v).astype(np.float32),
|
||||||
|
np.vstack(parts_t).astype(np.uint32),
|
||||||
|
np.vstack(parts_c).astype(np.float32),
|
||||||
|
)
|
||||||
@@ -100,7 +100,12 @@ SENSING_ARRAYS = {
|
|||||||
"sensing/sample_indices.npy": ("i4", 2),
|
"sensing/sample_indices.npy": ("i4", 2),
|
||||||
"sensing/sample_weights.npy": ("f4", 2),
|
"sensing/sample_weights.npy": ("f4", 2),
|
||||||
}
|
}
|
||||||
OPTIONAL_ARRAYS = {"sensing/source_vertex.npy": ("i4", 1)}
|
OPTIONAL_ARRAYS = {
|
||||||
|
"sensing/source_vertex.npy": ("i4", 1),
|
||||||
|
"sensing/surface_positions.npy": ("f4", 3),
|
||||||
|
"sensing/surface_normals.npy": ("f4", 3),
|
||||||
|
"sensing/surface_valid.npy": ("i4", 2),
|
||||||
|
}
|
||||||
HOUSING_ARRAYS = {
|
HOUSING_ARRAYS = {
|
||||||
"housing/positions.npy": ("f4", 2),
|
"housing/positions.npy": ("f4", 2),
|
||||||
"housing/normals.npy": ("f4", 2),
|
"housing/normals.npy": ("f4", 2),
|
||||||
@@ -228,6 +233,17 @@ def _arrays_of(assets: SceneAssets) -> dict[str, np.ndarray]:
|
|||||||
arrays["sensing/source_vertex.npy"] = np.ascontiguousarray(
|
arrays["sensing/source_vertex.npy"] = np.ascontiguousarray(
|
||||||
assets.source_vertex_of_render
|
assets.source_vertex_of_render
|
||||||
)
|
)
|
||||||
|
if assets.surface_mapping is not None:
|
||||||
|
mapping = assets.surface_mapping
|
||||||
|
arrays["sensing/surface_positions.npy"] = np.ascontiguousarray(
|
||||||
|
mapping.positions_sensor_m
|
||||||
|
)
|
||||||
|
arrays["sensing/surface_normals.npy"] = np.ascontiguousarray(
|
||||||
|
mapping.normals_sensor
|
||||||
|
)
|
||||||
|
arrays["sensing/surface_valid.npy"] = np.ascontiguousarray(
|
||||||
|
np.asarray(mapping.valid, dtype=np.int32)
|
||||||
|
)
|
||||||
for part in assets.parts:
|
for part in assets.parts:
|
||||||
if part.role == "housing":
|
if part.role == "housing":
|
||||||
arrays["housing/positions.npy"] = np.ascontiguousarray(part.positions_sensor_m)
|
arrays["housing/positions.npy"] = np.ascontiguousarray(part.positions_sensor_m)
|
||||||
@@ -684,6 +700,19 @@ def scene_from_contents(
|
|||||||
recomputed=recomputed_binding[:16],
|
recomputed=recomputed_binding[:16],
|
||||||
)
|
)
|
||||||
source = arrays.get("sensing/source_vertex.npy")
|
source = arrays.get("sensing/source_vertex.npy")
|
||||||
|
surface_pos = arrays.get("sensing/surface_positions.npy")
|
||||||
|
surface_nrm = arrays.get("sensing/surface_normals.npy")
|
||||||
|
surface_valid = arrays.get("sensing/surface_valid.npy")
|
||||||
|
surface_mapping = None
|
||||||
|
if surface_pos is not None and surface_nrm is not None and surface_valid is not None:
|
||||||
|
from orisys_app_shared.scene3d.contracts import SurfaceMapping
|
||||||
|
|
||||||
|
surface_mapping = SurfaceMapping(
|
||||||
|
shape_hw=(shape[0], shape[1]),
|
||||||
|
positions_sensor_m=freeze(surface_pos, dtype=np.float32),
|
||||||
|
normals_sensor=freeze(surface_nrm, dtype=np.float32),
|
||||||
|
valid=freeze(np.asarray(surface_valid, dtype=bool), dtype=np.bool_),
|
||||||
|
)
|
||||||
compatible = dict(manifest.get("compatible") or {})
|
compatible = dict(manifest.get("compatible") or {})
|
||||||
source_geometry = dict(manifest.get("source_geometry") or {})
|
source_geometry = dict(manifest.get("source_geometry") or {})
|
||||||
assets = SceneAssets(
|
assets = SceneAssets(
|
||||||
@@ -719,6 +748,7 @@ def scene_from_contents(
|
|||||||
compatible_hardware_revisions=tuple(
|
compatible_hardware_revisions=tuple(
|
||||||
str(item) for item in (compatible.get("hardware_revisions") or ())
|
str(item) for item in (compatible.get("hardware_revisions") or ())
|
||||||
),
|
),
|
||||||
|
surface_mapping=surface_mapping,
|
||||||
)
|
)
|
||||||
validate_assets(assets, expected)
|
validate_assets(assets, expected)
|
||||||
return assets
|
return assets
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
MeshPart,
|
MeshPart,
|
||||||
SceneAssets,
|
SceneAssets,
|
||||||
SurfaceBinding,
|
SurfaceBinding,
|
||||||
|
SurfaceMapping,
|
||||||
freeze,
|
freeze,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
|
from orisys_app_shared.scene3d.digest import field_domain_id, geometry_id
|
||||||
@@ -130,6 +131,19 @@ def _binding() -> SurfaceBinding:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_mapping(plate: MeshPart) -> SurfaceMapping:
|
||||||
|
"""Flat UV→scene mapping aligned with the synthetic plate grid."""
|
||||||
|
positions = np.asarray(plate.positions_sensor_m, dtype=np.float32).reshape(GRID, GRID, 3)
|
||||||
|
normals = np.asarray(plate.normals_sensor, dtype=np.float32).reshape(GRID, GRID, 3)
|
||||||
|
valid = np.ones((GRID, GRID), dtype=bool)
|
||||||
|
return SurfaceMapping(
|
||||||
|
shape_hw=(GRID, GRID),
|
||||||
|
positions_sensor_m=freeze(positions, dtype=np.float32),
|
||||||
|
normals_sensor=freeze(normals, dtype=np.float32),
|
||||||
|
valid=freeze(valid, dtype=np.bool_),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def poc_scene() -> SceneAssets:
|
def poc_scene() -> SceneAssets:
|
||||||
"""The synthetic scene: one sensing plate, one housing box, one preset."""
|
"""The synthetic scene: one sensing plate, one housing box, one preset."""
|
||||||
plate = _plate()
|
plate = _plate()
|
||||||
@@ -166,6 +180,7 @@ def poc_scene() -> SceneAssets:
|
|||||||
),
|
),
|
||||||
field_domain_id=POC_DOMAIN_ID,
|
field_domain_id=POC_DOMAIN_ID,
|
||||||
shape_hw=(GRID, GRID),
|
shape_hw=(GRID, GRID),
|
||||||
|
surface_mapping=_surface_mapping(plate),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Shared visual policy for 2D/3D flow sampling and domain masks.
|
||||||
|
|
||||||
|
No Qt, no GL, no SDK: both the arrow canvas and the 3D overlays consume the same
|
||||||
|
sample points and thresholds so "arrows respond / depth does not" stays a
|
||||||
|
measurement difference rather than a display bug.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d.contracts import Diagnostic, Mask, diagnostic, error, freeze
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VisualPolicy:
|
||||||
|
"""Units are explicit: flow thresholds are pixels; depth thresholds are relative."""
|
||||||
|
|
||||||
|
flow_on_px: float = 0.4
|
||||||
|
flow_off_px: float = 0.3
|
||||||
|
flow_grid_spacing: int = 10
|
||||||
|
flow_arrow_scale: float = 1.0
|
||||||
|
flow_edge_margin: int = 8
|
||||||
|
flow_length_margin: float = 0.8
|
||||||
|
max_arrows: int = 400
|
||||||
|
depth_on: float = 0.0
|
||||||
|
depth_off: float = 0.0
|
||||||
|
#: Inside / outside colours for the pure-color 2D canvas (BGR).
|
||||||
|
island_bgr: tuple[int, int, int] = (0x16, 0x13, 0x12)
|
||||||
|
exterior_bgr: tuple[int, int, int] = (0x0A, 0x09, 0x08)
|
||||||
|
|
||||||
|
def normalised(self) -> "VisualPolicy":
|
||||||
|
on = max(0.0, float(self.flow_on_px))
|
||||||
|
off = max(0.0, float(self.flow_off_px))
|
||||||
|
if on < off:
|
||||||
|
on, off = off, on
|
||||||
|
spacing = max(1, int(self.flow_grid_spacing))
|
||||||
|
return VisualPolicy(
|
||||||
|
flow_on_px=on,
|
||||||
|
flow_off_px=off,
|
||||||
|
flow_grid_spacing=spacing,
|
||||||
|
flow_arrow_scale=max(1e-9, float(self.flow_arrow_scale)),
|
||||||
|
flow_edge_margin=max(0, int(self.flow_edge_margin)),
|
||||||
|
flow_length_margin=max(0.05, float(self.flow_length_margin)),
|
||||||
|
max_arrows=max(1, int(self.max_arrows)),
|
||||||
|
depth_on=float(self.depth_on),
|
||||||
|
depth_off=float(self.depth_off),
|
||||||
|
island_bgr=tuple(int(v) for v in self.island_bgr),
|
||||||
|
exterior_bgr=tuple(int(v) for v in self.exterior_bgr),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FlowSamples:
|
||||||
|
"""Shared UV sample points for 2D and 3D flow arrows."""
|
||||||
|
|
||||||
|
rows: np.ndarray # [K] int
|
||||||
|
cols: np.ndarray # [K] int
|
||||||
|
dx: np.ndarray # [K] float — tip offset in UV pixels
|
||||||
|
dy: np.ndarray # [K] float
|
||||||
|
magnitude: np.ndarray # [K] float — |2*flow|
|
||||||
|
|
||||||
|
|
||||||
|
def build_domain_mask(
|
||||||
|
geometry_mask: Optional[np.ndarray],
|
||||||
|
texture_roi: Optional[np.ndarray] = None,
|
||||||
|
*,
|
||||||
|
shape_hw: Optional[tuple[int, int]] = None,
|
||||||
|
) -> tuple[Optional[Mask], tuple[Diagnostic, ...]]:
|
||||||
|
"""Strict intersection of geometry and optional texture ROI.
|
||||||
|
|
||||||
|
Shape mismatches raise rather than silently resize: a wrong mask invents
|
||||||
|
valid samples or erases the finger outline.
|
||||||
|
"""
|
||||||
|
if geometry_mask is None and texture_roi is None:
|
||||||
|
return None, ()
|
||||||
|
diagnostics: list[Diagnostic] = []
|
||||||
|
geo = None if geometry_mask is None else np.asarray(geometry_mask, dtype=bool)
|
||||||
|
roi = None if texture_roi is None else np.asarray(texture_roi, dtype=bool)
|
||||||
|
if shape_hw is not None:
|
||||||
|
expected = (int(shape_hw[0]), int(shape_hw[1]))
|
||||||
|
for name, array in (("geometry_mask", geo), ("texture_roi", roi)):
|
||||||
|
if array is None:
|
||||||
|
continue
|
||||||
|
if tuple(array.shape[:2]) != expected:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
f"{name} shape {tuple(array.shape[:2])} does not match "
|
||||||
|
f"domain {expected[0]}x{expected[1]}",
|
||||||
|
)
|
||||||
|
if geo is not None and roi is not None:
|
||||||
|
if tuple(geo.shape[:2]) != tuple(roi.shape[:2]):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"geometry_mask and texture_roi describe different grids",
|
||||||
|
geometry=f"{geo.shape[0]}x{geo.shape[1]}",
|
||||||
|
texture=f"{roi.shape[0]}x{roi.shape[1]}",
|
||||||
|
)
|
||||||
|
mask = geo & roi
|
||||||
|
elif geo is not None:
|
||||||
|
mask = geo
|
||||||
|
else:
|
||||||
|
mask = roi
|
||||||
|
diagnostics.append(
|
||||||
|
diagnostic(
|
||||||
|
"geometry_unverified",
|
||||||
|
"display_mask uses texture_roi alone; no geometry_mask was given",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return freeze(mask, dtype=np.bool_), tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def select_flow_samples(
|
||||||
|
flow: np.ndarray,
|
||||||
|
mask: Optional[np.ndarray],
|
||||||
|
policy: VisualPolicy,
|
||||||
|
*,
|
||||||
|
previously_on: Optional[np.ndarray] = None,
|
||||||
|
) -> FlowSamples:
|
||||||
|
"""Sample optical flow on a UV grid with hysteresis and mask clipping.
|
||||||
|
|
||||||
|
``previously_on`` is a boolean grid of the same shape as ``flow`` (or flat
|
||||||
|
over the candidate points) used for on/off hysteresis; pass ``None`` to use
|
||||||
|
``flow_on_px`` only (stateless).
|
||||||
|
"""
|
||||||
|
policy = policy.normalised()
|
||||||
|
flow = np.asarray(flow, dtype=np.float64)
|
||||||
|
if flow.ndim != 3 or flow.shape[-1] != 2:
|
||||||
|
raise error("field_domain_mismatch", "flow must be [H,W,2]")
|
||||||
|
h, w = int(flow.shape[0]), int(flow.shape[1])
|
||||||
|
spacing = policy.flow_grid_spacing
|
||||||
|
# Never erase a small domain: an edge larger than half the short side leaves
|
||||||
|
# no legal sample points (the synthetic 3×3 PoC is the extreme case).
|
||||||
|
edge = min(int(policy.flow_edge_margin), max(0, (min(h, w) - 1) // 2))
|
||||||
|
rr = np.arange(spacing // 2, h, spacing, dtype=np.int32)
|
||||||
|
cc = np.arange(spacing // 2, w, spacing, dtype=np.int32)
|
||||||
|
rr, cc = np.meshgrid(rr, cc, indexing="ij")
|
||||||
|
rr = rr.reshape(-1)
|
||||||
|
cc = cc.reshape(-1)
|
||||||
|
|
||||||
|
disp = 2.0 * flow[rr, cc, :]
|
||||||
|
mag = np.linalg.norm(disp, axis=1)
|
||||||
|
arrow_scale = policy.flow_arrow_scale
|
||||||
|
dx = disp[:, 0] / arrow_scale
|
||||||
|
dy = disp[:, 1] / arrow_scale
|
||||||
|
length = np.sqrt(dx * dx + dy * dy)
|
||||||
|
margin = policy.flow_length_margin
|
||||||
|
scale = np.where(
|
||||||
|
length > spacing * margin,
|
||||||
|
spacing / np.maximum(length, 1e-9),
|
||||||
|
1.0,
|
||||||
|
) * margin
|
||||||
|
dx = dx * scale
|
||||||
|
dy = dy * scale
|
||||||
|
end_x = np.clip(np.round(cc + dx).astype(np.int32), 0, w - 1)
|
||||||
|
end_y = np.clip(np.round(rr + dy).astype(np.int32), 0, h - 1)
|
||||||
|
|
||||||
|
threshold = policy.flow_on_px
|
||||||
|
if previously_on is not None:
|
||||||
|
prev = np.asarray(previously_on, dtype=bool)
|
||||||
|
if prev.shape == (h, w):
|
||||||
|
was_on = prev[rr, cc]
|
||||||
|
threshold = np.where(was_on, policy.flow_off_px, policy.flow_on_px)
|
||||||
|
|
||||||
|
valid = (
|
||||||
|
(cc >= edge)
|
||||||
|
& (cc < w - edge)
|
||||||
|
& (rr >= edge)
|
||||||
|
& (rr < h - edge)
|
||||||
|
& (mag >= threshold)
|
||||||
|
& np.isfinite(mag)
|
||||||
|
)
|
||||||
|
if mask is not None:
|
||||||
|
m = np.asarray(mask, dtype=bool)
|
||||||
|
if tuple(m.shape[:2]) != (h, w):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"flow mask shape does not match the flow field",
|
||||||
|
mask=f"{m.shape[0]}x{m.shape[1]}",
|
||||||
|
flow=f"{h}x{w}",
|
||||||
|
)
|
||||||
|
valid &= m[rr, cc]
|
||||||
|
valid &= m[end_y, end_x]
|
||||||
|
|
||||||
|
if not np.any(valid):
|
||||||
|
empty = np.empty(0, dtype=np.int32)
|
||||||
|
empty_f = np.empty(0, dtype=np.float64)
|
||||||
|
return FlowSamples(rows=empty, cols=empty, dx=empty_f, dy=empty_f, magnitude=empty_f)
|
||||||
|
|
||||||
|
idx = np.flatnonzero(valid)
|
||||||
|
if idx.size > policy.max_arrows:
|
||||||
|
# Keep the strongest magnitudes when the grid is dense.
|
||||||
|
order = np.argsort(mag[idx])[::-1][: policy.max_arrows]
|
||||||
|
idx = idx[order]
|
||||||
|
|
||||||
|
return FlowSamples(
|
||||||
|
rows=rr[idx].astype(np.int32, copy=False),
|
||||||
|
cols=cc[idx].astype(np.int32, copy=False),
|
||||||
|
dx=dx[idx].astype(np.float64, copy=False),
|
||||||
|
dy=dy[idx].astype(np.float64, copy=False),
|
||||||
|
magnitude=mag[idx].astype(np.float64, copy=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def paint_domain_background(
|
||||||
|
shape_hw: tuple[int, int],
|
||||||
|
mask: Optional[np.ndarray],
|
||||||
|
*,
|
||||||
|
island_bgr: tuple[int, int, int],
|
||||||
|
exterior_bgr: tuple[int, int, int],
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""BGR canvas with distinct colours inside / outside the UV island."""
|
||||||
|
h, w = int(shape_hw[0]), int(shape_hw[1])
|
||||||
|
canvas = np.empty((h, w, 3), dtype=np.uint8)
|
||||||
|
canvas[:, :] = tuple(int(v) for v in exterior_bgr)
|
||||||
|
if mask is None:
|
||||||
|
canvas[:, :] = tuple(int(v) for v in island_bgr)
|
||||||
|
return canvas
|
||||||
|
m = np.asarray(mask, dtype=bool)
|
||||||
|
if tuple(m.shape[:2]) != (h, w):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"background mask shape does not match the canvas",
|
||||||
|
mask=f"{m.shape[0]}x{m.shape[1]}",
|
||||||
|
canvas=f"{h}x{w}",
|
||||||
|
)
|
||||||
|
canvas[m] = tuple(int(v) for v in island_bgr)
|
||||||
|
return canvas
|
||||||
@@ -46,6 +46,8 @@
|
|||||||
"brand_bar_bg": "#12315D",
|
"brand_bar_bg": "#12315D",
|
||||||
"brand_bar_fg": "#FFFFFF",
|
"brand_bar_fg": "#FFFFFF",
|
||||||
"brand_bar_fg_muted": "#B9C8E4",
|
"brand_bar_fg_muted": "#B9C8E4",
|
||||||
|
"status_bar_bg": "#0B1017",
|
||||||
|
"status_bar_text": "#B9C2CF",
|
||||||
"arrow_canvas": "#0B1830",
|
"arrow_canvas": "#0B1830",
|
||||||
"flow_ramp": "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
"flow_ramp": "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
"brand_bar_bg": "#12315D",
|
"brand_bar_bg": "#12315D",
|
||||||
"brand_bar_fg": "#FFFFFF",
|
"brand_bar_fg": "#FFFFFF",
|
||||||
"brand_bar_fg_muted": "#B9C8E4",
|
"brand_bar_fg_muted": "#B9C8E4",
|
||||||
|
"status_bar_bg": "#EDF1F8",
|
||||||
|
"status_bar_text": "#141A24",
|
||||||
"arrow_canvas": "#12315D",
|
"arrow_canvas": "#12315D",
|
||||||
"flow_ramp": "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
"flow_ramp": "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Typed, side-effect-free client for the ZMotion ZMC controller DLLs.
|
||||||
|
|
||||||
|
Only the calls the AutoCal platform control needs are wrapped here, rather than
|
||||||
|
copying the vendor's 5381-line ``zmcdll/zauxdllPython.py``. The vendor wrapper
|
||||||
|
is transcribed method by method -- its Chinese docstrings are kept because they
|
||||||
|
document the parameter contract (the English reference for the same calls would
|
||||||
|
be the ``ZAUXDLL``/``ZAux_Direct_*`` API documentation).
|
||||||
|
|
||||||
|
Differences from the vendor file that matter:
|
||||||
|
|
||||||
|
* **No import side effects.** Importing this package does not probe, load, or
|
||||||
|
``print()`` anything about the native library. The DLL is resolved and loaded
|
||||||
|
on first :func:`load_dll`, so a machine with no controller still imports the
|
||||||
|
whole app.
|
||||||
|
* **No path guessing at import time.** See :mod:`.loader`.
|
||||||
|
* **Errors are values, not tuples.** Every wrapper returns a
|
||||||
|
:class:`ZmotionResult` (or :class:`ZmotionValue` for reads), so a caller can
|
||||||
|
never silently treat a failed read's zero as data.
|
||||||
|
|
||||||
|
No Qt binding is imported here, deliberately: this module is usable from a
|
||||||
|
headless test process and from either binding.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .client import (
|
||||||
|
ZAuxError,
|
||||||
|
ZmotionClient,
|
||||||
|
ZmotionDllNotFound,
|
||||||
|
ZmotionResult,
|
||||||
|
ZmotionValue,
|
||||||
|
)
|
||||||
|
from .loader import ZAUXDLL_DLL, ZMOTION_DLL, load_dll, resolve_dll_dir
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ZAUXDLL_DLL",
|
||||||
|
"ZMOTION_DLL",
|
||||||
|
"ZAuxError",
|
||||||
|
"ZmotionClient",
|
||||||
|
"ZmotionDllNotFound",
|
||||||
|
"ZmotionResult",
|
||||||
|
"ZmotionValue",
|
||||||
|
"load_dll",
|
||||||
|
"resolve_dll_dir",
|
||||||
|
]
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"""Typed wrappers around the ZMotion ``ZAux_*`` controller API.
|
||||||
|
|
||||||
|
Every wrapper returns :class:`ZmotionResult` (commands) or :class:`ZmotionValue`
|
||||||
|
(reads) instead of the vendor's ``(code, ctypes_value)`` pairs, so a failed read
|
||||||
|
can never be mistaken for data. The ~25 calls here are exactly the ones the
|
||||||
|
AutoCal platform control uses; see ``ZAUXDLL2.h``
|
||||||
|
(``shared/resources/zmotion/x64/``) for the underlying contract.
|
||||||
|
|
||||||
|
Loading is lazy: constructing :class:`ZmotionClient` does not touch the native
|
||||||
|
library, so importing the app on a machine with no controller (or no DLL at all)
|
||||||
|
is always safe. Call :meth:`ZmotionClient.load` explicitly, or let
|
||||||
|
:meth:`ZmotionClient.connect` do it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any # noqa: F401 (kept for ZmotionValue.value's annotation)
|
||||||
|
|
||||||
|
from .loader import ZmotionDllNotFound, load_dll, resolve_dll_dir
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ZAuxError",
|
||||||
|
"ZmotionClient",
|
||||||
|
"ZmotionDllNotFound",
|
||||||
|
"ZmotionResult",
|
||||||
|
"ZmotionValue",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ZAuxError(RuntimeError):
|
||||||
|
"""A ``ZAux_*`` call returned a non-zero code.
|
||||||
|
|
||||||
|
Carries the numeric code so callers can map it to a localised message
|
||||||
|
without re-parsing a string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code: int, detail: str = "") -> None:
|
||||||
|
self.code = int(code)
|
||||||
|
self.detail = detail
|
||||||
|
message = f"ZMotion error {self.code}"
|
||||||
|
if detail:
|
||||||
|
message = f"{message}: {detail}"
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZmotionResult:
|
||||||
|
"""Outcome of a command that returns only a status code."""
|
||||||
|
|
||||||
|
code: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.code == 0
|
||||||
|
|
||||||
|
def raise_for_status(self, detail: str = "") -> "ZmotionResult":
|
||||||
|
if not self.ok:
|
||||||
|
raise ZAuxError(self.code, detail)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZmotionValue:
|
||||||
|
"""Outcome of a read: a status code plus the value it produced."""
|
||||||
|
|
||||||
|
code: int
|
||||||
|
value: Any
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.code == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value_or_none(self) -> Any | None:
|
||||||
|
"""The value when the read succeeded, otherwise ``None``.
|
||||||
|
|
||||||
|
Callers that want a default must say so themselves; silently using a
|
||||||
|
failed read's zero is what makes a wrong number look like a
|
||||||
|
measurement.
|
||||||
|
"""
|
||||||
|
return self.value if self.ok else None
|
||||||
|
|
||||||
|
def raise_for_status(self, detail: str = "") -> "ZmotionValue":
|
||||||
|
if not self.ok:
|
||||||
|
raise ZAuxError(self.code, detail)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ZmotionClient:
|
||||||
|
"""The controller connection, one instance per process.
|
||||||
|
|
||||||
|
Not thread-safe: the underlying DLL handle is shared state. AutoCal owns one
|
||||||
|
client inside a single poller thread (see
|
||||||
|
``orisys_autocal.motion.poller``) and never calls it from two threads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._dll: ctypes.CDLL | None = None
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
self._dll_dir: str | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded(self) -> bool:
|
||||||
|
return self._dll is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._handle.value is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dll_dir(self) -> str | None:
|
||||||
|
"""Directory the runtime was loaded from, for logs and About boxes."""
|
||||||
|
return self._dll_dir
|
||||||
|
|
||||||
|
def load(self) -> "ZmotionClient":
|
||||||
|
"""Resolve and load the native runtime. Idempotent."""
|
||||||
|
if self._dll is None:
|
||||||
|
resolved = resolve_dll_dir()
|
||||||
|
self._dll = load_dll(resolved)
|
||||||
|
self._dll_dir = str(resolved)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _require_handle(self) -> ctypes.c_void_p:
|
||||||
|
if self._handle.value is None:
|
||||||
|
raise ZAuxError(-2, "no controller connection is open")
|
||||||
|
return self._handle
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ connection
|
||||||
|
|
||||||
|
def open_eth(self, ip: str) -> ZmotionResult:
|
||||||
|
"""Connect to a controller by IPv4 address (string form)."""
|
||||||
|
self.load()
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
ret = self._dll.ZAux_OpenEth( # type: ignore[union-attr]
|
||||||
|
ip.encode("utf-8"), ctypes.pointer(self._handle)
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def open_com(self, com_id: int) -> ZmotionResult:
|
||||||
|
"""Connect over a serial port by index."""
|
||||||
|
self.load()
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
ret = self._dll.ZAux_OpenCom( # type: ignore[union-attr]
|
||||||
|
ctypes.c_int(com_id), ctypes.pointer(self._handle)
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def open_pci(self, card_num: int) -> ZmotionResult:
|
||||||
|
"""Connect to a PCI/PCIe card by card index."""
|
||||||
|
self.load()
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
ret = self._dll.ZAux_OpenPci( # type: ignore[union-attr]
|
||||||
|
ctypes.c_int(card_num), ctypes.pointer(self._handle)
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def fast_open(self, conn_type: int, connect_string: str, timeout_ms: int) -> ZmotionResult:
|
||||||
|
"""Open a local/simulated controller (``ZAux_FastOpen``)."""
|
||||||
|
self.load()
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
ret = self._dll.ZAux_FastOpen( # type: ignore[union-attr]
|
||||||
|
ctypes.c_int(conn_type),
|
||||||
|
connect_string.encode("utf-8"),
|
||||||
|
ctypes.c_uint32(timeout_ms),
|
||||||
|
ctypes.pointer(self._handle),
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def close(self) -> ZmotionResult:
|
||||||
|
"""Close the connection. Safe to call when nothing is open."""
|
||||||
|
if self._dll is None or self._handle.value is None:
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
return ZmotionResult(0)
|
||||||
|
ret = self._dll.ZAux_Close(self._handle) # type: ignore[union-attr]
|
||||||
|
self._handle = ctypes.c_void_p()
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def search_eth(self, buffer_length: int = 10240, timeout_ms: int = 200) -> ZmotionValue:
|
||||||
|
"""Scan the local network segment for controllers.
|
||||||
|
|
||||||
|
Returns the whitespace-separated address list as text. Does not need an
|
||||||
|
open connection.
|
||||||
|
"""
|
||||||
|
self.load()
|
||||||
|
buffer = (ctypes.c_char * int(buffer_length))()
|
||||||
|
ret = self._dll.ZAux_SearchEthlist( # type: ignore[union-attr]
|
||||||
|
buffer, ctypes.c_uint32(buffer_length), ctypes.c_uint32(timeout_ms)
|
||||||
|
)
|
||||||
|
text = buffer.value.decode("utf-8", errors="ignore") if buffer.value else ""
|
||||||
|
return ZmotionValue(int(ret), text.strip())
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- BAS / command
|
||||||
|
|
||||||
|
def bas_download(self, filename: str, run_mode: int = 1) -> ZmotionResult:
|
||||||
|
"""Download a ``.bas`` program to the controller ROM.
|
||||||
|
|
||||||
|
``run_mode=1`` downloads to ROM, which makes the controller run the
|
||||||
|
program's task after the configured delay (see ``ECAT.bas``).
|
||||||
|
"""
|
||||||
|
ret = self._dll.ZAux_BasDown( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), filename.encode("utf-8"), ctypes.c_uint32(run_mode)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def execute(self, command: str) -> ZmotionValue:
|
||||||
|
"""Run an online command string (e.g. ``RUN "ECAT.bas",4``)."""
|
||||||
|
ret = self._dll.ZAux_Execute( # type: ignore[union-attr]
|
||||||
|
self._require_handle(),
|
||||||
|
command.encode("utf-8"),
|
||||||
|
None,
|
||||||
|
ctypes.c_uint32(0),
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), command)
|
||||||
|
|
||||||
|
def get_user_var(self, name: str) -> ZmotionValue:
|
||||||
|
"""Read a global variable from the running ``.bas`` program.
|
||||||
|
|
||||||
|
The vendor wrapper returns a ``c_float`` here while other reads return a
|
||||||
|
plain float; both are normalised to a Python float.
|
||||||
|
"""
|
||||||
|
value = ctypes.c_float()
|
||||||
|
ret = self._dll.ZAux_Direct_GetUserVar( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), name.encode("utf-8"), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), float(value.value))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- bus state
|
||||||
|
|
||||||
|
def bus_node_count(self, slot: int = 0) -> ZmotionValue:
|
||||||
|
"""Number of slave nodes the controller found on the bus."""
|
||||||
|
value = ctypes.c_uint32()
|
||||||
|
ret = self._dll.ZAux_BusCmd_GetNodeNum( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(slot), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), int(value.value))
|
||||||
|
|
||||||
|
def bus_home_status(self, axis: int) -> ZmotionValue:
|
||||||
|
"""Driver homing status: 1 once homing has completed."""
|
||||||
|
value = ctypes.c_int()
|
||||||
|
ret = self._dll.ZAux_BusCmd_GetHomeStatus( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), int(value.value))
|
||||||
|
|
||||||
|
def bus_datum(self, axis: int, home_mode: int) -> ZmotionResult:
|
||||||
|
"""Start driver homing with a CiA-402 homing method."""
|
||||||
|
ret = self._dll.ZAux_BusCmd_Datum( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(home_mode)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def bus_set_datum_offset(self, axis: int, value: float) -> ZmotionResult:
|
||||||
|
"""Set the homing offset (machine units)."""
|
||||||
|
ret = self._dll.ZAux_BusCmd_SetDatumOffpos( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def bus_drive_clear(self, axis: int, mode: int = 0) -> ZmotionResult:
|
||||||
|
"""Clear a drive fault/alert."""
|
||||||
|
ret = self._dll.ZAux_BusCmd_DriveClear( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(mode)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def sdo_read(
|
||||||
|
self, slot: int, node: int, index: int, subindex: int, data_type: int
|
||||||
|
) -> ZmotionValue:
|
||||||
|
"""Read an EtherCAT object-dictionary entry.
|
||||||
|
|
||||||
|
``data_type``: 1=int8, 2=int16, 3=int32, 4=uint8, 5=uint16, 6=uint32.
|
||||||
|
"""
|
||||||
|
value = ctypes.c_int()
|
||||||
|
ret = self._dll.ZAux_BusCmd_SDORead( # type: ignore[union-attr]
|
||||||
|
self._require_handle(),
|
||||||
|
ctypes.c_int(slot),
|
||||||
|
ctypes.c_int(node),
|
||||||
|
ctypes.c_int(index),
|
||||||
|
ctypes.c_int(subindex),
|
||||||
|
ctypes.c_int(data_type),
|
||||||
|
ctypes.byref(value),
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), int(value.value))
|
||||||
|
|
||||||
|
def sdo_write(
|
||||||
|
self,
|
||||||
|
slot: int,
|
||||||
|
node: int,
|
||||||
|
index: int,
|
||||||
|
subindex: int,
|
||||||
|
data_type: int,
|
||||||
|
value: int,
|
||||||
|
) -> ZmotionResult:
|
||||||
|
"""Write an EtherCAT object-dictionary entry. See :meth:`sdo_read`."""
|
||||||
|
ret = self._dll.ZAux_BusCmd_SDOWrite( # type: ignore[union-attr]
|
||||||
|
self._require_handle(),
|
||||||
|
ctypes.c_int(slot),
|
||||||
|
ctypes.c_int(node),
|
||||||
|
ctypes.c_int(index),
|
||||||
|
ctypes.c_int(subindex),
|
||||||
|
ctypes.c_int(data_type),
|
||||||
|
ctypes.c_int(value),
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def rtex_read(self, axis: int, parameter: int) -> ZmotionValue:
|
||||||
|
"""Read an RTEX drive parameter."""
|
||||||
|
value = ctypes.c_float()
|
||||||
|
ret = self._dll.ZAux_BusCmd_RtexRead( # type: ignore[union-attr]
|
||||||
|
self._require_handle(),
|
||||||
|
ctypes.c_int(axis),
|
||||||
|
ctypes.c_int(parameter),
|
||||||
|
ctypes.byref(value),
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), float(value.value))
|
||||||
|
|
||||||
|
def rtex_write(self, axis: int, parameter: int, value: float) -> ZmotionResult:
|
||||||
|
"""Write an RTEX drive parameter."""
|
||||||
|
ret = self._dll.ZAux_BusCmd_RtexWrite( # type: ignore[union-attr]
|
||||||
|
self._require_handle(),
|
||||||
|
ctypes.c_int(axis),
|
||||||
|
ctypes.c_int(parameter),
|
||||||
|
ctypes.c_float(value),
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- axis
|
||||||
|
|
||||||
|
def set_axis_enable(self, axis: int, value: int) -> ZmotionResult:
|
||||||
|
"""Servo enable (1) / disable (0) for one axis."""
|
||||||
|
ret = self._dll.ZAux_Direct_SetAxisEnable( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(value)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def get_axis_enable(self, axis: int) -> ZmotionValue:
|
||||||
|
value = ctypes.c_int()
|
||||||
|
ret = self._dll.ZAux_Direct_GetAxisEnable( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), int(value.value))
|
||||||
|
|
||||||
|
def set_units(self, axis: int, value: float) -> ZmotionResult:
|
||||||
|
"""Pulse equivalent (user units per pulse)."""
|
||||||
|
ret = self._dll.ZAux_Direct_SetUnits( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def get_units(self, axis: int) -> ZmotionValue:
|
||||||
|
value = ctypes.c_float()
|
||||||
|
ret = self._dll.ZAux_Direct_GetUnits( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), float(value.value))
|
||||||
|
|
||||||
|
def set_speed(self, axis: int, value: float) -> ZmotionResult:
|
||||||
|
ret = self._dll.ZAux_Direct_SetSpeed( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def get_speed(self, axis: int) -> ZmotionValue:
|
||||||
|
value = ctypes.c_float()
|
||||||
|
ret = self._dll.ZAux_Direct_GetSpeed( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), float(value.value))
|
||||||
|
|
||||||
|
def set_accel(self, axis: int, value: float) -> ZmotionResult:
|
||||||
|
ret = self._dll.ZAux_Direct_SetAccel( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def get_accel(self, axis: int) -> ZmotionValue:
|
||||||
|
value = ctypes.c_float()
|
||||||
|
ret = self._dll.ZAux_Direct_GetAccel( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), float(value.value))
|
||||||
|
|
||||||
|
def set_creep(self, axis: int, value: float) -> ZmotionResult:
|
||||||
|
"""Creep (slow homing) speed."""
|
||||||
|
ret = self._dll.ZAux_Direct_SetCreep( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(value)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def get_atype(self, axis: int) -> ZmotionValue:
|
||||||
|
"""Axis type (65=position, 66=speed, 67=torque)."""
|
||||||
|
value = ctypes.c_int()
|
||||||
|
ret = self._dll.ZAux_Direct_GetAtype( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), int(value.value))
|
||||||
|
|
||||||
|
def get_dpos(self, axis: int) -> ZmotionValue:
|
||||||
|
"""Commanded position, in user units."""
|
||||||
|
value = ctypes.c_float()
|
||||||
|
ret = self._dll.ZAux_Direct_GetDpos( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), float(value.value))
|
||||||
|
|
||||||
|
def get_mpos(self, axis: int) -> ZmotionValue:
|
||||||
|
"""Measured (feedback) position, in user units."""
|
||||||
|
value = ctypes.c_float()
|
||||||
|
ret = self._dll.ZAux_Direct_GetMpos( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), float(value.value))
|
||||||
|
|
||||||
|
def get_axis_status(self, axis: int) -> ZmotionValue:
|
||||||
|
"""Axis status word. Bit 6 is the homing-in-progress bit."""
|
||||||
|
value = ctypes.c_int()
|
||||||
|
ret = self._dll.ZAux_Direct_GetAxisStatus( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), int(value.value))
|
||||||
|
|
||||||
|
def get_if_idle(self, axis: int) -> ZmotionValue:
|
||||||
|
"""0 while the axis is moving, -1 when it has stopped."""
|
||||||
|
value = ctypes.c_int()
|
||||||
|
ret = self._dll.ZAux_Direct_GetIfIdle( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.byref(value)
|
||||||
|
)
|
||||||
|
return ZmotionValue(int(ret), int(value.value))
|
||||||
|
|
||||||
|
def single_vmove(self, axis: int, direction: int) -> ZmotionResult:
|
||||||
|
"""Continuous jog: ``direction`` 1 for forward, -1 for reverse."""
|
||||||
|
ret = self._dll.ZAux_Direct_Single_Vmove( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(direction)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def single_move(self, axis: int, distance: float) -> ZmotionResult:
|
||||||
|
"""Move by a distance relative to the current position."""
|
||||||
|
ret = self._dll.ZAux_Direct_Single_Move( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(distance)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def single_move_abs(self, axis: int, position: float) -> ZmotionResult:
|
||||||
|
"""Move to an absolute position."""
|
||||||
|
ret = self._dll.ZAux_Direct_Single_MoveAbs( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_float(position)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
|
|
||||||
|
def single_cancel(self, axis: int, mode: int = 2) -> ZmotionResult:
|
||||||
|
"""Stop the axis. ``mode=2`` is a decelerating stop."""
|
||||||
|
ret = self._dll.ZAux_Direct_Single_Cancel( # type: ignore[union-attr]
|
||||||
|
self._require_handle(), ctypes.c_int(axis), ctypes.c_int(mode)
|
||||||
|
)
|
||||||
|
return ZmotionResult(int(ret))
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Resolve and load the vendored ZMotion runtime DLLs.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. ``ORISYS_ZMOTION_DLL_DIR`` — an explicit override, e.g. a newer
|
||||||
|
controller-support CD.
|
||||||
|
2. ``<exe dir>/_internal/zmotion/x64`` and ``<exe dir>/zmotion/x64`` — a frozen
|
||||||
|
(PyInstaller) bundle, matching how ``build/studio`` places branding.
|
||||||
|
3. ``shared/resources/zmotion/x64`` in the source tree — the vendored copy
|
||||||
|
(see ``shared/resources/zmotion/x64/README.md`` for provenance).
|
||||||
|
|
||||||
|
Nothing here runs at import time; :func:`load_dll` is called by the app once a
|
||||||
|
connection is actually requested.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ZAUXDLL_DLL = "zauxdll.dll"
|
||||||
|
ZMOTION_DLL = "zmotion.dll"
|
||||||
|
|
||||||
|
#: Environment variable that overrides the vendored DLL directory.
|
||||||
|
DLL_DIR_ENV = "ORISYS_ZMOTION_DLL_DIR"
|
||||||
|
|
||||||
|
|
||||||
|
class ZmotionDllNotFound(OSError):
|
||||||
|
"""The ZMotion runtime could not be located.
|
||||||
|
|
||||||
|
Carries every path that was searched, so the message can tell an operator
|
||||||
|
what to install or which environment variable to set instead of just
|
||||||
|
naming the missing file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, searched: list[Path]) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.searched = searched
|
||||||
|
listing = "\n".join(f" - {path}" for path in searched)
|
||||||
|
super().__init__(
|
||||||
|
f"{name} not found. Searched:\n{listing}\n"
|
||||||
|
f"Install the ZMotion runtime, or set {DLL_DIR_ENV} to the folder "
|
||||||
|
f"that contains it."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_tree_dir() -> Path:
|
||||||
|
# .../orisys-app/shared/src/orisys_app_shared/zmotion/loader.py -> shared/
|
||||||
|
return Path(__file__).resolve().parents[3] / "resources" / "zmotion" / "x64"
|
||||||
|
|
||||||
|
|
||||||
|
def _frozen_dirs() -> list[Path]:
|
||||||
|
if not getattr(sys, "frozen", False):
|
||||||
|
return []
|
||||||
|
exe_dir = Path(sys.executable).resolve().parent
|
||||||
|
return [exe_dir / "_internal" / "zmotion" / "x64", exe_dir / "zmotion" / "x64"]
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_dll_dirs() -> list[Path]:
|
||||||
|
"""Every directory that will be searched for the runtime DLLs, in order.
|
||||||
|
|
||||||
|
An explicit ``ORISYS_ZMOTION_DLL_DIR`` is authoritative: it is the only
|
||||||
|
candidate, so a typo fails loudly instead of quietly loading a different
|
||||||
|
build of the runtime than the operator asked for. On a motion controller,
|
||||||
|
silently using the wrong DLL version is exactly the kind of failure that
|
||||||
|
looks like a working machine until it moves.
|
||||||
|
"""
|
||||||
|
override = os.environ.get(DLL_DIR_ENV, "").strip()
|
||||||
|
if override:
|
||||||
|
return [Path(override).expanduser()]
|
||||||
|
|
||||||
|
candidates: list[Path] = []
|
||||||
|
candidates.extend(_frozen_dirs())
|
||||||
|
candidates.append(_source_tree_dir())
|
||||||
|
|
||||||
|
seen: set[Path] = set()
|
||||||
|
unique: list[Path] = []
|
||||||
|
for path in candidates:
|
||||||
|
resolved = path.resolve() if path.exists() else path
|
||||||
|
if resolved not in seen:
|
||||||
|
seen.add(resolved)
|
||||||
|
unique.append(resolved)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dll_dir() -> Path:
|
||||||
|
"""Return the first candidate directory that holds ``zauxdll.dll``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ZmotionDllNotFound: no candidate directory contains the runtime.
|
||||||
|
"""
|
||||||
|
searched = candidate_dll_dirs()
|
||||||
|
for path in searched:
|
||||||
|
if (path / ZAUXDLL_DLL).is_file():
|
||||||
|
return path
|
||||||
|
raise ZmotionDllNotFound(ZAUXDLL_DLL, searched)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_search_path(dll_dir: Path) -> None:
|
||||||
|
"""Make ``dll_dir`` available to the loader for dependent DLLs.
|
||||||
|
|
||||||
|
``zauxdll.dll`` links against ``zmotion.dll`` beside it, so the directory
|
||||||
|
must be on the search path *before* ``zauxdll.dll`` is loaded. Both the
|
||||||
|
process ``PATH`` and the loader's own DLL directory are set: the former is
|
||||||
|
what the vendor wrapper relied on, the latter is the documented Win32
|
||||||
|
mechanism.
|
||||||
|
"""
|
||||||
|
dll_dir_str = str(dll_dir)
|
||||||
|
path_entries = os.environ.get("PATH", "").split(os.pathsep)
|
||||||
|
if dll_dir_str not in path_entries:
|
||||||
|
os.environ["PATH"] = dll_dir_str + os.pathsep + os.environ.get("PATH", "")
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
kernel32.SetDllDirectoryW(ctypes.c_wchar_p(dll_dir_str))
|
||||||
|
except (OSError, AttributeError): # pragma: no cover - defensive
|
||||||
|
# Not fatal: the PATH entry above is usually enough, and the real
|
||||||
|
# failure (if any) surfaces as a clearer error from load_dll.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_dll(dll_dir: Path | None = None) -> ctypes.CDLL:
|
||||||
|
"""Load ``zauxdll.dll`` with its directory on the search path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dll_dir: directory to load from; resolved automatically when omitted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The loaded CDLL.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ZmotionDllNotFound: the runtime is not present.
|
||||||
|
OSError: the DLL is present but the platform loader rejected it.
|
||||||
|
"""
|
||||||
|
resolved = dll_dir or resolve_dll_dir()
|
||||||
|
dll_path = resolved / ZAUXDLL_DLL
|
||||||
|
if not dll_path.is_file():
|
||||||
|
raise ZmotionDllNotFound(ZAUXDLL_DLL, [resolved])
|
||||||
|
_prepare_search_path(resolved)
|
||||||
|
return ctypes.CDLL(str(dll_path))
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Display-only force baseline / deadband (value cards)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.force.presentation import ForcePresentation, ZeroEstimate
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_zero_rejects_insufficient_samples() -> None:
|
||||||
|
presentation = ForcePresentation(enabled=True, min_samples=30)
|
||||||
|
estimate = presentation.fit_zero([(1.0, 2.0, 3.0), (1.1, 2.1, 3.1)])
|
||||||
|
assert estimate.quality == "insufficient"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_zero_and_accept_deadband() -> None:
|
||||||
|
rng = np.random.default_rng(0)
|
||||||
|
bias = np.array([1262.0, -11.0, -353.0])
|
||||||
|
noise = rng.normal(0.0, 20.0, size=(80, 3))
|
||||||
|
samples = (bias + noise).tolist()
|
||||||
|
presentation = ForcePresentation(
|
||||||
|
enabled=True,
|
||||||
|
min_samples=30,
|
||||||
|
baseline_seconds=0.0,
|
||||||
|
min_deadband=50.0,
|
||||||
|
deadband_percentile=99.0,
|
||||||
|
)
|
||||||
|
estimate = presentation.fit_zero(samples)
|
||||||
|
assert estimate.quality == "ok"
|
||||||
|
presentation.apply_estimate(estimate)
|
||||||
|
idle = presentation.accept(tuple(bias), unit="raw")
|
||||||
|
assert idle.zero_zone
|
||||||
|
assert idle.values == (0.0, 0.0, 0.0)
|
||||||
|
# Curves / CSV keep the measurement: accept never mutates the input tuple.
|
||||||
|
measured = (1262.0, -11.0, -353.0)
|
||||||
|
presentation.accept(measured, unit="raw")
|
||||||
|
assert measured == (1262.0, -11.0, -353.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_skips_contact_contamination() -> None:
|
||||||
|
presentation = ForcePresentation(
|
||||||
|
enabled=True,
|
||||||
|
min_samples=5,
|
||||||
|
baseline_seconds=0.1,
|
||||||
|
require_no_contact=True,
|
||||||
|
)
|
||||||
|
for i in range(10):
|
||||||
|
out = presentation.observe(
|
||||||
|
(1000.0, 0.0, 0.0),
|
||||||
|
t_rel_s=i * 0.05,
|
||||||
|
contact_found=True,
|
||||||
|
flow_p95=0.1,
|
||||||
|
)
|
||||||
|
assert out is None
|
||||||
|
assert not presentation.ready
|
||||||
|
|
||||||
|
|
||||||
|
def test_observe_freezes_after_quiet_window() -> None:
|
||||||
|
presentation = ForcePresentation(
|
||||||
|
enabled=True,
|
||||||
|
min_samples=10,
|
||||||
|
baseline_seconds=0.5,
|
||||||
|
min_deadband=10.0,
|
||||||
|
)
|
||||||
|
estimate: ZeroEstimate | None = None
|
||||||
|
for i in range(40):
|
||||||
|
estimate = presentation.observe(
|
||||||
|
(100.0 + (i % 3), -5.0, 2.0),
|
||||||
|
t_rel_s=i * 0.02,
|
||||||
|
flow_p95=0.1,
|
||||||
|
contact_found=False,
|
||||||
|
)
|
||||||
|
if estimate is not None:
|
||||||
|
break
|
||||||
|
assert estimate is not None
|
||||||
|
assert estimate.quality == "ok"
|
||||||
|
assert presentation.ready
|
||||||
|
# Further observes do not chase a new zero once frozen.
|
||||||
|
later = presentation.observe((5000.0, 0.0, 0.0), t_rel_s=10.0, flow_p95=0.0)
|
||||||
|
assert later is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_force_stable_digits() -> None:
|
||||||
|
presentation = ForcePresentation(enabled=True)
|
||||||
|
assert presentation.format_force(0.0, unit="raw") == "0"
|
||||||
|
assert presentation.format_force(1262.4, unit="raw") == "1262"
|
||||||
|
assert presentation.format_force(1.2345, unit="N") == "1.234"
|
||||||
@@ -127,6 +127,7 @@ def test_the_backend_renders_what_the_primitives_rendered_before():
|
|||||||
background="captured" if mode == "arrows_stitched" else "pure_color",
|
background="captured" if mode == "arrows_stitched" else "pure_color",
|
||||||
draw_style="finger",
|
draw_style="finger",
|
||||||
valid_mask=mask,
|
valid_mask=mask,
|
||||||
|
colormap=flow_colormap("dark"),
|
||||||
)
|
)
|
||||||
got = backend.render_image(frame, plan, mode=mode, draw_style="finger")
|
got = backend.render_image(frame, plan, mode=mode, draw_style="finger")
|
||||||
assert np.array_equal(got, expected), mode
|
assert np.array_equal(got, expected), mode
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Pure-NumPy overlay geometry for Finger 3D arrows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d.overlays import (
|
||||||
|
arrow_instances_to_mesh,
|
||||||
|
build_flow_overlay,
|
||||||
|
build_normal_overlay,
|
||||||
|
force_visual_strength,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.scene3d.synthetic import poc_scene
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_visual_strength_is_capped() -> None:
|
||||||
|
assert force_visual_strength(0.0) == 0.0
|
||||||
|
strong = force_visual_strength(1e9)
|
||||||
|
mild = force_visual_strength(100.0)
|
||||||
|
assert strong >= mild > 0.0
|
||||||
|
assert strong <= 1.0 + 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_and_normal_overlays_on_poc_mapping() -> None:
|
||||||
|
assets = poc_scene()
|
||||||
|
mapping = assets.surface_mapping
|
||||||
|
assert mapping is not None
|
||||||
|
h, w = mapping.shape_hw
|
||||||
|
flow = np.zeros((h, w, 2), dtype=np.float64)
|
||||||
|
flow[1, 1] = (0.8, 0.0)
|
||||||
|
mask = np.asarray(mapping.valid, dtype=bool)
|
||||||
|
policy = VisualPolicy(
|
||||||
|
flow_on_px=0.4,
|
||||||
|
flow_grid_spacing=1,
|
||||||
|
flow_edge_margin=0,
|
||||||
|
max_arrows=50,
|
||||||
|
)
|
||||||
|
samples = select_flow_samples(flow, mask, policy)
|
||||||
|
arrows, diags = build_flow_overlay(samples, mapping, style=policy)
|
||||||
|
assert not diags
|
||||||
|
assert arrows is not None and arrows.origins.shape[0] >= 1
|
||||||
|
contact, cdiags = build_normal_overlay(
|
||||||
|
contact_uv_xy=(1.0, 1.0),
|
||||||
|
contact_found=True,
|
||||||
|
contact_valid=True,
|
||||||
|
fn=500.0,
|
||||||
|
mapping=mapping,
|
||||||
|
)
|
||||||
|
assert not cdiags
|
||||||
|
assert contact is not None and contact.origins.shape[0] == 1
|
||||||
|
# Yellow-ish contact colour.
|
||||||
|
assert float(contact.colors[0, 0]) > 0.7
|
||||||
|
assert float(contact.colors[0, 1]) > 0.7
|
||||||
|
|
||||||
|
positions, triangles, colors = arrow_instances_to_mesh(arrows)
|
||||||
|
assert positions.ndim == 2 and positions.shape[1] == 3
|
||||||
|
assert triangles.ndim == 2 and triangles.shape[1] == 3
|
||||||
|
assert colors.shape[0] == positions.shape[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_contact_yields_no_arrow() -> None:
|
||||||
|
assets = poc_scene()
|
||||||
|
mapping = assets.surface_mapping
|
||||||
|
assert mapping is not None
|
||||||
|
arrows, _ = build_normal_overlay(
|
||||||
|
contact_uv_xy=(1.0, 1.0),
|
||||||
|
contact_found=False,
|
||||||
|
contact_valid=True,
|
||||||
|
fn=100.0,
|
||||||
|
mapping=mapping,
|
||||||
|
)
|
||||||
|
assert arrows.origins.shape[0] == 0
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Shared 2D/3D flow sampling and domain-mask rules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d.contracts import ThreeDError
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import (
|
||||||
|
VisualPolicy,
|
||||||
|
build_domain_mask,
|
||||||
|
paint_domain_background,
|
||||||
|
select_flow_samples,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_domain_mask_rejects_shape_mismatch() -> None:
|
||||||
|
geo = np.ones((4, 5), dtype=bool)
|
||||||
|
with pytest.raises(ThreeDError):
|
||||||
|
build_domain_mask(geo, shape_hw=(3, 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_domain_mask_intersects_roi() -> None:
|
||||||
|
geo = np.ones((3, 3), dtype=bool)
|
||||||
|
roi = np.zeros((3, 3), dtype=bool)
|
||||||
|
roi[1, 1] = True
|
||||||
|
mask, diags = build_domain_mask(geo, roi)
|
||||||
|
assert mask is not None
|
||||||
|
assert bool(mask[1, 1]) and not bool(mask[0, 0])
|
||||||
|
assert diags == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_flow_samples_uses_shared_grid_and_mask() -> None:
|
||||||
|
h, w = 40, 40
|
||||||
|
flow = np.zeros((h, w, 2), dtype=np.float64)
|
||||||
|
flow[15, 15] = (0.5, 0.0) # |2*flow| = 1.0
|
||||||
|
mask = np.ones((h, w), dtype=bool)
|
||||||
|
mask[:, :5] = False
|
||||||
|
policy = VisualPolicy(flow_on_px=0.4, flow_grid_spacing=10, flow_edge_margin=0)
|
||||||
|
samples = select_flow_samples(flow, mask, policy)
|
||||||
|
assert samples.rows.size >= 1
|
||||||
|
assert all(c >= 5 for c in samples.cols.tolist())
|
||||||
|
pairs = set(zip(samples.rows.tolist(), samples.cols.tolist()))
|
||||||
|
assert (15, 15) in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_flow_samples_hysteresis_keeps_weaker_motion() -> None:
|
||||||
|
h, w = 30, 30
|
||||||
|
flow = np.zeros((h, w, 2), dtype=np.float64)
|
||||||
|
# |2*flow| = 0.7: below on (0.8) but above off (0.5)
|
||||||
|
flow[15, 15] = (0.35, 0.0)
|
||||||
|
policy = VisualPolicy(
|
||||||
|
flow_on_px=0.8,
|
||||||
|
flow_off_px=0.5,
|
||||||
|
flow_grid_spacing=10,
|
||||||
|
flow_edge_margin=0,
|
||||||
|
)
|
||||||
|
previously = np.zeros((h, w), dtype=bool)
|
||||||
|
previously[15, 15] = True
|
||||||
|
off = select_flow_samples(flow, None, policy, previously_on=None)
|
||||||
|
on = select_flow_samples(flow, None, policy, previously_on=previously)
|
||||||
|
assert off.rows.size == 0
|
||||||
|
assert on.rows.size == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_paint_domain_background_colours() -> None:
|
||||||
|
mask = np.zeros((4, 4), dtype=bool)
|
||||||
|
mask[1:3, 1:3] = True
|
||||||
|
canvas = paint_domain_background(
|
||||||
|
(4, 4), mask, island_bgr=(10, 20, 30), exterior_bgr=(1, 2, 3)
|
||||||
|
)
|
||||||
|
assert tuple(canvas[0, 0]) == (1, 2, 3)
|
||||||
|
assert tuple(canvas[1, 1]) == (10, 20, 30)
|
||||||
Reference in New Issue
Block a user