autocal layout first design

This commit is contained in:
ykcheungab
2026-09-20 21:15:39 +08:00
parent 07d7c3bad6
commit e276b5de0f
92 changed files with 18071 additions and 64 deletions
+142
View File
@@ -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.
+103
View File
@@ -0,0 +1,103 @@
'*******************************************************ECAT总线初始化
'''''''''
global CONST BUS_TYPE = 0 '总线类型。用于上位机区分当前模式
global CONST MAX_AXISNUM = 32 '最大轴数
global CONST Bus_Slot = 0 '槽位号0
global CONST Bus_AxisStart = 0 '总线轴起始轴号
global Bus_InitStatus '总线初始化完成状态
Bus_InitStatus = -1
global Bus_TotalAxisnum '检查扫描的总轴数
delay(3000) '延时3S等待驱动器上电
'**********************初始化ECAT总线
Ecat_Init()
end
global sub Ecat_Init()
for i=0 to MAX_AXISNUM - 1 '初始化还原轴类型
AXIS_ENABLE(i) = 0
atype(i)=0
next
Bus_InitStatus = -1
Bus_TotalAxisnum = 0
SLOT_STOP(Bus_Slot)
delay(200)
slot_scan(Bus_Slot) '开始扫描
if return then
?"总线扫描成功","连接设备数:"NODE_COUNT(Bus_Slot)
?
?"开始映射轴号"
for i=0 to NODE_COUNT(Bus_Slot)-1 '遍历总线下所有从站节点
if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then '判断当前节点是否有电机
for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1
AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 '映射轴号
ATYPE(Bus_AxisStart+i)=65 '设置控制模式 65-位置 66-速度 67-转矩
DRIVE_PROFILE(Bus_AxisStart+i)= 4 '设置PROFILE功能
disable_group(Bus_AxisStart+i) '每轴单独分组
DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 '映射驱动器上的IO状态
REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16
FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16
DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16
INVERT_IN(128 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(129 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(130 + (Bus_AxisStart+i)*16,ON)
Bus_TotalAxisnum=Bus_TotalAxisnum+1 '总轴数+1
next
endif
next
?"轴号映射完成","连接总轴数:"Bus_TotalAxisnum
wa 2000
SLOT_START(Bus_Slot) '启动总线
if return then
?"总线开启成功"
?"开始清除驱动器错误(根据驱动器数据字典设置)"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
DRIVE_CONTROLWORD(i)=128 '根据驱动器数据字典
wa 100
DRIVE_CONTROLWORD(i)=6
wa 100
DRIVE_CONTROLWORD(i)=15
wa 100
next
?"驱动器错误清除完成"
wa 100
?"清除控制器错误"
datum(0)
DRIVE_CLEAR(0)
?"控制器错误清除完成"
wa 100
?"轴使能准备"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
base(i)
AXIS_ENABLE=1
next
wdog=1 '使能总开关
Bus_InitStatus = 1
?"轴使能完成"
else
?"总线开启失败"
Bus_InitStatus = 0
endif
else
?"总线扫描失败"
Bus_InitStatus = 0
endif
end sub
+103
View File
@@ -0,0 +1,103 @@
'*******************************************************RTEX总线初始化
'''''''''
global CONST BUS_TYPE = 1 '总线类型。用于上位机区分当前模式
global CONST MAX_AXISNUM = 32 '最大轴数
global CONST Bus_Slot = 0 '槽位号0
global CONST Bus_AxisStart = 0 '总线轴起始轴号
global Bus_InitStatus '总线初始化完成状态
Bus_InitStatus = -1
global Bus_TotalAxisnum '检查扫描的总轴数
delay(3000) '延时3S等待驱动器上电
'**********************初始化RTEX总线
Rtex_Init()
end
global sub Rtex_Init()
for i=0 to MAX_AXISNUM - 1 '初始化还原轴类型
AXIS_ENABLE(i) = 0
atype(i)=0
next
Bus_InitStatus = -1
Bus_TotalAxisnum = 0
SLOT_STOP(Bus_Slot)
delay(200)
slot_scan(Bus_Slot) '开始扫描
if return then
?"总线扫描成功","连接设备数:"NODE_COUNT(Bus_Slot)
?
?"开始映射轴号"
for i=0 to NODE_COUNT(Bus_Slot)-1 '遍历总线下所有从站节点
if NODE_AXIS_COUNT(Bus_Slot,i) <>0 then '判断当前节点是否有电机
for j=0 to NODE_AXIS_COUNT(Bus_Slot,i)-1
AXIS_ADDRESS(Bus_AxisStart+i)=Bus_TotalAxisnum+1 '映射轴号
ATYPE(Bus_AxisStart+i)=50 '设置控制模式 50-位置 51-速度 52-转矩
DRIVE_PROFILE(Bus_AxisStart+i)= 4 '设置PROFILE功能
disable_group(Bus_AxisStart+i) '每轴单独分组
DRIVE_IO(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16 '映射驱动器上的IO状态
REV_IN(Bus_AxisStart+i) = 128 + (Bus_AxisStart+i)*16
FWD_IN(Bus_AxisStart+i) = 129 + (Bus_AxisStart+i)*16
DATUM_IN(Bus_AxisStart+i) = 130 + (Bus_AxisStart+i)*16
INVERT_IN(128 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(129 + (Bus_AxisStart+i)*16,ON)
INVERT_IN(130 + (Bus_AxisStart+i)*16,ON)
Bus_TotalAxisnum=Bus_TotalAxisnum+1 '总轴数+1
next
endif
next
?"轴号映射完成","连接总轴数:"Bus_TotalAxisnum
wa 2000
SLOT_START(Bus_Slot) '启动总线
if return then
?"总线开启成功"
?"开始清除驱动器错误(根据驱动器数据字典设置)"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
DRIVE_CONTROLWORD(i)=128 '根据驱动器数据字典
wa 100
DRIVE_CONTROLWORD(i)=6
wa 100
DRIVE_CONTROLWORD(i)=15
wa 100
next
?"驱动器错误清除完成"
wa 100
?"清除控制器错误"
datum(0)
DRIVE_CLEAR(0)
?"控制器错误清除完成"
wa 100
?"轴使能准备"
for i= Bus_AxisStart to Bus_AxisStart + Bus_TotalAxisnum - 1
base(i)
AXIS_ENABLE=1
next
wdog=1 '使能总开关
Bus_InitStatus = 1
?"轴使能完成"
else
?"总线开启失败"
Bus_InitStatus = 0
endif
else
?"总线扫描失败"
Bus_InitStatus = 0
endif
end sub
+254
View File
@@ -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"
}
}
+254
View File
@@ -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"
}
}
+335
View File
@@ -0,0 +1,335 @@
# AutoCal workflow — platform bring-up to calibration data
This is the operating workflow the application is built around. It is written to
be argued with: each phase names what it needs, what it produces, why it exists,
and what must be true before the next one starts.
A **gate** is a checkpoint. The app's job at a gate is to make the operator's
answer trustworthy, not to ask "are you sure?".
```text
P0 Session & safety baseline [gate]
P1 ECAT setup [gate]
P2 Homing + test motion [gate]
P3 Sensor & probe setup [gate]
P4 Coordinate setup: sensor <-> platform [gate]
P5 Sequence design [gate]
P6 Simulate & dry run [gate]
P7 Run + acquire [gate]
P8 Store raw
P9 Post-process [gate]
P10 Calibration export
P11 Verify [gate]
```
---
## P0 — Session & safety baseline
**Why:** the machine can move from P2 onward. Everything that limits that motion
should be decided while nothing is moving.
**Inputs:** operator identity, machine/bench identifier, units per axis,
`units_per_mm`.
**Safety limits, set here, enforced from P2:**
| Limit | Meaning |
|---|---|
| maximum travel per axis | never commanded past this, in user units |
| maximum Z descent per step | a single approach may not exceed this |
| press-depth ceiling | the press depth a sequence profile is allowed to request |
| no-contact abort | if contact is not detected within the descent budget, stop the run |
| following-error bound | how far a drive may lag before the move counts as stalled |
**Outputs:** a session record — who, when, which machine, and the limits in
force. Stamped onto everything the session produces.
**Gate:** every limit has a value; no limit is "unset" (an unset limit is an
unlimited machine).
**Currently missing from the workflow:** this phase did not exist. Without it,
step 1 went from bus setup straight to jogging five axes.
---
## P1 — ECAT setup
**Why:** nothing else can be trusted until the controller has actually found its
slaves. A missing drive discovered at P7 wastes the whole run.
**Inputs:** controller address, the matching `.bas` program
(`ECAT.bas` / `RTEX.bas`).
**Steps:** connect → download and run the BAS program → watch
`Bus_InitStatus` to completion or timeout → read `BUS_TYPE`,
`Bus_TotalAxisnum`, and the node count.
**Outputs:** bus type, node count, axis count, init status.
**Gate:** init status is success, **and** the node/axis counts match what this
bench is expected to have. A mismatch is a finding, not a warning.
**Existing code:** `MotionService.download_bas` / `poll` already implement the
state machine and the 25 s timeout.
---
## P2 — Homing + test motion
**Why:** a drive that homes and jogs cleanly at low speed is the precondition for
letting a sequence drive it.
**Inputs:** per-axis homing mode, speeds, offset; a test-motion profile.
**Steps:** enable → home each axis → low-speed jog each axis in both directions
→ confirm movement and return.
**Outputs:** per-axis pass/fail with the evidence (distance moved, following
error, alarm state).
**Gate — the test motion needs a criterion.** This was missing. Proposed:
- the axis moved at least a configured minimum distance;
- it returned within a tolerance of its start position;
- following error stayed under the bound from P0;
- no alarm before, during or after.
**Existing code:** the axis panel does the jog/homing; the criterion is new.
---
## P3 — Sensor & probe setup
**Why:** which sensor is under test, what touches it, and how far the touching
point is from the axis reference. Contact depth is meaningless without the tool.
**Inputs:**
- sensor type and config reference (the same `config/sensors/<type>/sensor.json`
the studio uses);
- the sensor's 3D scene package and its `geometry_id`;
- the **probe tip** (tool): which axis presses, and the tip offset from the
platform's reference point;
- the mounting pose: how the sensor is fixtured on the platform.
**Outputs:** a saved setup block.
**Gate:**
- the calibration's `geometry_id` matches the scene's, or the app says which is
which rather than colouring the wrong geometry;
- the probe tip offset is set (a zero offset that was never entered is not the
same as a probe at the origin).
---
## P4 — Coordinate setup: sensor ↔ platform
**Why this is the load-bearing phase.** "Which points on the sensor should be
tested" is a question in the *sensor's* frame. The platform moves in *platform*
coordinates. Without the transform between them:
- a generated grid cannot be checked against the sensor's surface;
- a measured point cannot be placed on that surface for the sensitivity map;
- a calibration cannot be re-applied to a re-fixtured sensor.
**Inputs:** the probe tip from P3, and a set of **at least four non-collinear
known features** on the sensor or its fixture, probed in platform coordinates.
**Steps:** probe each feature → solve for the rigid transform (rotation and
translation; scale if the units are not yet trusted) → report the residual.
**Outputs:** a 4×4 transform from sensor frame to platform frame, with its
residual and the feature set that produced it.
**Gate:** residual under a configured tolerance, and at least four non-collinear
points (four collinear points determine a rotation about that line, not a
transform — the app should refuse that set rather than return a number).
**Not built in this step:** the solver. Only the workflow step, the stored
transform, and the place its residual is displayed.
---
## P5 — Sequence design
**Why:** this is the decision the whole run exists to carry out.
**Inputs (the profile, persisted and reusable):**
| Parameter | Meaning |
|---|---|
| mode | flat surface or curved (a cylindrical/spherical sensor area) |
| grid pitch | spacing between points, in mm |
| margin / radius | how far in from the active edge the grid stays; radius for curved |
| clear height | standoff above the surface to travel at |
| approach speed | descent speed before contact |
| press speed | descent speed after contact |
| press depth | how far past contact to press |
| dwell | how long to hold the press |
| settle window | how long to wait before the measurement is considered settled |
| repeats | how many times each point is measured |
| point order | row-wise, serpentine, or by distance |
**Steps:** generate the grid → validate **every point** three ways:
1. **on the sensor** — its sensor-frame position falls on the modelled surface,
not off the edge and not inside a hole;
2. **within travel** — the platform can reach it inside the P0 limits;
3. **confirmable** — the sensor's sensing area is under the point, so a contact
there can actually be seen by the sensor.
**Outputs:** a point list with a sensor-frame position and a platform-frame
target for each, plus a validation verdict per point.
**Gate:** no point may be unusable without being marked as such. A run may
proceed with skipped points if the operator says so explicitly; it may not
proceed with unvalidated ones.
**The interface:** three zones in the Sequence tab — profile parameters, a 3D
preview of the surface with the points and their visiting order, and the
validation list. See `apps/autocal/README.md`.
---
## P6 — Simulate & dry run
**Why:** a wrong Z or a wrong grid is much cheaper to find now.
**Simulate:** play the full toolpath in the 3D view — a moving marker with a
phase label (standoff → descend → press → dwell → retract), the estimated
duration, and per-point reachability. No motion.
**Dry run:** execute the real XY path, descending only to the clear height,
never pressing. This proves the geometry, the ordering, and the timing.
**Outputs:** estimated duration, per-point reachability, and (dry run) the
measured time per point.
**Gate:** the operator has seen the path and a dry run has completed without a
guard violation.
---
## P7 — Run + acquire
**Why:** the only phase that touches the sensor.
**Per step, in order:**
1. move to the point's XY at clear height;
2. descend at approach speed;
3. **detect contact by position** — the axis stalls / following error rises
while descending;
4. **confirm with the sensor under test** — its reading must change when the
probe presses. This is the second opinion: position says "I stopped moving",
the sensor says "something real is happening";
5. press to the configured depth at press speed;
6. dwell, then acquire the settle window;
7. retract, move to the next point.
**Failure semantics (per point, because resume needs them):**
| Outcome | Run behaviour |
|---|---|
| contact confirmed | record the point as measured |
| contact detected, sensor shows no change | mark **suspect**, continue; a suspect point is visible, not silently averaged in |
| no contact within the descent budget | **abort** the run (P0 limit) and leave the point unfinished |
| guard violation | abort the run, name the step and the limit |
**Writes are incremental**: each point is written as it completes, with its
status, so an interrupted run resumes at the first unfinished point and partial
data stays valid.
**Existing code:** the motion layer has the guards and the poller; the step
machine, the contact detection and the acquisition binding are new.
---
## P8 — Store raw
**Why:** post-processing will change. The raw record must not.
**Contents:**
- the sequence profile and the instantiated point list (positions and order);
- per point: the position trace, the raw sensor series, the settle window used,
timings, the contact position, and the status;
- the setup: sensor identity, geometry id, probe tip, the P4 transform and its
residual;
- the session record and app/version stamps from P0.
Nothing derived is authoritative. The requirement that makes this reproducible:
**a dataset can be regenerated from the profile, the setup and the raw series**,
with no dependency on a previously computed result.
---
## P9 — Post-process
**Why:** turning samples into a number is a decision, and it has to be visible.
**Steps:** choose the settle window per point → stability check (is the point
actually settled, or drifting?) → sensitivity per point per axis → outlier
detection → residual per point → fit the surface across points.
**Outputs:** per-point sensitivity with a quality verdict, and a surface field
over the sensor's geometry ready for the 3D colour map.
**Gate:** every point is either valid or explicitly excluded, with a reason.
A point with too few samples or a drifting window is not a coefficient.
---
## P10 — Calibration export
**Per-point results and the fitted surface, explicitly linked.** They are one
calibration; exporting them as unrelated artefacts is how a map ends up in a
report that does not match the table beside it.
---
## P11 — Verify
**Why this exists:** P9 and P10 produce numbers. Nothing yet checks they are
right. "Post process → calibration data" had no acceptance test.
**Steps:** apply the calibration → re-measure a small subset of the points →
compare the measured sensitivity against what the calibration predicts →
report the discrepancy per point.
**Gate:** the discrepancy is within tolerance, or the verification is recorded
as failed. A calibration that was never verified is labelled as such wherever it
is used.
---
## Gaps this document closes
| # | Gap | Phase |
|---|---|---|
| 1 | no limits before motion | P0 |
| 2 | no session identity (who/when/which machine) | P0 |
| 3 | the sensor↔platform transform was absent | P4 |
| 4 | the probe tip (tool) was never defined | P3 |
| 5 | the test motion had no pass/fail criterion | P2 |
| 6 | grid validity was not checked per point | P5 |
| 7 | the settle window and stability criterion were undefined | P9 |
| 8 | a point's failure had no defined behaviour | P7 |
| 9 | the sequence was transient, not a saved, versioned artefact | P8 |
| 10 | no verification pass | P11 |
## Data flow
```text
P0 limits ─┬───────────────────────────────> enforced at P2, P6, P7
P1 bus ────┴─> axis availability ─────────> P2
P3 sensor setup ─┬─ geometry id ──────────> P5 validation, P10 surface
└─ probe tip ────────────> P4, P7 press depth
P4 transform ─────> sensor <-> platform ──> P5 point generation, P9 map
(residual) P11 re-fixture check
P5 profile + points ──> P6 simulate/dry run ──> P7 run
P7 raw series ─────> P8 dataset ──> P9 post-process ──> P10 export
└─> P11 verify
```
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-autocal"
version = "0.1.0"
description = "Orisys AutoCal — internal 5-axis calibration platform control"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
# PySide6 is the SQL-free, LGPLv3 Qt binding. It is deliberate, not a
# preference: this is an internal tool, and PyQt5's free option is GPLv3
# (see README.md and src/orisys_autocal/qtcompat.py).
"PySide6>=6.6",
"orisys-app-shared>=0.1.0",
]
[project.scripts]
orisys-autocal = "orisys_autocal.main:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Launch Orisys AutoCal from the source tree."""
from __future__ import annotations
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
SHARED_SRC = ROOT.parents[1] / "shared" / "src"
for path in (SRC, SHARED_SRC):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
# Resolve config/outputs to this app, and set the app id before any shared
# import can read it (see orisys_app_shared.paths).
os.environ.setdefault("ORISYS_APP_ID", "autocal")
os.chdir(ROOT)
from orisys_autocal.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,12 @@
"""Orisys AutoCal — internal 5-axis calibration platform control app."""
__version__ = "0.1.0"
REQUIRES_SHARED_API = "0.1"
#: Window / QApplication name.
APP_DISPLAY_NAME = "Orisys AutoCal"
#: The Qt binding this app uses. It is PySide6 and only PySide6: LGPLv3, so an
#: internal closed-source tool needs no licence purchase, and it is the
#: maintained Qt-for-Python binding (PySide2 is discontinued upstream).
QT_BINDING = "PySide6"
+126
View File
@@ -0,0 +1,126 @@
"""AutoCal entrypoint: logging, locale, theme, then the window."""
from __future__ import annotations
import argparse
import logging
import os
import sys
# The app id must be set before anything imports orisys_app_shared.paths, because
# the config/output roots are resolved on first use and cached by callers.
os.environ.setdefault("ORISYS_APP_ID", "autocal")
def _force_utf8_streams() -> None:
"""Make stdout/stderr UTF-8 where the platform lets us.
The controller's error strings and every panel label are Chinese, and a
Windows console defaults to a legacy code page that cannot encode them.
A failed reconfigure is not fatal — the log file is UTF-8 regardless.
"""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError): # pragma: no cover - stream already detached
pass
def _pyside_version() -> str:
import PySide6
return getattr(PySide6, "__version__", "unknown")
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="orisys-autocal", description=__doc__)
parser.add_argument(
"--demo",
action="store_true",
help="start on the simulated platform (no hardware, no controller DLL)",
)
parser.add_argument(
"--log-level",
default="INFO",
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
help="console and file log level",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
_force_utf8_streams()
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.logging import init_logging, install_exception_hooks
from orisys_app_shared.theme import build_qss
from PySide6.QtWidgets import QApplication
from . import REQUIRES_SHARED_API, __version__ as app_version
from .motion import ConnectionSpec, DemoTransport
from .preferences import load_preferences
from .ui.window import AutoCalMainWindow
init_logging("autocal", level=getattr(logging, args.log_level))
install_exception_hooks()
check_shared_compat(REQUIRES_SHARED_API)
logger = logging.getLogger(__name__)
prefs = load_preferences()
language = str(prefs.get("language") or "zh_CN")
theme = str(prefs.get("theme") or "dark")
set_locale(language)
app = QApplication(sys.argv[:1])
app.setApplicationName("Orisys AutoCal")
app.setApplicationVersion(app_version)
app.setStyleSheet(build_qss(theme))
transport = None
if args.demo:
transport = DemoTransport()
connection = dict(prefs.get("connection") or {})
connection["mode"] = "demo"
prefs["connection"] = connection
window = AutoCalMainWindow(prefs=prefs)
if transport is not None:
window.poller.service._transport = transport # noqa: SLF001 - deliberate swap
window.show()
logger.info(
"AutoCal started (version=%s shared=%s api=%s binding=PySide6 %s "
"theme=%s language=%s demo=%s)",
app_version,
shared_version,
API_VERSION,
_pyside_version(),
theme,
language,
args.demo,
)
if args.demo:
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
code = int(app.exec())
# Leave the process immediately. The ZMotion runtime keeps native threads of
# its own, so a normal interpreter shutdown can hang after the window closes;
# everything durable was already flushed by closeEvent and the log handlers.
logging.shutdown()
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except Exception: # noqa: BLE001 - a closed stream must not block exit
pass
os._exit(code)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,58 @@
"""Platform control: models, guards, polling, and the Qt worker bridge."""
from .models import (
INIT_FAILED,
INIT_OK,
INIT_RUNNING,
INIT_TIMEOUT_S,
AxisParams,
AxisState,
AxisStatusBits,
BusStatus,
BusType,
ConnectionMode,
ConnectionSpec,
MotionResult,
Snapshot,
)
from .poller import MotionPoller
from .service import PLATFORM_AXES, MotionService
from .transport import DemoTransport, NativeTransport, ZmcTransport
from .units import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
DEFAULT_UNITS,
PULSES_PER_MM,
mm_to_user_units,
safe_float,
user_units_to_mm,
)
__all__ = [
"DEFAULT_ACCEL",
"DEFAULT_SPEED",
"DEFAULT_UNITS",
"INIT_FAILED",
"INIT_OK",
"INIT_RUNNING",
"INIT_TIMEOUT_S",
"PLATFORM_AXES",
"PULSES_PER_MM",
"AxisParams",
"AxisState",
"AxisStatusBits",
"BusStatus",
"BusType",
"ConnectionMode",
"ConnectionSpec",
"DemoTransport",
"MotionPoller",
"MotionResult",
"MotionService",
"NativeTransport",
"Snapshot",
"ZmcTransport",
"mm_to_user_units",
"safe_float",
"user_units_to_mm",
]
@@ -0,0 +1,77 @@
"""Translate controller return codes into something an operator can act on.
Only the codes the vendor actually documents are given a meaning here. The
`sdk header <../../resources/zmotion/x64/zauxdll2.h>`_ defines the auxiliary
error block at offset 30000:
#define ERR_NOACK ERR_AUX_OFFSET //无应答
#define ERR_ACKERROR (ERR_AUX_OFFSET+1) //应答错误
#define ERR_AUX_PARAERR (ERR_AUX_OFFSET+2) //参数错误
#define ERR_AUX_NOTSUPPORT (ERR_AUX_OFFSET+3) //不支持
#define ERR_AUX_FILE_ERROR (ERR_AUX_OFFSET+4) //参数错误
#define ERR_AUX_OS_ERR (ERR_AUX_OFFSET+5) //
Everything else is the controller's own number. :func:`describe` deliberately
does **not** invent a meaning for those: it names the number and points at
ZDevelop, which is where the firmware's code table lives. A guessed message on
a motion controller is worse than "unknown".
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
#: Auxiliary (host-side) error block offset, from ``zauxdll2.h``.
ERR_AUX_OFFSET = 30000
#: Meaning per code, as ``(english, chinese)``.
MESSAGES: dict[int, tuple[str, str]] = {
ERR_AUX_OFFSET: ("No response from the controller", "控制器无应答"),
ERR_AUX_OFFSET + 1: ("The controller rejected the command", "应答错误"),
ERR_AUX_OFFSET + 2: ("Wrong parameter", "参数错误"),
ERR_AUX_OFFSET + 3: ("Not supported by this model or firmware", "不支持该功能"),
ERR_AUX_OFFSET + 4: ("File error", "文件错误"),
ERR_AUX_OFFSET + 5: ("Operating-system error in the library", "库内操作系统错误"),
}
#: The last-resort advice appended to every unknown code.
UNKNOWN_HINT = (
"Look this number up in ZDevelop's error table (控制器错误码表), "
"or run the same command from ZDevelop to see the decoded fault."
)
def describe(code: int, *, language: str = "zh_CN") -> str:
"""A human-readable message for a controller return code.
Args:
code: the value returned by a ``ZAux_*`` call.
language: ``zh_CN`` for Chinese, anything else for English.
Returns:
``"0"`` is reported as success; known codes get a translated message;
unknown codes keep their number visible rather than being guessed at.
"""
code = int(code)
if code == 0:
return "成功" if language == "zh_CN" else "OK"
index = 1 if language == "zh_CN" else 0
known = MESSAGES.get(code)
if known is None:
# An unknown code that still resolves to an auxiliary slot is more
# useful than a bare integer, but only when it is documented.
logger.debug("Undocumented ZMotion return code %s", code)
prefix = "错误码" if language == "zh_CN" else "Error code"
return f"{prefix} {code}"
return known[index]
def describe_with_hint(code: int, *, language: str = "zh_CN") -> str:
"""As :func:`describe`, plus the ZDevelop pointer for unknown codes."""
message = describe(code, language=language)
if int(code) == 0 or int(code) in MESSAGES:
return message
return f"{message} — {UNKNOWN_HINT}"
@@ -0,0 +1,180 @@
"""Motion-control domain model.
Plain frozen dataclasses and no Qt: the service layer and the tests both use
these, and neither needs a GUI to do it.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
#: Which physical transport the controller is reached over.
ConnectionMode = Literal["eth", "com", "pci", "local", "demo"]
#: Bus families the ``.bas`` programs report through ``BUS_TYPE``.
BusType = Literal["unknown", "ecat", "rtex"]
#: The vendor reference's window between "BAS downloaded" and a final status.
INIT_TIMEOUT_S = 25.0
#: ``Bus_InitStatus`` values, from ``ECAT.bas``.
INIT_RUNNING = -1
INIT_FAILED = 0
INIT_OK = 1
@dataclass(frozen=True)
class ConnectionSpec:
"""How to reach the controller."""
mode: ConnectionMode
ip: str = ""
com_id: int = 0
card_num: int = 0
local_string: str = "0"
def describe(self) -> str:
if self.mode == "eth":
return self.ip or "(no IP)"
if self.mode == "com":
return f"COM{self.com_id}"
if self.mode == "pci":
return f"PCI card {self.card_num}"
if self.mode == "local":
return f"local {self.local_string}"
return "demo (no hardware)"
@dataclass(frozen=True)
class AxisParams:
"""Per-axis parameters as entered by the operator."""
units: float = 10.0
speed: float = 1000.0
accel: float = 10000.0
creep: float = 10.0
home_mode: int = 0
home_high: float = 50.0
home_low: float = 10.0
home_offset: float = 0.0
@dataclass(frozen=True)
class AxisStatusBits:
"""Decoded ``ZAux_Direct_GetAxisStatus`` word.
Only the bits the AutoCal panels display are named. Bit 6 is the one the
vendor reference tests (``status & 64``) to decide that homing is running.
"""
raw: int = 0
@property
def home_in_progress(self) -> bool:
"""Bit 6 — set while the drive is executing a homing move."""
return bool(self.raw & 64)
@property
def moving(self) -> bool:
"""Bit 1 — the axis is producing motion."""
return bool(self.raw & 2)
@property
def alarm(self) -> bool:
"""Bit 9 — the drive is reporting a fault."""
return bool(self.raw & 512)
def active_bits(self) -> list[int]:
"""Every set bit position, for the status column's tooltip."""
return [bit for bit in range(32) if self.raw & (1 << bit)]
@dataclass(frozen=True)
class AxisState:
"""One axis as read back from the controller."""
axis: int
enabled: bool = False
atype: int = 0
units: float = 0.0
speed: float = 0.0
accel: float = 0.0
dpos: float = 0.0
mpos: float = 0.0
status: AxisStatusBits = field(default_factory=AxisStatusBits)
idle: bool = True
home_status: int = 0
read_ok: bool = True
error: str = ""
@dataclass(frozen=True)
class BusStatus:
"""Bus-level state, from the ``.bas`` program's globals."""
bus_type: BusType = "unknown"
init_status: int = INIT_RUNNING
node_count: int = 0
total_axes: int = 0
bas_path: str = ""
bas_loaded: bool = False
init_started_at: float | None = None
message: str = ""
@property
def initialized(self) -> bool:
return self.init_status == INIT_OK
@property
def supports_sdo(self) -> bool:
return self.bus_type == "ecat"
@property
def supports_rtex(self) -> bool:
return self.bus_type == "rtex"
def timed_out(self, now: float) -> bool:
"""True while a BAS run started long ago has still not reported."""
if self.init_status != INIT_RUNNING or self.init_started_at is None:
return False
return (now - self.init_started_at) > INIT_TIMEOUT_S
@dataclass(frozen=True)
class Snapshot:
"""Everything the UI needs for one refresh."""
connected: bool = False
spec: ConnectionSpec | None = None
bus: BusStatus = field(default_factory=BusStatus)
axes: tuple[AxisState, ...] = ()
error: str = ""
def axis(self, index: int) -> AxisState | None:
for state in self.axes:
if state.axis == index:
return state
return None
@dataclass(frozen=True)
class MotionResult:
"""Outcome of a command, with a reason when it was refused or failed."""
ok: bool
code: int = 0
message: str = ""
@classmethod
def success(cls, message: str = "") -> "MotionResult":
return cls(ok=True, code=0, message=message)
@classmethod
def refused(cls, message: str) -> "MotionResult":
"""The command was not sent: a guard rejected it before any motion."""
return cls(ok=False, code=0, message=message)
@classmethod
def failed(cls, code: int, message: str) -> "MotionResult":
return cls(ok=False, code=int(code), message=message)
@@ -0,0 +1,312 @@
"""Owns the controller connection on a worker thread.
Why a thread and not a ``QTimer`` in the GUI thread: ``ZAux_BasDown`` (a ROM
write) and a bus scan block for seconds, and the bus-init sequence blocks for
three; a synchronous call on the GUI thread freezes the window for that whole
time. The Qt signal boundary here is the *only* way the UI reaches the
controller, which also means there is exactly one thread making DLL calls —
the ZMotion handle is not safe to share.
Contract:
* Creating the poller touches no hardware and loads no DLL.
* :meth:`MotionPoller.submit` queues a service call; the result comes back on
``commandFinished``/``commandFailed``, and commands run in submission order.
* Only one service call is in flight at once, because the worker is a single
thread and each due step performs one control action.
* A call that raises is reported, not fatal: the timer keeps running and the
next snapshot still arrives.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Iterable
from PySide6.QtCore import (
QMetaObject,
QObject,
Qt,
QThread,
QTimer,
Signal,
Slot,
)
from .models import Snapshot
from .service import PLATFORM_AXES, MotionService
logger = logging.getLogger(__name__)
#: Default UI refresh interval, matching the reference program's 100 ms timer.
DEFAULT_INTERVAL_MS = 100
class _Worker(QObject):
"""Lives on the poller thread. Every controller call happens here."""
snapshotReady = Signal(object)
commandFinished = Signal(int, object)
commandFailed = Signal(int, str)
connected = Signal(object)
disconnected = Signal()
def __init__(self, service: MotionService, axes: Iterable[int] | None) -> None:
super().__init__()
self._service = service
self._axes = list(axes) if axes is not None else None
# Both are created in ``warm_up``, which runs on the worker thread: a
# QTimer must be created, started and stopped from the thread that owns
# it, and this object is constructed on the GUI thread.
self._timer: QTimer | None = None
self._timer_loop: QTimer | None = None
self._interval_ms = DEFAULT_INTERVAL_MS
self._pending: list[tuple[int, Callable[[], Any]]] = []
self._counter = 0
self._last_snapshot_at = 0.0
# ------------------------------------------------------------ thread setup
@Slot()
def warm_up(self) -> None:
"""Create the timers and start polling.
Invoked on the worker thread, once, at thread start-up — and taking no
arguments precisely so it can be, because marshalling an argument across
the meta-object system differs between the Qt bindings.
"""
if self._timer is None:
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
# A zero-interval timer that always stays active. Its only job is to
# keep the thread's event loop turning (``QThread.exec`` returns as
# soon as no events are left, even though the thread stays alive), so
# a queued ``submit`` is delivered even while polling is stopped.
self._timer_loop = QTimer(self)
self._timer_loop.timeout.connect(lambda: None)
self._timer_loop.start(0)
self._timer.setInterval(self._interval_ms)
if not self._timer.isActive():
self._timer.start()
def start_polling(self, interval_ms: int = DEFAULT_INTERVAL_MS) -> None:
"""Start (or retune) the poll timer. Must run on the worker thread."""
self._interval_ms = max(20, int(interval_ms))
if self._timer is None:
self.warm_up()
assert self._timer is not None
self._timer.setInterval(self._interval_ms)
if not self._timer.isActive():
self._timer.start()
@Slot()
def stop_polling(self) -> None:
if self._timer is not None:
self._timer.stop()
if self._timer_loop is not None:
self._timer_loop.stop()
@Slot()
def cleanup(self) -> None:
"""Destroy the timers on the thread that owns them.
A QTimer stopped or deleted from another thread warns ("Timers cannot be
stopped from another thread") and leaves the timestamp bookkeeping in an
undefined state. Since these are parented to the worker, they would
otherwise be destroyed with it, from whichever thread drops the last
reference to the worker.
"""
for timer in (self._timer, self._timer_loop):
if timer is not None:
timer.stop()
timer.deleteLater()
self._timer = None
self._timer_loop = None
def set_axes(self, axes: Iterable[int]) -> None:
self._axes = list(axes)
def set_language(self, language: str) -> None:
self._service.set_language(language)
# ------------------------------------------------------------------ queue
def submit(self, action: Callable[[MotionService], Any]) -> int:
"""Queue ``action``; returns the id its result will be reported under.
Queuing is thread-safe (list append under the GIL); the *work* is not
done here. ``invokeMethod`` with a queued connection is what runs
:meth:`_tick` on the worker thread, so no controller call ever happens
on the caller's thread — which matters twice over, because the DLL is
single-threaded and a blocking call would freeze the window.
"""
self._counter += 1
token = self._counter
self._pending.append((token, action))
QMetaObject.invokeMethod(self, "_tick", Qt.ConnectionType.QueuedConnection)
return token
@Slot()
def _tick(self) -> None:
"""One poll cycle: run at most one queued command, then publish state.
One control action per step, then a snapshot. Draining the whole queue
here would make a slow BAS download delay every later command with no
feedback in between.
Qt looks this slot up by name (see ``submit``), so it keeps both the
decorator and the leading underscore.
"""
if self._pending:
token, action = self._pending.pop(0)
try:
result = action(self._service)
except Exception as exc: # noqa: BLE001 - report, never kill the thread
logger.exception("Controller command %s failed", token)
self.commandFailed.emit(token, self._describe(exc))
else:
self.commandFinished.emit(token, result)
self._emit_snapshot()
def _emit_snapshot(self) -> None:
try:
snapshot = self._service.poll(self._axes)
except Exception as exc: # noqa: BLE001 - a poll must never stop the timer
logger.exception("Polling the controller failed")
snapshot = Snapshot(
connected=self._service.connected,
spec=self._service.spec,
bus=self._service.bus,
error=self._describe(exc),
)
self.snapshotReady.emit(snapshot)
def _describe(self, exc: Exception) -> str:
return self._service.describe_error(exc)
class MotionPoller(QObject):
"""GUI-thread facade over the worker."""
snapshotReady = Signal(object)
commandFinished = Signal(int, object)
commandFailed = Signal(int, str)
connectionChanged = Signal(bool)
def __init__(
self,
service: MotionService | None = None,
*,
axes: Iterable[int] | None = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
self.service = service or MotionService()
self._axes = list(axes) if axes is not None else list(
range(self.service.axis_count or PLATFORM_AXES)
)
self._thread = QThread()
self._worker = _Worker(self.service, self._axes)
self._worker.moveToThread(self._thread)
self._worker.snapshotReady.connect(self.snapshotReady)
self._worker.commandFinished.connect(self.commandFinished)
self._worker.commandFailed.connect(self.commandFailed)
self._worker.connected.connect(lambda _spec: self.connectionChanged.emit(True))
self._worker.disconnected.connect(lambda: self.connectionChanged.emit(False))
# Kicks the worker's event loop into life on the new thread.
self._thread.started.connect(self._on_thread_started)
self._thread.start()
self._interval_ms = DEFAULT_INTERVAL_MS
def _on_thread_started(self) -> None:
"""Ask the worker to build its timers, on its own thread.
``QMetaObject.invokeMethod`` with a queued connection is used rather
than calling :meth:`_Worker.warm_up` here: this slot runs on the GUI
thread (the receiver is ``self``), and a QTimer created here would then
belong to the GUI thread while being started and stopped from the
worker — which Qt refuses at run time.
"""
QMetaObject.invokeMethod(
self._worker, "warm_up", Qt.ConnectionType.QueuedConnection
)
@property
def interval_ms(self) -> int:
return self._interval_ms
def set_interval_ms(self, value: int) -> None:
"""Change the refresh rate. Callable from the GUI thread."""
self._interval_ms = max(20, int(value))
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
def set_axes(self, axes: Iterable[int]) -> None:
self._axes = list(axes)
self._submit_void(lambda worker: worker.set_axes(self._axes))
def set_language(self, language: str) -> None:
self._submit_void(lambda worker: worker.set_language(language))
def pause(self) -> None:
"""Stop the periodic state poll.
The connection is untouched: pausing only stops asking. The event loop
keeps turning, so queued commands and shutdown still work while paused.
"""
self._paused = True
self._submit_void(lambda worker: worker.stop_polling())
def resume(self) -> None:
"""Resume the periodic state poll after :meth:`pause`."""
self._paused = False
self._submit_void(lambda worker: worker.start_polling(self._interval_ms))
@property
def paused(self) -> bool:
return self._paused
#: Whether the caller has paused the poll (see :meth:`pause`).
_paused = False
def _submit_void(self, action: Callable[[_Worker], None]) -> None:
"""Hand a fire-and-forget call to the worker through its event loop."""
self._worker.submit(lambda _service: action(self._worker))
def submit(self, action: Callable[[MotionService], Any]) -> int:
"""Queue a service call. Returns the id it will be reported under."""
return self._worker.submit(action)
def connect_controller(self, spec) -> int:
return self.submit(lambda service: service.connect(spec))
def disconnect_controller(self) -> int:
def _disconnect(service: MotionService):
# Stop the axes before closing the handle: the reference program
# closes immediately and leaves the platform coasting.
service.stop_all()
service.disconnect()
return None
return self.submit(_disconnect)
def shutdown(self, timeout_ms: int = 5000) -> None:
"""Stop polling and join the thread. Safe to call more than once."""
if not self._thread.isRunning():
return
# Both hops must run on the worker thread, because that is where the
# timers were created. ``cleanup`` is blocking on purpose: it has to
# complete *before* ``quit`` can take the event loop down, and waiting
# for it also proves the worker has stopped touching the controller.
QMetaObject.invokeMethod(
self._worker, "cleanup", Qt.ConnectionType.BlockingQueuedConnection
)
self._thread.quit()
if not self._thread.wait(timeout_ms):
logger.warning(
"Poller thread did not stop within %sms; it will be left to the "
"process exit path",
timeout_ms,
)
@@ -0,0 +1,670 @@
"""Platform control logic: connection, bus init, axis commands.
Pure Python and Qt-free, so the whole guard/state-machine layer is testable
without a GUI and without hardware. :mod:`~orisys_autocal.motion.poller` drives
it from a worker thread; the UI only ever calls these methods.
Guards live here rather than in the UI on purpose. A motion command is refused
for a stated reason — bus not initialised, axis not idle, non-numeric input —
and :meth:`MotionService.jog` and friends return that reason as a
``MotionResult`` instead of silently doing nothing.
"""
from __future__ import annotations
import logging
import math
import time
import os
from typing import Callable, Iterable
from orisys_app_shared.zmotion import ZAuxError
from . import errors
from .models import (
INIT_FAILED,
INIT_RUNNING,
INIT_TIMEOUT_S,
AxisParams,
AxisState,
AxisStatusBits,
BusStatus,
BusType,
ConnectionSpec,
MotionResult,
Snapshot,
)
from .transport import DemoTransport, ZmcTransport, NativeTransport
from .units import (
DEFAULT_ACCEL,
DEFAULT_SPEED,
DEFAULT_UNITS,
PULSES_PER_MM,
safe_float,
user_units_to_mm,
)
logger = logging.getLogger(__name__)
#: The platform has five axes; the bus may report fewer if a drive is missing.
PLATFORM_AXES = 5
#: ``BUS_TYPE`` values written by the vendor ``.bas`` programs.
_BUS_TYPE_NAMES: dict[int, BusType] = {0: "ecat", 1: "rtex"}
class MotionService:
"""The controller session: one connection, five axes, one state machine."""
def __init__(
self,
transport: ZmcTransport | None = None,
*,
axis_count: int = PLATFORM_AXES,
units_per_mm: float = DEFAULT_UNITS,
pulses_per_mm: float = PULSES_PER_MM,
language: str = "zh_CN",
) -> None:
self._transport: ZmcTransport = transport or NativeTransport()
self.axis_count = int(axis_count)
self._units_per_mm = safe_float(units_per_mm, DEFAULT_UNITS)
self._pulses_per_mm = pulses_per_mm
self.language = language
self._spec: ConnectionSpec | None = None
self._bus = BusStatus()
self._params: dict[int, AxisParams] = {
axis: AxisParams() for axis in range(self.axis_count)
}
self._bas_loaded = False
self._init_started_at: float | None = None
self._init_timeout = INIT_TIMEOUT_S
self._home_started: dict[int, float] = {}
self._home_done: dict[int, bool] = {}
# ------------------------------------------------------------- properties
@property
def transport(self) -> ZmcTransport:
return self._transport
@property
def connected(self) -> bool:
return self._spec is not None
@property
def spec(self) -> ConnectionSpec | None:
return self._spec
@property
def bus(self) -> BusStatus:
return self._bus
@property
def bas_loaded(self) -> bool:
return self._bas_loaded
def params(self, axis: int) -> AxisParams:
return self._params.get(axis, AxisParams())
def set_language(self, language: str) -> None:
self.language = language
def _msg(self, code: int) -> str:
return errors.describe_with_hint(code, language=self.language)
def describe_error(self, exc: Exception) -> str:
"""Localised text for a failed call, for the UI and the poller.
A ``ZAuxError`` carries a controller code that :mod:`.errors` can
translate; anything else (a missing DLL, a socket error) is reported
as-is, which is where the "searched these paths" detail arrives.
"""
code = getattr(exc, "code", 0)
return self._msg(code) if code else str(exc)
# ------------------------------------------------------------ connect/init
def connect(self, spec: ConnectionSpec) -> MotionResult:
"""Open the controller.
A previous connection is closed first: switching transport type without
closing leaks the DLL handle, which the reference program does.
"""
if self._spec is not None:
self.disconnect()
try:
self._transport.open(spec)
except (ZAuxError, OSError, ValueError) as exc:
code = getattr(exc, "code", 0)
self._spec = None
message = self._msg(code) if code else str(exc)
logger.warning("Connect to %s failed: %s", spec.describe(), exc)
return MotionResult.failed(code, message)
self._spec = spec
logger.info("Connected: %s via %s", spec.describe(), self._transport.source)
# A controller may already have a BAS program running from a previous
# session; adopt it rather than claiming the bus is uninitialised.
self._adopt_existing_bas()
return MotionResult.success(f"已连接 {spec.describe()}" if self._is_zh else f"Connected {spec.describe()}")
@property
def _is_zh(self) -> bool:
return self.language == "zh_CN"
def _adopt_existing_bas(self) -> None:
try:
bus_type = self._transport.user_var("BUS_TYPE")
except (ZAuxError, OSError):
self._bas_loaded = False
self._bus = BusStatus(bus_type="unknown", init_status=INIT_RUNNING)
return
self._bas_loaded = True
self._bus = BusStatus(
bus_type=_BUS_TYPE_NAMES.get(int(bus_type), "unknown"),
init_status=INIT_RUNNING,
bas_loaded=True,
)
def disconnect(self) -> None:
try:
self._transport.close()
except (ZAuxError, OSError): # pragma: no cover - close is best-effort
logger.exception("Closing the controller connection failed")
self._spec = None
self._bas_loaded = False
self._init_started_at = None
self._home_started.clear()
self._home_done.clear()
self._bus = BusStatus()
def download_bas(self, filename: str, run_mode: int = 1) -> MotionResult:
"""Download a ``.bas`` program to the controller and start its scan.
``run_mode=1`` writes to ROM, so the controller re-runs the program on
power-up as well; the vendor ``ECAT.bas`` delays 3 s, scans the slot,
then sets ``Bus_InitStatus``. The UI follows that through
:func:`poll`.
"""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not filename or not os.path.isfile(filename):
return MotionResult.refused(
self._text(f"文件不存在: {filename}", f"File not found: {filename}")
)
try:
self._transport.bas_download(filename, run_mode)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
self._bas_loaded = False
self._init_started_at = None
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._bas_loaded = True
self._init_started_at = time.monotonic()
self._bus = BusStatus(
bus_type=self._bus.bus_type,
init_status=INIT_RUNNING,
bas_path=filename,
bas_loaded=True,
init_started_at=self._init_started_at,
)
# Read BUS_TYPE so the SDO/RTEX panels enable for the right family.
try:
bus_type = self._transport.user_var("BUS_TYPE")
self._bus = _replace_bus_type(self._bus, _BUS_TYPE_NAMES.get(int(bus_type), "unknown"))
except (ZAuxError, OSError):
pass
return MotionResult.success(
self._text(
"BAS 已下载,控制器将延时扫描总线",
"BAS downloaded; the controller will scan the bus after its delay",
)
)
def reinit_bus(self) -> MotionResult:
"""Re-run the BAS program's init task without re-downloading it."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._bas_loaded or not self._bus.bas_path:
return MotionResult.refused(
self._text(
"Bas文件未加载,请先下载 ECAT.bas 或 RTEX.bas",
"No BAS program loaded; download ECAT.bas or RTEX.bas first",
)
)
if self._bus.init_status == INIT_RUNNING and not self._bus.timed_out(time.monotonic()):
return MotionResult.refused(
self._text("总线正在初始化,请稍候", "Bus initialisation is already running")
)
script = os.path.basename(self._bus.bas_path)
try:
self._transport.execute("STOPTASK 2")
self._transport.execute(f'RUN "{script}",4')
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._init_started_at = time.monotonic()
self._bus = BusStatus(
bus_type=self._bus.bus_type,
init_status=INIT_RUNNING,
bas_path=self._bus.bas_path,
bas_loaded=True,
init_started_at=self._init_started_at,
)
return MotionResult.success(
self._text("正在重新初始化总线", "Re-initialising the bus")
)
def _text(self, zh: str, en: str) -> str:
return zh if self._is_zh else en
# ----------------------------------------------------------------- polling
def poll(self, axes: Iterable[int] | None = None) -> Snapshot:
"""Read bus and axis state for one UI refresh.
Never raises: a controller that has gone away produces a snapshot with
``error`` set and the last known values, because a poll runs on a timer
and an exception there would either kill the timer or spam it.
"""
if not self.connected:
return Snapshot(connected=False, spec=self._spec, bus=self._bus)
wanted = list(axes) if axes is not None else list(range(self.axis_count))
error = ""
try:
self._refresh_bus()
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
error = self._msg(code) if code else str(exc)
logger.warning("Bus poll failed: %s", exc)
states: list[AxisState] = []
for axis in wanted:
try:
states.append(self._read_axis(axis))
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
states.append(
AxisState(
axis=axis,
read_ok=False,
error=self._msg(code) if code else str(exc),
)
)
return Snapshot(
connected=True,
spec=self._spec,
bus=self._bus,
axes=tuple(states),
error=error,
)
def _refresh_bus(self) -> None:
now = time.monotonic()
bus = self._bus
init_started = self._init_started_at
# BUS_TYPE is present as soon as a BAS program has run at all.
bus_type = bus.bus_type
try:
raw_type = self._transport.user_var("BUS_TYPE")
bus_type = _BUS_TYPE_NAMES.get(int(raw_type), "unknown")
self._bas_loaded = True
except (ZAuxError, OSError):
# Between "downloaded" and the program's first instruction the
# globals do not exist yet, and they may never appear if the
# program failed to start. That is the timeout case, not a reason
# to forget that a download happened.
if init_started is None:
self._bas_loaded = False
if not self._bas_loaded:
self._bus = BusStatus(
bus_type=bus_type, init_status=INIT_RUNNING, bas_loaded=False
)
return
init_status = bus.init_status
node_count = bus.node_count
total_axes = bus.total_axes
message = ""
try:
init_status = int(self._transport.user_var("Bus_InitStatus"))
except (ZAuxError, OSError) as exc:
if init_started is not None and (now - init_started) > self._init_timeout:
# The vendor's own 25 s window has elapsed without the program
# publishing its globals: report a timeout, with the diagnostic
# the reference program prints, rather than raising on a timer.
init_status = INIT_FAILED
message = self._text(
"初始化超时:读不到 BUS_TYPE/Bus_InitStatus,"
"请用 ZDevelop 查看 ?*ETHERCAT",
"Initialisation timed out: BUS_TYPE/Bus_InitStatus unreadable. "
"Check ?*ETHERCAT in ZDevelop.",
)
logger.warning("Bus init timed out after %.0fs: %s", self._init_timeout, exc)
self._init_started_at = None
self._bas_loaded = False
else:
self._bus = BusStatus(
bus_type=bus_type,
init_status=INIT_RUNNING,
bas_path=bus.bas_path,
bas_loaded=True,
init_started_at=init_started,
)
return
if init_status != INIT_RUNNING:
self._init_started_at = None
try:
node_count = self._transport.bus_node_count(0)
total_axes = int(round(self._transport.user_var("Bus_TotalAxisnum")))
except (ZAuxError, OSError):
pass
self._bus = BusStatus(
bus_type=bus_type,
init_status=init_status,
node_count=node_count,
total_axes=total_axes,
bas_path=bus.bas_path,
bas_loaded=True,
init_started_at=self._init_started_at,
message=message,
)
def _read_axis(self, axis: int) -> AxisState:
transport = self._transport
status_raw = transport.get_axis_status(axis)
idle = transport.get_if_idle(axis) == -1
status = AxisStatusBits(status_raw)
# Track the homing edge the same way the reference program does: bit 6
# set means homing is running, and the falling edge means ask the drive
# whether it actually completed. Between those two, report the last
# known answer rather than claiming homing either way.
home_status = 1 if self._home_done.get(axis, False) else 0
if status.home_in_progress:
self._home_started[axis] = self._home_started.get(axis, time.monotonic())
self._home_done[axis] = False
home_status = 0
elif axis in self._home_started:
del self._home_started[axis]
try:
home_status = int(transport.bus_home_status(axis))
except (ZAuxError, OSError):
home_status = 0
self._home_done[axis] = home_status == 1
params = self.params(axis)
return AxisState(
axis=axis,
enabled=transport.get_axis_enable(axis) != 0,
atype=transport.get_atype(axis),
units=transport.get_units(axis),
speed=transport.get_speed(axis),
accel=transport.get_accel(axis),
dpos=transport.get_dpos(axis),
mpos=transport.get_mpos(axis),
status=status,
idle=idle,
home_status=home_status,
read_ok=True,
)
# -------------------------------------------------------------- axis setup
def set_axis_params(self, axis: int, params: AxisParams) -> MotionResult:
"""Apply units/speed/accel to the controller and remember them."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.set_units(axis, params.units)
self._transport.set_speed(axis, params.speed)
self._transport.set_accel(axis, params.accel)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._params[axis] = params
return MotionResult.success()
def set_axis_enable(self, axis: int, enabled: bool) -> MotionResult:
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.set_axis_enable(axis, enabled)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(
self._text(f"轴{axis} 已{'使能' if enabled else '关闭'}",
f"Axis {axis} {'enabled' if enabled else 'disabled'}")
)
def clear_alarm(self, axis: int) -> MotionResult:
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.bus_drive_clear(axis, 0)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(self._text("已清除报警", "Alarm cleared"))
# ------------------------------------------------------------------ motion
def _motion_guard(self, axis: int) -> MotionResult | None:
"""Return the refusal for a motion command, or ``None`` if it may run."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
if not self._bus.initialized:
return MotionResult.refused(
self._text(
"总线未初始化完成,禁止运动",
"Bus is not initialised; motion is refused",
)
)
return None
def jog(self, axis: int, forward: bool) -> MotionResult:
refusal = self._motion_guard(axis)
if refusal is not None:
return refusal
try:
self._transport.jog(axis, forward)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(
self._text("正转" if forward else "反转", "Forward" if forward else "Reverse")
)
def move_abs(self, axis: int, target: float) -> MotionResult:
return self._move(axis, target, absolute=True)
def move_rel(self, axis: int, distance: float) -> MotionResult:
return self._move(axis, distance, absolute=False)
def _move(self, axis: int, value: float, *, absolute: bool) -> MotionResult:
refusal = self._motion_guard(axis)
if refusal is not None:
return refusal
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
return MotionResult.refused(
self._text("目标位置请输入数字", "Enter a numeric position")
)
try:
if absolute:
self._transport.move_abs(axis, float(value))
else:
self._transport.move_rel(axis, float(value))
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success()
def stop(self, axis: int) -> MotionResult:
"""Stop one axis (decelerating stop, mode 2)."""
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if not self._known_axis(axis):
return MotionResult.refused(self._axis_range_message(axis))
try:
self._transport.stop(axis, 2)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success(self._text("已停止", "Stopped"))
def stop_all(self) -> list[MotionResult]:
"""Stop every axis. Used before disconnecting."""
return [self.stop(axis) for axis in range(self.axis_count)]
def home(self, axis: int) -> MotionResult:
"""Start driver homing (EtherCAT only, as the vendor example notes).
Speeds and offset are written before the mode: the controller latches
them when homing starts, so setting them afterwards has no effect on
the move already in progress.
"""
refusal = self._motion_guard(axis)
if refusal is not None:
return refusal
if not self._bus.supports_sdo:
return MotionResult.refused(
self._text("rtex不支持回零", "Homing is not supported over RTEX")
)
params = self.params(axis)
if params.home_mode <= 0:
return MotionResult.refused(
self._text(
f"轴{axis} 回零模式未设置(0 不是有效模式)",
f"Axis {axis} has no homing mode set (0 is not a valid mode)",
)
)
try:
self._transport.set_speed(axis, params.home_high)
self._transport.set_creep(axis, params.home_low)
self._transport.bus_set_datum_offset(axis, params.home_offset)
self._transport.bus_datum(axis, params.home_mode)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
self._home_started[axis] = time.monotonic()
self._home_done[axis] = False
return MotionResult.success(self._text("回零已启动", "Homing started"))
def _known_axis(self, axis: int) -> bool:
return 0 <= int(axis) < self.axis_count
def _axis_range_message(self, axis: int) -> str:
return self._text(
f"轴号超出范围 (0-{self.axis_count - 1})",
f"Axis out of range (0-{self.axis_count - 1})",
)
# ---------------------------------------------------------------- bus data
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int
) -> tuple[MotionResult, int]:
"""Read an EtherCAT object; returns the result and the value read."""
refusal = self._bus_data_guard("ecat")
if refusal is not None:
return refusal, 0
try:
value = self._transport.sdo_read(node, index, subindex, data_type)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0
return MotionResult.success(), int(value)
def sdo_write(
self, node: int, index: int, subindex: int, data_type: int, value: int
) -> MotionResult:
return self._bus_write(
"ecat",
lambda: self._transport.sdo_write(node, index, subindex, data_type, int(value)),
)
def rtex_read(self, axis: int, parameter: int) -> tuple[MotionResult, float]:
refusal = self._bus_data_guard("rtex")
if refusal is not None:
return refusal, 0.0
try:
value = self._transport.rtex_read(axis, parameter)
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc)), 0.0
return MotionResult.success(), float(value)
def rtex_write(self, axis: int, parameter: int, value: float) -> MotionResult:
return self._bus_write(
"rtex", lambda: self._transport.rtex_write(axis, parameter, float(value))
)
def _bus_data_guard(self, family: BusType) -> MotionResult | None:
if not self.connected:
return MotionResult.refused(self._text("未连接控制器", "Not connected"))
if self._bus.bus_type == "unknown":
return MotionResult.refused(
self._text(
"总线类型未知,请先初始化总线",
"Bus type is unknown; initialise the bus first",
)
)
if self._bus.bus_type != family:
other = "ETHERCAT" if family == "rtex" else "RTEX"
return MotionResult.refused(
self._text(f"非{other}模块", f"This is not a {other} module")
)
return None
def _bus_write(self, family: BusType, action: Callable[[], None]) -> MotionResult:
refusal = self._bus_data_guard(family)
if refusal is not None:
return refusal
try:
action()
except (ZAuxError, OSError) as exc:
code = getattr(exc, "code", 0)
return MotionResult.failed(code, self._msg(code) if code else str(exc))
return MotionResult.success()
# ------------------------------------------------------------------ display
def to_mm(self, axis: int, user_units: float) -> float:
"""Displayed millimetres for a reading. Derived, never a measurement."""
units = self.params(axis).units or self._units_per_mm
return user_units_to_mm(
user_units, units=units, pulses_per_mm=self._pulses_per_mm
)
def _replace_bus_type(bus: BusStatus, bus_type: BusType) -> BusStatus:
return BusStatus(
bus_type=bus_type,
init_status=bus.init_status,
node_count=bus.node_count,
total_axes=bus.total_axes,
bas_path=bus.bas_path,
bas_loaded=bus.bas_loaded,
init_started_at=bus.init_started_at,
message=bus.message,
)
@@ -0,0 +1,467 @@
"""The seam between the platform-control logic and the controller DLL.
:class:`ZmcTransport` is what :mod:`~orisys_autocal.motion.service` talks to.
Contract: every method **raises** :class:`~orisys_app_shared.zmotion.ZAuxError`
when the controller returns non-zero, and returns plain Python values (never
ctypes objects) when it succeeds. That lets the service have one error path and
lets the tests inject a fake that fails on demand.
Three implementations:
* :class:`NativeTransport` — the real controller, via the vendored DLL.
* :class:`DemoTransport` — a self-consistent simulated platform, so the whole
UI can be exercised with no hardware and no DLL.
* ``FakeTransport`` in the app's test suite — the same protocol, driven by the
test rather than by a simulation.
"""
from __future__ import annotations
import math
import time
from typing import Protocol, runtime_checkable
from orisys_app_shared.zmotion import ZAuxError, ZmotionClient
from .errors import ERR_AUX_OFFSET
from .models import BusType, ConnectionSpec
@runtime_checkable
class ZmcTransport(Protocol):
"""What the service needs from a controller connection."""
#: Human-readable source, e.g. the DLL directory or ``"demo"``.
source: str
def open(self, spec: ConnectionSpec) -> None: ...
def close(self) -> None: ...
def search_eth(self) -> list[str]: ...
def bas_download(self, filename: str, run_mode: int = 1) -> None: ...
def execute(self, command: str) -> None: ...
def user_var(self, name: str) -> float: ...
def bus_node_count(self, slot: int = 0) -> int: ...
def bus_home_status(self, axis: int) -> int: ...
def bus_datum(self, axis: int, home_mode: int) -> None: ...
def bus_set_datum_offset(self, axis: int, value: float) -> None: ...
def bus_drive_clear(self, axis: int, mode: int = 0) -> None: ...
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
) -> int: ...
def sdo_write(
self,
node: int,
index: int,
subindex: int,
data_type: int,
value: int,
slot: int = 0,
) -> None: ...
def rtex_read(self, axis: int, parameter: int) -> float: ...
def rtex_write(self, axis: int, parameter: int, value: float) -> None: ...
def set_axis_enable(self, axis: int, enabled: bool) -> None: ...
def set_units(self, axis: int, value: float) -> None: ...
def get_units(self, axis: int) -> float: ...
def set_speed(self, axis: int, value: float) -> None: ...
def get_speed(self, axis: int) -> float: ...
def set_accel(self, axis: int, value: float) -> None: ...
def get_accel(self, axis: int) -> float: ...
def set_creep(self, axis: int, value: float) -> None: ...
def get_atype(self, axis: int) -> int: ...
def get_axis_enable(self, axis: int) -> int: ...
def get_dpos(self, axis: int) -> float: ...
def get_mpos(self, axis: int) -> float: ...
def get_axis_status(self, axis: int) -> int: ...
def get_if_idle(self, axis: int) -> int: ...
def jog(self, axis: int, forward: bool) -> None: ...
def move_abs(self, axis: int, position: float) -> None: ...
def move_rel(self, axis: int, distance: float) -> None: ...
def stop(self, axis: int, mode: int = 2) -> None: ...
class NativeTransport:
"""The real controller, through the vendored ZMotion runtime."""
def __init__(self, client: ZmotionClient | None = None) -> None:
self._client = client or ZmotionClient()
@property
def client(self) -> ZmotionClient:
return self._client
@property
def source(self) -> str:
return self._client.dll_dir or "zauxdll.dll (not loaded)"
def open(self, spec: ConnectionSpec) -> None:
if spec.mode == "eth":
result = self._client.open_eth(spec.ip)
elif spec.mode == "com":
result = self._client.open_com(spec.com_id)
elif spec.mode == "pci":
result = self._client.open_pci(spec.card_num)
elif spec.mode == "local":
result = self._client.fast_open(5, spec.local_string, 1000)
else:
raise ValueError(f"{spec.mode!r} is not a native connection mode")
result.raise_for_status(f"open {spec.describe()}")
def close(self) -> None:
self._client.close()
def search_eth(self) -> list[str]:
value = self._client.search_eth()
# A scan that finds nothing is not an error: the reference program
# lists the configured defaults and moves on.
if not value.ok:
return []
return [item for item in str(value.value).split() if item]
def bas_download(self, filename: str, run_mode: int = 1) -> None:
self._client.bas_download(filename, run_mode).raise_for_status("BAS download")
def execute(self, command: str) -> None:
self._client.execute(command).raise_for_status(command)
def user_var(self, name: str) -> float:
return float(self._client.get_user_var(name).raise_for_status(name).value)
def bus_node_count(self, slot: int = 0) -> int:
return int(self._client.bus_node_count(slot).raise_for_status().value)
def bus_home_status(self, axis: int) -> int:
return int(self._client.bus_home_status(axis).raise_for_status().value)
def bus_datum(self, axis: int, home_mode: int) -> None:
self._client.bus_datum(axis, home_mode).raise_for_status("datum")
def bus_set_datum_offset(self, axis: int, value: float) -> None:
self._client.bus_set_datum_offset(axis, value).raise_for_status()
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
self._client.bus_drive_clear(axis, mode).raise_for_status()
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
) -> int:
return int(
self._client.sdo_read(slot, node, index, subindex, data_type)
.raise_for_status("SDO read")
.value
)
def sdo_write(
self,
node: int,
index: int,
subindex: int,
data_type: int,
value: int,
slot: int = 0,
) -> None:
self._client.sdo_write(slot, node, index, subindex, data_type, value).raise_for_status(
"SDO write"
)
def rtex_read(self, axis: int, parameter: int) -> float:
return float(self._client.rtex_read(axis, parameter).raise_for_status().value)
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
self._client.rtex_write(axis, parameter, value).raise_for_status()
def set_axis_enable(self, axis: int, enabled: bool) -> None:
self._client.set_axis_enable(axis, 1 if enabled else 0).raise_for_status()
def set_units(self, axis: int, value: float) -> None:
self._client.set_units(axis, value).raise_for_status()
def get_units(self, axis: int) -> float:
return float(self._client.get_units(axis).raise_for_status().value)
def set_speed(self, axis: int, value: float) -> None:
self._client.set_speed(axis, value).raise_for_status()
def get_speed(self, axis: int) -> float:
return float(self._client.get_speed(axis).raise_for_status().value)
def set_accel(self, axis: int, value: float) -> None:
self._client.set_accel(axis, value).raise_for_status()
def get_accel(self, axis: int) -> float:
return float(self._client.get_accel(axis).raise_for_status().value)
def set_creep(self, axis: int, value: float) -> None:
self._client.set_creep(axis, value).raise_for_status()
def get_atype(self, axis: int) -> int:
return int(self._client.get_atype(axis).raise_for_status().value)
def get_axis_enable(self, axis: int) -> int:
return int(self._client.get_axis_enable(axis).raise_for_status().value)
def get_dpos(self, axis: int) -> float:
return float(self._client.get_dpos(axis).raise_for_status().value)
def get_mpos(self, axis: int) -> float:
return float(self._client.get_mpos(axis).raise_for_status().value)
def get_axis_status(self, axis: int) -> int:
return int(self._client.get_axis_status(axis).raise_for_status().value)
def get_if_idle(self, axis: int) -> int:
return int(self._client.get_if_idle(axis).raise_for_status().value)
def jog(self, axis: int, forward: bool) -> None:
self._client.single_vmove(axis, 1 if forward else -1).raise_for_status()
def move_abs(self, axis: int, position: float) -> None:
self._client.single_move_abs(axis, position).raise_for_status()
def move_rel(self, axis: int, distance: float) -> None:
self._client.single_move(axis, distance).raise_for_status()
def stop(self, axis: int, mode: int = 2) -> None:
self._client.single_cancel(axis, mode).raise_for_status()
class DemoTransport:
"""A self-consistent simulated 5-axis platform.
Not a mock: it keeps position, enable state and motion state so the UI's
polling, jogging and stop behaviour are all exercised without hardware.
``BUS_TYPE`` reports EtherCAT so the SDO panel is live too.
"""
#: Where jogging stops, in user units — keeps a demo jog bounded.
TRAVEL_LIMIT = 5000.0
def __init__(self, *, axes: int = 5) -> None:
self._axes = int(axes)
self._enabled = {axis: True for axis in range(self._axes)}
self._units = {axis: 10.0 for axis in range(self._axes)}
self._speed = {axis: 1000.0 for axis in range(self._axes)}
self._accel = {axis: 10000.0 for axis in range(self._axes)}
self._dpos = {axis: 0.0 for axis in range(self._axes)}
self._moving_until = {axis: 0.0 for axis in range(self._axes)}
self._homing_until = {axis: 0.0 for axis in range(self._axes)}
self._home_done = {axis: False for axis in range(self._axes)}
self._bas_loaded = False
self._init_started: float | None = None
self._opened = False
self._sdo: dict[tuple[int, int, int], int] = {}
@property
def source(self) -> str:
return "demo (simulated platform, no hardware)"
def open(self, spec: ConnectionSpec) -> None:
if spec.mode != "demo":
raise ZAuxError(ERR_DEMO_UNSUPPORTED, "DemoTransport only serves demo mode")
self._opened = True
def close(self) -> None:
self._opened = False
def search_eth(self) -> list[str]:
return ["127.0.0.1"]
def bas_download(self, filename: str, run_mode: int = 1) -> None:
if not self._opened:
raise ZAuxError(ERR_NOT_OPEN, "not connected")
self._bas_loaded = True
# ECAT.bas delays 3 s before scanning; mimic the delay so the UI's
# "initialising" state is visible rather than instantaneous.
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
def execute(self, command: str) -> None:
if not self._opened:
raise ZAuxError(ERR_NOT_OPEN, "not connected")
if "RUN" in command.upper() and self._bas_loaded:
self._init_started = time.monotonic() + DEMO_INIT_DELAY_S
def user_var(self, name: str) -> float:
if not self._bas_loaded:
# Mirrors the real controller: unknown/absent variable.
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
if name == "BUS_TYPE":
return 0.0
if name == "Bus_TotalAxisnum":
return float(self._axes)
if name == "Bus_InitStatus":
if self._init_started is not None and time.monotonic() >= self._init_started:
return 1.0
return -1.0
raise ZAuxError(ERR_AUX_OFFSET + 2, f"{name} is not defined")
def _require_axis(self, axis: int) -> None:
if not (0 <= axis < self._axes):
raise ZAuxError(ERR_AUX_OFFSET + 2, f"axis {axis} does not exist")
def bus_node_count(self, slot: int = 0) -> int:
return self._axes
def bus_home_status(self, axis: int) -> int:
self._require_axis(axis)
return 1 if self._home_done[axis] else 0
def bus_datum(self, axis: int, home_mode: int) -> None:
self._require_axis(axis)
self._homing_until[axis] = time.monotonic() + DEMO_HOME_S
def bus_set_datum_offset(self, axis: int, value: float) -> None:
self._require_axis(axis)
def bus_drive_clear(self, axis: int, mode: int = 0) -> None:
self._require_axis(axis)
def sdo_read(
self, node: int, index: int, subindex: int, data_type: int, slot: int = 0
) -> int:
return self._sdo.get((node, index, subindex), 0)
def sdo_write(
self,
node: int,
index: int,
subindex: int,
data_type: int,
value: int,
slot: int = 0,
) -> None:
self._sdo[(node, index, subindex)] = int(value)
def rtex_read(self, axis: int, parameter: int) -> float:
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
def rtex_write(self, axis: int, parameter: int, value: float) -> None:
raise ZAuxError(ERR_AUX_OFFSET + 3, "RTEX is not available in demo mode")
def set_axis_enable(self, axis: int, enabled: bool) -> None:
self._require_axis(axis)
self._enabled[axis] = bool(enabled)
def set_units(self, axis: int, value: float) -> None:
self._require_axis(axis)
self._units[axis] = float(value)
def get_units(self, axis: int) -> float:
self._require_axis(axis)
return self._units[axis]
def set_speed(self, axis: int, value: float) -> None:
self._require_axis(axis)
self._speed[axis] = float(value)
def get_speed(self, axis: int) -> float:
self._require_axis(axis)
return self._speed[axis]
def set_accel(self, axis: int, value: float) -> None:
self._require_axis(axis)
self._accel[axis] = float(value)
def get_accel(self, axis: int) -> float:
self._require_axis(axis)
return self._accel[axis]
def set_creep(self, axis: int, value: float) -> None:
self._require_axis(axis)
def get_atype(self, axis: int) -> int:
self._require_axis(axis)
return 65 # position mode, as ECAT.bas configures
def get_axis_enable(self, axis: int) -> int:
self._require_axis(axis)
return 1 if self._enabled[axis] else 0
def get_dpos(self, axis: int) -> float:
self._require_axis(axis)
return self._dpos[axis]
def get_mpos(self, axis: int) -> float:
self._require_axis(axis)
# Follow the command position with a small lag while moving.
return self._dpos[axis] * 0.999
def get_axis_status(self, axis: int) -> int:
self._require_axis(axis)
status = 0
if self._homing_until[axis] > time.monotonic():
status |= 64 # homing in progress
if self._moving_until[axis] > time.monotonic():
status |= 2 # moving
return status
def get_if_idle(self, axis: int) -> int:
self._require_axis(axis)
moving = self._moving_until[axis] > time.monotonic()
homing = self._homing_until[axis] > time.monotonic()
return 0 if (moving or homing) else -1
def jog(self, axis: int, forward: bool) -> None:
self._require_axis(axis)
self._moving_until[axis] = time.monotonic() + DEMO_JOG_S
def move_abs(self, axis: int, position: float) -> None:
self._require_axis(axis)
if not math.isfinite(position):
raise ZAuxError(ERR_AUX_OFFSET + 2, "position is not finite")
self._dpos[axis] = float(position)
self._moving_until[axis] = time.monotonic() + DEMO_MOVE_S
def move_rel(self, axis: int, distance: float) -> None:
self._require_axis(axis)
self.move_abs(axis, self._dpos[axis] + float(distance))
def stop(self, axis: int, mode: int = 2) -> None:
self._require_axis(axis)
self._moving_until[axis] = 0.0
self._homing_until[axis] = 0.0
self._home_done[axis] = True
def bus_type(self) -> BusType:
return "ecat"
#: Demo timing constants (seconds), chosen so a human sees each state.
DEMO_INIT_DELAY_S = 1.5
DEMO_JOG_S = 1.0
DEMO_MOVE_S = 0.8
DEMO_HOME_S = 2.0
#: Demo-only failure codes, kept outside the documented vendor range on purpose.
ERR_NOT_OPEN = 30500
ERR_DEMO_UNSUPPORTED = 30501
@@ -0,0 +1,72 @@
"""User-unit to millimetre conversion.
The numbers come from bench measurement on the platform, transcribed from the
vendor reference program's comments::
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
# 当量5 → 1000单位=1mm,不是100单位=1mm
so ``PULSES_PER_MM = 5000`` is the calibration constant and ``UNITS = 10`` is
the pulse equivalent the platform is configured with. The displayed millimetre
value is therefore *derived*, not measured: the UI labels it as such, and no
code path may treat it as a calibrated length.
"""
from __future__ import annotations
import math
#: Measured: 5000 pulses per millimetre (see the module docstring).
PULSES_PER_MM = 5000.0
#: Pulse equivalent the platform is configured with by default.
DEFAULT_UNITS = 10.0
#: Move-parameter defaults, matching the reference program's UI initialisation.
DEFAULT_SPEED = 1000.0
DEFAULT_ACCEL = 10000.0
def safe_float(text: object, fallback: float) -> float:
"""Parse ``text`` as a finite float, falling back on anything unusable.
A blank, non-numeric, zero or non-finite entry must not silently become a
pulse equivalent of zero — on a motion controller that is a divide-by-zero
waiting to happen, or an unbounded move.
"""
try:
value = float(str(text).strip())
except (TypeError, ValueError):
return fallback
if not math.isfinite(value) or value == 0.0:
return fallback
return value
def user_units_to_mm(
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
) -> float:
"""Convert a controller user-unit value to millimetres.
Args:
value: a ``DPOS``/``MPOS`` reading or move distance in user units.
units: pulse equivalent in force on the axis.
pulses_per_mm: the platform's calibration constant.
Returns:
Millimetres. Not finite when the inputs are not usable, so a caller
that forgets to validate sees an obvious NaN rather than a plausible
wrong number.
"""
if pulses_per_mm <= 0:
return math.nan
return float(value) * float(units) / float(pulses_per_mm)
def mm_to_user_units(
value: float, *, units: float = DEFAULT_UNITS, pulses_per_mm: float = PULSES_PER_MM
) -> float:
"""Inverse of :func:`user_units_to_mm` (for an mm-entered move)."""
if units <= 0:
return math.nan
return float(value) * float(pulses_per_mm) / float(units)
@@ -0,0 +1,179 @@
"""AutoCal's preference schema and storage.
The shared store keys off whatever schema it is handed, so AutoCal gets its own
key set rather than inheriting the studio's camera/plot/3D keys. The values here
are the ones an operator would otherwise retype at every start-up.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from orisys_app_shared import prefs as _prefs
from orisys_app_shared.paths import preference_path
from .motion.service import PLATFORM_AXES
from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS
#: Connection modes, in the order the combo offers them.
CONNECTION_MODES = ("eth", "com", "pci", "local", "demo")
#: Addresses the controller is normally reached on, tried first by a scan.
DEFAULT_CONTROLLER_IPS = ("192.168.0.11", "127.0.0.1")
#: Grid modes the sequence profile offers (workflow P5).
SURFACE_MODES = ("flat", "curved")
#: Point visiting orders (workflow P5).
POINT_ORDERS = ("row", "serpentine", "distance")
#: The design target is a well-lit bench, so light is the default; dark stays
#: selectable for a dim room.
DEFAULT_THEME = "light"
def _default_sequence_profile() -> dict[str, Any]:
"""The point-generation and run parameters of workflow P5.
One profile is global for now; named, saved sequences come with the runner.
The numbers are starting points an operator confirms at the bench, not
measured values — in particular ``home_mode``-style unknowns are left at 0 so
nothing is silently inherited from another machine.
"""
return {
# --- grid shape
"mode": "flat",
"pitch_mm": 2.0,
"margin_mm": 1.0,
"radius_mm": 0.0,
# --- motion
"clear_height_mm": 5.0,
"approach_speed_mm_s": 2.0,
"press_speed_mm_s": 0.5,
"press_depth_mm": 0.2,
"dwell_s": 0.5,
# --- measurement
"settle_window_s": 0.5,
"repeats": 3,
# --- order
"order": "serpentine",
}
def _default_safety_limits() -> dict[str, float]:
"""Workflow P0: the limits that must have a value before anything moves.
``0`` means "not set" for the ones where a real value is required, and the
app reports that at the P0 gate rather than treating it as unlimited.
"""
return {
"max_travel_mm": 100.0,
"max_descent_mm": 10.0,
"max_press_depth_mm": 1.0,
"follow_error_mm": 0.5,
#: How far the axis may descend looking for contact before the run is
#: aborted (workflow P7, no-contact abort).
"contact_search_mm": 3.0,
}
def _default_sensor_setup() -> dict[str, Any]:
"""Workflow P3/P4: what is under test, what touches it, and the transform.
``sensor_to_platform`` stays empty until the transform is established by
probing known features; the residual travels with it so a re-fixtured sensor
cannot silently reuse a stale transform.
"""
return {
"sensor_type": "box",
"config_ref": "",
"geometry_id": "",
#: Which axis presses, and the probe tip offset from the platform's
#: reference point. Empty offset means "not set", which blocks P5.
"press_axis": 2,
"probe_tip_offset_mm": [0.0, 0.0, 0.0],
"probe_tip_set": False,
#: 4x4 row-major sensor -> platform transform, plus how well it fitted.
"sensor_to_platform": [],
"transform_residual_mm": 0.0,
"transform_feature_count": 0,
}
def _default_axis_params() -> dict[str, dict[str, float | int]]:
"""One parameter block per platform axis.
``home_mode`` starts at 0, which is **not** a valid CiA-402 homing mode:
the operator must choose one for the drive in front of them rather than
inherit a number from another machine. The reference program pre-filled 17,
which is a specific drive's mode, so it is deliberately not copied here.
"""
return {
str(axis): {
"units": DEFAULT_UNITS,
"speed": DEFAULT_SPEED,
"accel": DEFAULT_ACCEL,
"creep": 10.0,
"home_mode": 0,
"home_high": 50.0,
"home_low": 10.0,
"home_offset": 0.0,
}
for axis in range(PLATFORM_AXES)
}
def default_preferences() -> dict[str, Any]:
"""AutoCal's default preference document."""
return {
"language": "zh_CN",
"theme": DEFAULT_THEME,
"window_geometry": None,
"connection": {
"mode": "eth",
"ip": DEFAULT_CONTROLLER_IPS[0],
"com_id": 0,
"card_num": 0,
"local_string": "0",
},
# Remembered so "download the BAS program" is one click after the first
# time, instead of re-picking the file every session.
"bas_path": "",
#: Displayed millimetres are derived from this; it is a bench-measured
#: constant, not a calibrated length (see motion/units.py).
"units_per_mm": DEFAULT_UNITS,
"state_poll_ms": 100,
"axis_params": _default_axis_params(),
#: {node, index, subindex, type} of the last SDO read/written, so the
#: operator can repeat a drive-setting check quickly.
"sdo_last": {"node": 0, "index": 24704, "subindex": 0, "type": 0},
"rtex_last": {"axis": 0, "parameter": 0},
"show_diagnostics": False,
# --- workflow blocks (see apps/autocal/docs/workflow.md) --------------
#: P0: limits enforced from P2 onward.
"safety_limits": _default_safety_limits(),
#: P3/P4: sensor under test, probe tip, sensor<->platform transform.
"sensor_setup": _default_sensor_setup(),
#: P5: grid generation and run parameters.
"sequence_profile": _default_sequence_profile(),
}
def load_preferences() -> dict[str, Any]:
"""Stored preferences with AutoCal's defaults filling the gaps."""
prefs = _prefs.load_preferences(default_preferences())
# A stored document from an older build may miss a newly added axis block.
axis_params = prefs.setdefault("axis_params", {})
for axis, values in _default_axis_params().items():
axis_params.setdefault(axis, values)
return prefs
def save_preferences(prefs: dict[str, Any]) -> Path:
return _prefs.save_preferences(prefs, default_preferences())
def path() -> Path:
"""Where AutoCal's ``preference.json`` lives (for the About box)."""
return preference_path()
@@ -0,0 +1,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])
+137
View File
@@ -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
+185
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
"""Controller return-code translation.
The vendor SDK header defines the auxiliary error block at 30000; everything
else is the firmware's own number. The point of these tests is that AutoCal
never invents a meaning for a code it cannot read out of that header.
"""
from __future__ import annotations
import pytest
from orisys_autocal.motion import errors
DOCUMENTED = sorted(errors.MESSAGES)
def test_zero_is_success_in_both_languages():
assert errors.describe(0) == "成功"
assert errors.describe(0, language="en_US") == "OK"
def test_the_documented_block_is_the_vendor_offset():
assert errors.ERR_AUX_OFFSET == 30000
assert DOCUMENTED == [30000, 30001, 30002, 30003, 30004, 30005]
@pytest.mark.parametrize("code", DOCUMENTED)
def test_every_documented_code_has_both_languages(code):
zh = errors.describe(code)
en = errors.describe(code, language="en_US")
assert zh and en and zh != en
@pytest.mark.parametrize("code", DOCUMENTED)
def test_no_documented_message_is_empty(code):
assert errors.describe(code).strip()
assert errors.describe(code, language="en_US").strip()
def test_an_unknown_code_keeps_its_number():
"""A guessed meaning on a motion controller is worse than 'unknown'."""
message = errors.describe(12345)
assert "12345" in message
assert message == "错误码 12345"
def test_an_unknown_code_points_at_zdevelop():
message = errors.describe_with_hint(-1)
assert "-1" in message
assert errors.UNKNOWN_HINT in message
def test_a_documented_code_gets_no_hint_appended():
assert errors.describe_with_hint(30000) == errors.describe(30000)
+469
View File
@@ -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
+328
View File
@@ -0,0 +1,328 @@
"""Guard and state-machine behaviour of the motion service.
Driven against the simulated platform, so every assertion here is about AutoCal's
own logic: what it refuses, what it reads, and when. No test in this file touches
a controller or a DLL.
"""
from __future__ import annotations
import time
import pytest
from orisys_autocal.motion import (
INIT_FAILED,
INIT_OK,
INIT_RUNNING,
AxisParams,
ConnectionSpec,
DemoTransport,
MotionService,
)
@pytest.fixture
def service():
return MotionService(DemoTransport())
def _initialise(service, timeout: float = 5.0) -> None:
"""Download a BAS program and wait for the simulated bus to report ready."""
service.download_bas(__file__, 1)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline and service.bus.init_status != INIT_OK:
service.poll([0])
time.sleep(0.05)
# ----------------------------------------------------------------- connection
def test_a_fresh_service_is_disconnected_and_touches_nothing(service):
assert not service.connected
assert not service.bas_loaded
assert service.poll([0]).connected is False
def test_connect_reports_success_and_an_unknown_bus_without_a_bas(service):
result = service.connect(ConnectionSpec(mode="demo"))
assert result.ok
assert service.connected
# No BAS program has run, so the bus type must not be guessed at.
snapshot = service.poll([0])
assert snapshot.bus.bus_type == "unknown"
assert not snapshot.bus.initialized
def test_connect_failure_is_reported_without_raising(service):
result = service.connect(ConnectionSpec(mode="eth", ip="127.0.0.1"))
assert not result.ok
assert not service.connected
def test_disconnect_clears_bus_state(service):
service.connect(ConnectionSpec(mode="demo"))
service.disconnect()
assert not service.connected
assert service.bus.bus_type == "unknown"
# ---------------------------------------------------------------- bus init
def test_download_requires_a_real_file(service):
service.connect(ConnectionSpec(mode="demo"))
assert not service.download_bas("", 1).ok
assert not service.download_bas(r"D:\nope\missing.bas", 1).ok
def test_download_needs_a_connection(service):
assert not service.download_bas(__file__, 1).ok
def test_download_then_poll_reaches_initialised(service):
service.connect(ConnectionSpec(mode="demo"))
assert service.download_bas(__file__, 1).ok
_initialise(service)
assert service.bus.init_status == INIT_OK
assert service.bus.bus_type == "ecat"
assert service.bus.total_axes == 5
assert service.bus.node_count == 5
def test_bus_is_running_immediately_after_download(service):
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
assert service.bus.init_status == INIT_RUNNING
def test_reinitialise_is_refused_while_already_running(service):
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
assert not service.reinit_bus().ok
def test_reinitialise_is_refused_without_a_bas_program(service):
service.connect(ConnectionSpec(mode="demo"))
result = service.reinit_bus()
assert not result.ok
assert "BAS" in result.message or "Bas" in result.message
def test_init_timeout_is_reported_rather_than_raising():
"""A controller that never publishes its globals must time out, not hang."""
class SilentTransport(DemoTransport):
def user_var(self, name):
from orisys_app_shared.zmotion import ZAuxError
raise ZAuxError(30002, f"{name} is not defined")
service = MotionService(SilentTransport())
service._init_timeout = 0.05 # noqa: SLF001 - shortening the vendor window
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
time.sleep(0.1)
snapshot = service.poll([0])
assert snapshot.bus.init_status == INIT_FAILED
assert snapshot.bus.message # carries the ZDevelop hint, not an empty string
# ------------------------------------------------------------------- guards
def test_motion_is_refused_before_the_bus_is_initialised(service):
service.connect(ConnectionSpec(mode="demo"))
for attempt in (
service.jog(0, True),
service.move_abs(0, 100.0),
service.move_rel(0, 10.0),
service.home(0),
):
assert not attempt.ok
assert attempt.message
def test_motion_is_refused_when_not_connected(service):
assert not service.jog(0, True).ok
assert not service.move_abs(0, 1.0).ok
assert not service.stop(0).ok
def test_motion_on_an_unknown_axis_is_refused(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.jog(99, True)
assert not result.ok
assert "99" in result.message or "0-4" in result.message
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
def test_a_non_finite_target_is_refused(service, bad):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.move_abs(0, bad)
assert not result.ok
assert result.message
def test_home_needs_a_configured_mode(service):
"""0 is not a valid homing mode; inheriting one would be worse than saying so."""
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.home(0)
assert not result.ok
assert "0" in result.message
def test_home_runs_once_a_mode_is_set(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
service.set_axis_params(0, AxisParams(home_mode=17))
assert service.home(0).ok
def test_home_is_refused_on_rtex(service):
"""The vendor example refuses RTEX homing; so do we."""
class RtexTransport(DemoTransport):
def user_var(self, name):
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
rtex = MotionService(RtexTransport())
rtex.connect(ConnectionSpec(mode="demo"))
_initialise(rtex)
assert rtex.bus.bus_type == "rtex"
result = rtex.home(0)
assert not result.ok
assert result.message
# ------------------------------------------------------------------- motion
def test_jog_and_stop_move_the_state_machine(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.jog(0, True).ok
state = service.poll([0]).axis(0)
assert state is not None and not state.idle
assert service.stop(0).ok
state = service.poll([0]).axis(0)
assert state is not None and state.idle
def test_absolute_and_relative_moves_change_the_command_position(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.move_abs(1, 250.0).ok
assert service.poll([1]).axis(1).dpos == pytest.approx(250.0)
assert service.move_rel(1, -50.0).ok
assert service.poll([1]).axis(1).dpos == pytest.approx(200.0)
def test_set_axis_params_writes_and_remembers(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
params = AxisParams(units=5.0, speed=200.0, accel=2000.0)
assert service.set_axis_params(2, params).ok
assert service.params(2) == params
assert service.to_mm(2, 1000.0) == pytest.approx(1.0)
def test_enable_toggles_the_axis(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.set_axis_enable(3, False).ok
assert service.poll([3]).axis(3).enabled is False
assert service.set_axis_enable(3, True).ok
assert service.poll([3]).axis(3).enabled is True
def test_stop_all_covers_every_axis(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
results = service.stop_all()
assert len(results) == service.axis_count
assert all(result.ok for result in results)
def test_clear_alarm_reaches_the_drive(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.clear_alarm(0).ok
# ----------------------------------------------------------------- bus data
def test_sdo_round_trips_on_ethercat(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.sdo_write(0, 0x6040, 0, 3, 6).ok
result, value = service.sdo_read(0, 0x6040, 0, 3)
assert result.ok
assert value == 6
def test_sdo_is_refused_on_rtex(service):
class RtexTransport(DemoTransport):
def user_var(self, name):
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
rtex = MotionService(RtexTransport())
rtex.connect(ConnectionSpec(mode="demo"))
_initialise(rtex)
result, _value = rtex.sdo_read(0, 0x6040, 0, 3)
assert not result.ok
def test_rtex_is_refused_on_ethercat(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result, _value = service.rtex_read(0, 1)
assert not result.ok
def test_bus_data_is_refused_before_the_bus_type_is_known(service):
service.connect(ConnectionSpec(mode="demo"))
result, _value = service.sdo_read(0, 0x6040, 0, 3)
assert not result.ok
# ------------------------------------------------------------------- polling
def test_polling_reports_every_requested_axis(service):
service.connect(ConnectionSpec(mode="demo"))
snapshot = service.poll([0, 1, 2, 3, 4])
assert [state.axis for state in snapshot.axes] == [0, 1, 2, 3, 4]
assert all(state.read_ok for state in snapshot.axes)
def test_a_read_failure_is_marked_not_silently_zeroed():
"""A failed read must not look like a position of zero."""
class FailingReads(DemoTransport):
def get_dpos(self, axis):
from orisys_app_shared.zmotion import ZAuxError
raise ZAuxError(30000, "no response")
service = MotionService(FailingReads())
service.connect(ConnectionSpec(mode="demo"))
snapshot = service.poll([0])
state = snapshot.axis(0)
assert state is not None
assert state.read_ok is False
assert state.error
def test_language_switches_the_refusal_text(service):
assert not service.jog(0, True).ok
zh = service.jog(0, True).message
service.set_language("en_US")
en = service.jog(0, True).message
assert zh != en
+331
View File
@@ -0,0 +1,331 @@
"""Offscreen UI smoke tests: construction, layout, gating, and preferences.
The window is built for real, with the simulated platform standing in for the
controller. Nothing here needs a display or hardware.
"""
from __future__ import annotations
import pytest
from conftest import pump, visible_descendants
RESOLUTIONS = ((1440, 900), (1600, 900), (1920, 1080))
def _show(window, size=(1440, 880)):
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def _enabled(widget) -> bool:
"""Whether ``widget`` is effectively enabled, shown or not.
A plain ``isEnabled()`` is False for every child of a window that has not
been shown, which makes an assertion about gating depend on whether the
test happened to realise the window. ``isEnabledTo`` answers the question
the tests actually mean: would a user be able to operate this control?
"""
return widget.isEnabledTo(widget.window())
# ------------------------------------------------------------------ structure
@pytest.mark.parametrize(("theme", "language"), [
("dark", "zh_CN"),
("light", "zh_CN"),
("dark", "en_US"),
("light", "en_US"),
])
def test_the_window_builds_in_every_theme_and_language(make_window, theme, language):
window = _show(make_window(theme=theme, language=language))
assert window.windowTitle()
assert window.axis_panel.table.rowCount() == 5
def test_the_product_name_is_localised_in_the_window_title(make_window):
window = _show(make_window(language="zh_CN"))
zh = window.windowTitle()
window._set_language("en_US")
assert window.windowTitle() != zh
def test_every_axis_row_has_a_labelled_control(make_window):
window = _show(make_window())
panel = window.axis_panel
for row in range(panel.axis_count):
widgets = panel._rows[row]
for key in ("enable_button", "fwd_button", "rev_button", "stop_button"):
assert widgets[key].text().strip(), f"row {row} {key} has no label"
def test_the_table_shows_one_column_per_documented_quantity(make_window):
from orisys_app_shared.i18n import t
from orisys_autocal.ui.axis_panel import COLUMNS
window = _show(make_window())
headers = [
window.axis_panel.table.horizontalHeaderItem(index).text()
for index in range(window.axis_panel.table.columnCount())
]
assert headers == [t(key) for key in COLUMNS] + [t("axes.controls")]
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_every_axis_row_is_reachable(make_window, size):
"""No drive may be hidden by the splitter.
The panel asks for three rows without scrolling; at the default window size
all five must be visible, because a clipped fifth row on a five-axis platform
reads as a missing drive rather than as a scrollbar. If the panel is ever
squeezed below that, the table has to scroll rather than silently cut a row.
"""
window = _show(make_window(), size)
table = window.axis_panel.table
rows_height = sum(
table.rowHeight(row) for row in range(window.axis_panel.axis_count)
)
header = table.horizontalHeader().height()
available = table.viewport().height()
assert available >= rows_height or table.verticalScrollBar().maximum() > 0, (
f"{size}: rows need {rows_height}px, viewport has {available}px "
f"(header {header}px) and it cannot scroll"
)
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_no_visible_child_escapes_its_parent(make_window, size):
window = _show(make_window(), size)
oversize = []
for child in visible_descendants(window):
if _inside_scroll_area(child):
# A scroll area's content is *meant* to exceed its viewport: the rail
# scrolls instead of squeezing its controls into an unreadable column.
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > parent {parent.width()}x{parent.height()}"
)
assert not oversize, oversize
def _inside_scroll_area(widget) -> bool:
"""Whether ``widget`` is content of a QAbstractScrollArea, not a laid-out child."""
from PySide6.QtWidgets import QAbstractScrollArea
parent = widget.parentWidget()
while parent is not None:
if isinstance(parent, QAbstractScrollArea):
return True
parent = parent.parentWidget()
return False
# ----------------------------------------------------------------------- gating
def test_motion_controls_start_disabled_without_a_bus(make_window):
window = _show(make_window())
assert not _enabled(window.axis_panel._rows[0]["fwd_button"])
assert not _enabled(window.axis_panel._rows[0]["rev_button"])
def test_bus_data_panels_follow_the_reported_bus_type(make_window):
window = _show(make_window())
window.bus_data_panel.set_connected(True)
window.bus_data_panel.set_bus_type("ecat")
assert _enabled(window.bus_data_panel.ecat_box)
assert not _enabled(window.bus_data_panel.rtex_box)
window.bus_data_panel.set_bus_type("rtex")
assert _enabled(window.bus_data_panel.rtex_box)
assert not _enabled(window.bus_data_panel.ecat_box)
window.bus_data_panel.set_bus_type("unknown")
assert not _enabled(window.bus_data_panel.ecat_box)
assert not _enabled(window.bus_data_panel.rtex_box)
def test_bus_data_panels_are_disabled_while_disconnected(make_window):
window = _show(make_window())
window.bus_data_panel.set_bus_type("ecat")
window.bus_data_panel.set_connected(False)
assert not _enabled(window.bus_data_panel.ecat_box)
def test_axis_rows_dim_when_the_bus_reports_fewer_drives(make_window):
window = _show(make_window())
window.axis_panel.set_connected(True)
window.axis_panel.set_axis_count(3)
assert _enabled(window.axis_panel._rows[2]["enable_button"])
assert not _enabled(window.axis_panel._rows[3]["enable_button"])
def test_unknown_connection_modes_report_instead_of_pretending(make_window):
from orisys_app_shared.i18n import t
window = _show(make_window())
panel = window.connection_panel
index = panel.mode_combo.findData("com")
panel.mode_combo.setCurrentIndex(index)
panel._emit_connect()
assert panel.status_label.text() != t("connection.state_open")
# ------------------------------------------------------------------- actions
def test_the_selected_axis_follows_a_table_click(make_window):
window = _show(make_window())
window.axis_panel._on_cell_clicked(3, 0)
assert window.axis_panel.selected_axis == 3
assert window.axis_panel.axis_index_label.text() == "3"
def test_blank_numeric_fields_fall_back_to_defaults(make_window):
from orisys_autocal.motion import DEFAULT_UNITS
window = _show(make_window())
panel = window.axis_panel
panel.units_edit.setText("")
assert panel.current_params().units == DEFAULT_UNITS
panel.units_edit.setText("not a number")
assert panel.current_params().units == DEFAULT_UNITS
def test_a_blank_target_does_not_emit_a_move(make_window):
window = _show(make_window())
emitted = []
window.axis_panel.moveAbsRequested.connect(lambda axis, value: emitted.append(value))
window.axis_panel.target_edit.setText("")
window.axis_panel._on_move_abs()
assert emitted == []
def test_applying_parameters_persists_them_in_the_panel(make_window):
from orisys_autocal.motion import AxisParams
window = _show(make_window())
panel = window.axis_panel
panel.set_selected_axis(1)
panel.units_edit.setText("5")
panel.speed_edit.setText("250")
panel.accel_edit.setText("2500")
panel._on_apply()
stored = panel.stored_params()[1]
assert isinstance(stored, AxisParams)
assert stored.units == 5.0
# --------------------------------------------------------------- preferences
def test_the_window_geometry_round_trips_through_preferences(make_window):
# Above the enforced minimum (1400x860): a smaller request would be clamped,
# which tests Qt's constraint rather than the preference round-trip.
window = _show(make_window(), (1520, 920))
window._persist_prefs()
geometry = window.prefs["window_geometry"]
assert len(geometry) == 4
assert geometry[2] == pytest.approx(1520, abs=5)
assert geometry[3] == pytest.approx(920, abs=5)
def test_a_malformed_geometry_is_ignored_rather_than_crashing(make_window):
window = _show(make_window(window_geometry=["nonsense"]))
assert window.width() > 0
def test_axis_parameters_survive_a_persist_reload(make_window):
from orisys_autocal import preferences as prefs_mod
window = _show(make_window())
window.axis_panel.set_selected_axis(2)
window.axis_panel.units_edit.setText("7")
window.axis_panel._on_apply()
window._persist_prefs()
reloaded = prefs_mod.load_preferences()
assert reloaded["axis_params"]["2"]["units"] == 7.0
def test_the_bus_path_is_remembered(make_window):
window = _show(make_window())
window.bus_panel.set_bas_path(r"D:\somewhere\ECAT.bas")
window._persist_prefs()
assert window.prefs["bas_path"] == r"D:\somewhere\ECAT.bas"
def test_sdo_fields_are_remembered(make_window):
window = _show(make_window())
panel = window.bus_data_panel
panel.node_edit.setText("2")
panel.index_edit.setText("24608")
panel.subindex_edit.setText("1")
window._persist_prefs()
assert window.prefs["sdo_last"]["node"] == 2
assert window.prefs["sdo_last"]["index"] == 24608
# --------------------------------------------------------------- demo session
def test_a_demo_session_fills_the_table_and_gates_motion(make_window, qt_app):
"""End to end through the real signal path: connect, initialise, jog."""
from orisys_autocal.motion import ConnectionSpec
window = _show(make_window())
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
assert _wait_for(lambda: window._last_snapshot.connected, qt_app)
# Download the shipped ECAT.bas, then wait for the simulated scan.
window.bus_panel.set_bas_path(str(_shipped_bas("ECAT.bas")))
window._on_download_bas()
assert _wait_for(lambda: window._last_snapshot.bus.init_status == 1, qt_app, timeout=8.0)
assert window._last_snapshot.bus.bus_type == "ecat"
assert _enabled(window.bus_data_panel.ecat_box)
assert _enabled(window.axis_panel._rows[0]["fwd_button"])
# ATYPE is no longer a table column — the seven-column table shows position
# and status, and the drive's full frame is on the selected-axis detail line.
window.axis_panel.set_selected_axis(0)
assert "65" in window.axis_panel._detail_label.text()
pump(qt_app, 0.2)
def test_closing_the_window_stops_the_poller(make_window, qt_app):
window = _show(make_window())
window.close()
pump(qt_app, 0.2)
assert not window.poller._thread.isRunning()
def _shipped_bas(name: str):
from orisys_autocal.ui.bus_panel import default_bas_dir
return default_bas_dir() / name
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
+233
View File
@@ -0,0 +1,233 @@
"""The poller thread: serialisation, failure isolation, and clean teardown.
The two things that must never happen are a controller call on the GUI thread
and two controller calls at once. Both are asserted here against a transport
that records how it was used.
"""
from __future__ import annotations
import threading
import time
import pytest
from orisys_autocal.motion import ConnectionSpec, DemoTransport, MotionPoller, MotionService
class RecordingTransport(DemoTransport):
"""A *connected* demo platform that records thread and overlap of every call.
Connected on purpose: the service polls only ``BUS_TYPE`` and ``Bus_InitStatus``
without a BAS program, so an unconnected transport would keep every read
short and nothing could be observed. A ``_concurrent`` guard asserts no two
calls ever overlap rather than merely asserting the maximum sum of started
calls, which would be trivially satisfied.
"""
def __init__(
self,
*,
delay: float = 0.0,
unexpected_on: set[str] | None = None,
) -> None:
super().__init__()
self.delay = delay
self.unexpected_on = unexpected_on or set()
self.thread_ids: set[int] = set()
self.max_concurrent = 0
self.overlaps: list[str] = []
self.calls: list[str] = []
self._lock = threading.Lock()
self._concurrent = 0
# A fake "BAS program has run": the bus reports EtherCAT and ready.
self._init_started = None
self._preloaded = True
def open(self, spec) -> None:
super().open(ConnectionSpec(mode="demo"))
self._bas_loaded = True
def user_var(self, name: str) -> float:
if name == "BUS_TYPE":
return 0.0
if name == "Bus_InitStatus":
return 1.0
if name == "Bus_TotalAxisnum":
return 5.0
return super().user_var(name)
def _enter(self, name: str) -> None:
with self._lock:
self._concurrent += 1
if self._concurrent > 1:
self.overlaps.append(name)
self.max_concurrent = max(self.max_concurrent, self._concurrent)
self.calls.append(name)
self.thread_ids.add(threading.get_ident())
if self.delay:
time.sleep(self.delay)
if name in self.unexpected_on:
raise RuntimeError(f"simulated unexpected fault in {name}")
def _exit(self) -> None:
with self._lock:
self._concurrent -= 1
def get_dpos(self, axis: int) -> float:
self._enter("get_dpos")
try:
return super().get_dpos(axis)
finally:
self._exit()
def get_mpos(self, axis: int) -> float:
self._enter("get_mpos")
try:
return super().get_mpos(axis)
finally:
self._exit()
def get_axis_status(self, axis: int) -> int:
self._enter("get_axis_status")
try:
return super().get_axis_status(axis)
finally:
self._exit()
def get_if_idle(self, axis: int) -> int:
self._enter("get_if_idle")
try:
return super().get_if_idle(axis)
finally:
self._exit()
def _connected_poller(transport: RecordingTransport, *, axes=(0,)) -> MotionPoller:
service = MotionService(transport)
assert service.connect(ConnectionSpec(mode="demo")).ok
return MotionPoller(service, axes=list(axes))
@pytest.fixture
def qt_app_session(qt_app):
return qt_app
def _drain(app, seconds: float = 1.0) -> None:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
app.processEvents()
time.sleep(0.005)
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.01)
return False
def test_completed_commands_are_reported_and_none_are_lost(qt_app):
transport = RecordingTransport()
poller = _connected_poller(transport)
try:
repo = {"finished": [], "failed": []}
poller.commandFinished.connect(lambda token, result: repo["finished"].append(token))
poller.commandFailed.connect(lambda token, message: repo["failed"].append(token))
tokens = [poller.submit(lambda service: None) for _ in range(5)]
assert _wait_for(lambda: len(repo["finished"]) == 5, qt_app)
assert sorted(repo["finished"]) == sorted(tokens)
assert repo["failed"] == []
finally:
poller.shutdown()
def test_every_controller_call_happens_on_one_worker_thread(qt_app):
transport = RecordingTransport()
poller = _connected_poller(transport, axes=(0, 1))
try:
assert _wait_for(lambda: "get_dpos" in transport.calls, qt_app)
poller.submit(lambda service: None)
_drain(qt_app, 0.3)
assert len(transport.thread_ids) == 1, transport.thread_ids
assert threading.get_ident() not in transport.thread_ids, (
"a controller call ran on the test's (GUI) thread"
)
finally:
poller.shutdown()
def test_two_controller_calls_never_overlap(qt_app):
"""A slow poll plus queued commands must still serialise."""
transport = RecordingTransport(delay=0.02)
poller = _connected_poller(transport)
try:
for _ in range(4):
poller.submit(lambda service: None)
_drain(qt_app, 0.8)
assert transport.max_concurrent == 1, (
f"{transport.max_concurrent} controller calls overlapped at once"
)
assert not transport.overlaps, transport.overlaps
finally:
poller.shutdown()
def test_an_unexpected_fault_is_reported_and_does_not_kill_the_poller(qt_app):
"""A non-``ZAuxError`` from inside a command must not stop the timer."""
transport = RecordingTransport(unexpected_on={"get_dpos"})
poller = _connected_poller(transport)
try:
failures = []
poller.commandFailed.connect(lambda token, message: failures.append((token, message)))
# The submitted action asks for the position directly, so the fault
# surfaces from the command rather than being absorbed by a poll.
poller.submit(lambda service: service.transport.get_dpos(0))
assert _wait_for(lambda: bool(failures), qt_app)
assert failures[0][1]
# The poller is still alive: later snapshots keep arriving.
before = len(transport.calls)
_drain(qt_app, 0.4)
assert len(transport.calls) > before
finally:
poller.shutdown()
def test_shutdown_stops_the_thread_and_is_idempotent(qt_app):
poller = _connected_poller(RecordingTransport())
_drain(qt_app, 0.2)
poller.shutdown(timeout_ms=3000)
poller.shutdown(timeout_ms=3000) # must not raise or hang
def test_a_refused_command_still_arrives_with_its_reason(qt_app):
"""An unconnected service refuses in the guard, without touching the DLL."""
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
try:
results = []
poller.commandFinished.connect(lambda token, result: results.append(result))
poller.submit(lambda service: service.jog(0, True))
assert _wait_for(lambda: bool(results), qt_app)
assert results[0].ok is False
assert results[0].message
finally:
poller.shutdown()
def test_connect_and_disconnect_round_trip_through_the_poller(qt_app):
poller = MotionPoller(MotionService(RecordingTransport()), axes=[0])
try:
states = []
poller.connectionChanged.connect(states.append)
poller.connect_controller(ConnectionSpec(mode="demo"))
assert _wait_for(lambda: poller.service.connected, qt_app)
poller.disconnect_controller()
assert _wait_for(lambda: not poller.service.connected, qt_app)
finally:
poller.shutdown()
+73
View File
@@ -0,0 +1,73 @@
"""Unit-to-millimetre conversion.
The reference program carries the bench measurement in a comment::
# 轴0(X)/轴1(Y) 实测: UNITS=10 时 1000用户单位 = 2mm → 每毫米 5000 脉冲
# 当量5 → 1000单位=1mm,不是100单位=1mm
These are the assertions that keep that comment honest.
"""
from __future__ import annotations
import math
import pytest
from orisys_autocal.motion.units import (
PULSES_PER_MM,
mm_to_user_units,
safe_float,
user_units_to_mm,
)
@pytest.mark.parametrize(
("units", "user_units", "expected_mm"),
[
(10.0, 1000.0, 2.0), # the measured case from the vendor comment
(5.0, 1000.0, 1.0), # ...and the second one
(10.0, 0.0, 0.0),
(10.0, 5000.0, 10.0),
(10.0, -1000.0, -2.0), # negative travel must stay negative
],
)
def test_conversion_matches_the_bench_measurement(units, user_units, expected_mm):
assert user_units_to_mm(user_units, units=units) == pytest.approx(expected_mm)
def test_default_units_is_the_platform_value():
assert user_units_to_mm(1000.0) == pytest.approx(2.0)
assert PULSES_PER_MM == 5000.0
def test_conversion_round_trips():
for units in (1.0, 5.0, 10.0, 37.5):
millimetres = user_units_to_mm(1234.5, units=units)
assert mm_to_user_units(millimetres, units=units) == pytest.approx(1234.5)
def test_a_zero_or_negative_scale_is_not_a_number():
"""A bad constant must be visibly unusable, not a plausible number."""
assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=0.0))
assert math.isnan(user_units_to_mm(1000.0, pulses_per_mm=-1.0))
assert math.isnan(mm_to_user_units(1.0, units=0.0))
@pytest.mark.parametrize(
"text",
["", " ", "abc", "1,5", None, "nan", "inf", "-inf"],
)
def test_unusable_entries_fall_back(text):
assert safe_float(text, 42.0) == 42.0
@pytest.mark.parametrize("text", ["0", "0.0", " 0 "])
def test_zero_falls_back_because_it_would_divide(text):
"""A pulse equivalent of zero must never reach the controller."""
assert safe_float(text, 7.0) == 7.0
@pytest.mark.parametrize(("text", "expected"), [("5", 5.0), (" 2.5 ", 2.5), ("-3", -3.0)])
def test_usable_entries_are_parsed(text, expected):
assert safe_float(text, 42.0) == expected
File diff suppressed because it is too large Load Diff
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""Dependency-free offscreen check of the AutoCal window.
Builds the real window with the simulated platform and asserts the invariants
that are easy to break and hard to notice: theme tokens present, layout fitting
inside parents, controls gated by connection and bus state, locales complete.
python apps/autocal/tools/panel_check.py
No controller, no DLL and no display are needed. Mirrors
``apps/studio/tools/ui_check.py``, but cannot import it: that module 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())
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Render the AutoCal window offscreen to a PNG, for review.
python apps/autocal/tools/panel_snapshot.py --theme light --out shot.png
Optionally drives a simulated session first, so the shot shows live values:
python apps/autocal/tools/panel_snapshot.py --demo --language en_US --out shot.png
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
os.environ.setdefault("ORISYS_APP_ID", "autocal")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--theme", default="dark", choices=("dark", "light"))
parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US"))
parser.add_argument("--size", default="1600x900", help="WIDTHxHEIGHT")
parser.add_argument("--out", default="autocal.png")
parser.add_argument(
"--tab",
default="axes",
help="workspace tab to show: axes, sequence, sensor, surface, points",
)
parser.add_argument(
"--compare",
action="store_true",
help="render every tab, named <out stem>_<tab>.<ext>, so the layout can be "
"reviewed in one pass",
)
parser.add_argument(
"--demo",
action="store_true",
help="drive a simulated session so the shot shows live values",
)
args = parser.parse_args()
width, height = (int(part) for part in args.size.lower().split("x"))
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
from orisys_autocal.motion import ConnectionSpec, DemoTransport
from orisys_autocal.preferences import load_preferences
from orisys_autocal.ui.bus_panel import default_bas_dir
from orisys_autocal.ui.window import AutoCalMainWindow
set_locale(args.language)
app = QApplication.instance() or QApplication([])
app.setStyleSheet(build_qss(args.theme))
prefs = load_preferences()
prefs["theme"] = args.theme
prefs["language"] = args.language
prefs["show_diagnostics"] = False
window = AutoCalMainWindow(prefs=prefs)
window.poller.service._transport = DemoTransport() # noqa: SLF001
window.resize(width, height)
window.show()
app.processEvents()
if args.demo:
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
_wait_for(app, lambda: window._last_snapshot.connected, 6.0)
window.bus_panel.set_bas_path(str(default_bas_dir() / "ECAT.bas"))
window._on_download_bas()
_wait_for(app, lambda: window._last_snapshot.bus.init_status == 1, 8.0)
window.axis_panel.set_selected_axis(0)
window.axis_panel.target_edit.setText("1000")
window._on_move_abs(0, 1000.0)
_wait_for(app, lambda: window._last_snapshot.axis(0).dpos != 0, 4.0)
app.processEvents()
if args.compare:
return _render_every_tab(window, app, args, width, height)
window.workspace.set_current_key(args.tab)
app.processEvents()
output = Path(args.out).resolve()
if not window.grab().save(str(output)):
print(f"could not write {output}", file=sys.stderr)
return 1
print(
f"wrote {output} ({width}x{height}, theme={args.theme}, "
f"lang={args.language}, tab={args.tab})"
)
window.close()
app.processEvents()
return 0
def _render_every_tab(window, app, args, width: int, height: int) -> int:
"""One image per workspace tab, so the whole layout can be reviewed at once."""
base = Path(args.out).resolve()
failures = 0
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
app.processEvents()
target = base.with_name(f"{base.stem}_{key}{base.suffix or '.png'}")
if window.grab().save(str(target)):
print(f"wrote {target} ({width}x{height}, tab={key})")
else:
print(f"could not write {target}", file=sys.stderr)
failures += 1
window.close()
app.processEvents()
return 1 if failures else 0
def _wait_for(app, predicate, timeout: float) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
if __name__ == "__main__":
raise SystemExit(main())
+9 -3
View File
@@ -1,5 +1,11 @@
# Internal app (stub)
# Internal app (superseded)
Future home for automated calibration and laboratory testing tools.
The internal calibration and laboratory tooling this directory was reserved for
now lives in [`apps/autocal`](../autocal/README.md) — the 5-axis calibration
platform control application.
This directory is intentionally empty in the customer-facing scaffold. Shared code will come from `shared/`; do not ship this app in the customer installer.
That app keeps the property this stub existed to protect: it is **not** part of
the customer-facing product. It is not packaged by `build/studio` and must not
enter the customer installer.
This directory is otherwise unused; it is kept only so the move is documented.
+12
View File
@@ -33,6 +33,10 @@
"scene3d_settings": "3D display settings…",
"show_fps": "Show FPS",
"show_diagnostics": "Diagnostics",
"raw_auto_zero": "Finger raw auto-zero at start",
"raw_auto_zero_tip": "Value cards only: estimate an idle bias and deadband after start. Curves, statistics and CSV keep the measured values.",
"raw_reestimate": "Re-estimate raw baseline…",
"raw_reestimate_tip": "Drop the current display baseline and re-estimate from the next quiet samples (value cards only).",
"settings": "Settings area",
"plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°",
@@ -217,6 +221,8 @@
"selection_empty": "No valid samples in the selection",
"value_unavailable": "n/a",
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
"zero_zone": "zero zone",
"zero_zone_tip": "Display deadband after auto baseline; curves, statistics and CSV keep the measured values",
"unit_toggle": "Switch unit",
"unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units",
"browse_only_tip": "Drag or scroll on the chart to browse history"
@@ -412,6 +418,12 @@
"field_depth": "Depth response",
"field_flow": "Optical flow magnitude",
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
"flow_arrows": "Surface flow arrows",
"flow_arrows_tip": "Draw optical-flow arrows on the sensing surface using the same sample points as the 2D view.",
"flow_arrows_unavailable": "This scene package has no UV→scene surface mapping, so flow arrows cannot be drawn.",
"contact_arrow": "Contact normal arrow",
"contact_arrow_tip": "Draw a yellow local-normal arrow at the contact centre (length from the normal response, capped — not a calibrated force vector).",
"contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.",
"quantity_flow_magnitude": "Optical flow magnitude",
"quantity_depth_response": "Depth response",
"mode_surface": "surface colouring",
+12
View File
@@ -33,6 +33,10 @@
"scene3d_settings": "三维显示设置…",
"show_fps": "显示 FPS",
"show_diagnostics": "诊断信息",
"raw_auto_zero": "Finger raw 启动归零",
"raw_auto_zero_tip": "仅影响数字卡展示:启动后自动估计无载偏置与死区。曲线、统计与 CSV 仍为测量值。",
"raw_reestimate": "重新估计 raw 基线…",
"raw_reestimate_tip": "丢弃当前展示基线,从接下来的无载样本重新估计(仅数字卡)。",
"settings": "设置区",
"plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
@@ -217,6 +221,8 @@
"selection_empty": "选区无有效样本",
"value_unavailable": "不可用",
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
"zero_zone": "零区",
"zero_zone_tip": "展示归零后的死区内读数;曲线、统计与 CSV 仍为原始测量值",
"unit_toggle": "切换单位",
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
@@ -412,6 +418,12 @@
"field_depth": "深度响应",
"field_flow": "光流幅值",
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
"flow_arrows": "表面光流箭头",
"flow_arrows_tip": "在传感表面上按与二维相同的采样点绘制光流箭头。",
"flow_arrows_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制光流箭头。",
"contact_arrow": "接触法向箭头",
"contact_arrow_tip": "在接触中心沿局部法向绘制黄色示意箭头(长度由法向响应限幅,不是标定合力)。",
"contact_arrow_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制接触法向箭头。",
"quantity_flow_magnitude": "光流幅值",
"quantity_depth_response": "深度响应",
"mode_surface": "曲面着色",
@@ -9,6 +9,7 @@
"show_cal_panel": false,
"show_finger_3d": true,
"show_fps": false,
"raw_auto_zero": true,
"gl_profile": "desktop33",
"scene3d_package": "",
"scene3d_display": {
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"schema_version": 2,
"unit": "N",
"channels": {
"normal": {
@@ -14,8 +14,22 @@
[62000.0, 2.377]
]
},
"shear": {
"shear_x": {
"method": "piecewise_linear",
"raw_key": "fshearx",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[36000.0, 1.19],
[68000.0, 2.06]
]
},
"shear_y": {
"method": "piecewise_linear",
"raw_key": "fsheary",
"input": "components",
"signed": true,
"extrapolate": true,
@@ -925,6 +925,16 @@ class StudioMainWindow(QMainWindow):
)
self.view_menu.addAction(self.act_show_diagnostics)
self.act_raw_auto_zero = QAction(self)
self.act_raw_auto_zero.setCheckable(True)
self.act_raw_auto_zero.setChecked(bool(self.prefs.get("raw_auto_zero", True)))
self.act_raw_auto_zero.toggled.connect(self.display_ctl.on_raw_auto_zero_toggled)
self.view_menu.addAction(self.act_raw_auto_zero)
self.act_raw_reestimate = QAction(self)
self.act_raw_reestimate.triggered.connect(self.display_ctl.request_raw_reestimate)
self.view_menu.addAction(self.act_raw_reestimate)
self.act_settings = QAction(self)
self.act_settings.setCheckable(True)
self.act_settings.setChecked(self.toolbar.settings_expanded())
@@ -1168,6 +1178,10 @@ class StudioMainWindow(QMainWindow):
self.act_scene3d_settings.setText(t("menu.scene3d_settings"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_show_diagnostics.setText(t("menu.show_diagnostics"))
self.act_raw_auto_zero.setText(t("menu.raw_auto_zero"))
self.act_raw_auto_zero.setToolTip(t("menu.raw_auto_zero_tip"))
self.act_raw_reestimate.setText(t("menu.raw_reestimate"))
self.act_raw_reestimate.setToolTip(t("menu.raw_reestimate_tip"))
self.act_settings.setText(t("menu.settings"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
@@ -1886,6 +1900,16 @@ class StudioMainWindow(QMainWindow):
self._stop_preview(announce=False)
self.force_plots.reset()
units = "N" if self.toolbar.units() == "newton" else "raw"
self.force_plots.configure_presentation(
sensor_type=sensor_type,
unit=units,
enabled=(
sensor_type == "finger"
and units == "raw"
and bool(self.prefs.get("raw_auto_zero", True))
),
)
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
# Fresh record channel for the new session. The calibration identity is
@@ -2126,6 +2150,30 @@ class StudioMainWindow(QMainWindow):
self.session.last_sample_valid = True
self._last_display_fallback = bool(display.using_raw_fallback)
self._last_display_available = tuple(bool(flag) for flag in display.available)
flow = getattr(packet, "flow", None)
flow_p95 = None
if flow is not None:
try:
import numpy as np
arr = np.asarray(flow, dtype=np.float64)
if arr.ndim == 3 and arr.shape[-1] == 2:
mag = np.linalg.norm(arr, axis=-1)
mask = getattr(packet, "valid_mask", None)
if mask is not None:
m = np.asarray(mask, dtype=bool)
if m.shape == mag.shape:
vals = mag[m & np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
else:
vals = mag[np.isfinite(mag)]
if vals.size:
flow_p95 = float(np.percentile(vals, 95))
except Exception: # noqa: BLE001
flow_p95 = None
contact = getattr(packet, "contact", None)
contact_found = bool(getattr(contact, "found", False)) if contact else False
self.force_plots.push(
# Session-relative time, the same base the store and
# ``forces.csv`` use. The wall clock must never reach the
@@ -2136,6 +2184,9 @@ class StudioMainWindow(QMainWindow):
display.values[2],
unit=display.unit,
available=display.available,
flow_p95=flow_p95,
contact_found=contact_found,
forces_valid=bool(getattr(packet, "forces_valid", True)),
)
else:
self.session.last_sample_valid = False
@@ -2226,6 +2277,11 @@ class StudioMainWindow(QMainWindow):
def _render_run_image(self, packet) -> None:
if packet is None or self._replay_owns_image():
return
flow_valid = getattr(packet, "flow_valid", None)
depth_valid = getattr(packet, "depth_valid", None)
stale = bool(getattr(packet, "stale_forces", False)) or (
flow_valid is False and depth_valid is False
)
self._show_image_frame(
ImageFrame(
getattr(packet, "processed_frame", None),
@@ -2235,6 +2291,11 @@ class StudioMainWindow(QMainWindow):
source=SOURCE_RECOMPUTE if self._recompute_running else SOURCE_LIVE,
frame_id=int(getattr(packet, "frame_id", 0) or 0),
t_rel_s=float(getattr(packet, "mono_t", 0.0) or 0.0),
stale=stale,
flow_valid=flow_valid,
flow_frame_id=getattr(packet, "flow_frame_id", None),
depth_valid=depth_valid,
depth_frame_id=getattr(packet, "depth_frame_id", None),
)
)
@@ -84,7 +84,10 @@ class DisplayPrefsController(QObject):
#: Whether the panels explain the pipeline around the picture (see
#: ``on_show_diagnostics_toggled``). Off: each panel shows what the picture *is*.
self._show_diagnostics = bool(host.prefs.get("show_diagnostics", False))
#: Finger-raw value cards: startup auto baseline / deadband (display only).
self._raw_auto_zero = bool(host.prefs.get("raw_auto_zero", True))
self._apply_diagnostics()
self.apply_presentation_prefs()
def bind_actions(self, *, rotate_arrows: Any) -> None:
"""Take the rotate entry once the menus exist (it follows the sensor)."""
@@ -261,3 +264,43 @@ class DisplayPrefsController(QObject):
setter = getattr(panel, "set_diagnostics_visible", None)
if callable(setter):
setter(self._show_diagnostics)
# ------------------------------------------------------- raw display zeroing
@property
def raw_auto_zero(self) -> bool:
return bool(self._raw_auto_zero)
def set_raw_auto_zero(self, enabled: bool) -> None:
"""Persist whether Finger raw value cards auto-baseline at session start."""
self._raw_auto_zero = bool(enabled)
self._host.prefs["raw_auto_zero"] = self._raw_auto_zero
self._host._persist_prefs()
self.apply_presentation_prefs()
def on_raw_auto_zero_toggled(self, enabled: bool) -> None:
self.set_raw_auto_zero(enabled)
def apply_presentation_prefs(self) -> None:
"""Push the current baseline policy onto the charts (Finger raw only)."""
configure = getattr(self._charts, "configure_presentation", None)
if not callable(configure):
return
sensor = (
self._picker.sensor_type()
if hasattr(self._picker, "sensor_type")
else "box"
)
unit = "N" if self._toolbar.units() == "newton" else "raw"
configure(
sensor_type=sensor,
unit=unit,
enabled=bool(self._raw_auto_zero and sensor == "finger" and unit == "raw"),
)
def request_raw_reestimate(self) -> None:
"""Operator entry: restart the idle baseline window on the value cards."""
request = getattr(self._charts, "request_presentation_reestimate", None)
if callable(request):
request()
else:
self.apply_presentation_prefs()
@@ -392,6 +392,26 @@ class FingerFrameAdapter:
refused.append(mask_problem)
return None
self.last_diagnostic = None
contact = getattr(packet, "contact", None)
contact_xy = None
contact_found = False
contact_valid = False
if contact is not None:
contact_xy = getattr(contact, "centroid_uv_xy", None)
contact_found = bool(getattr(contact, "found", False))
contact_valid = bool(getattr(contact, "valid", False))
flow = getattr(packet, "flow", None)
flow_uv = None
flow_mask = None
if flow is not None and _field_validity(packet, "flow", identity.frame_id) is None:
arr = np.asarray(flow, dtype=np.float32)
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
flow_uv = freeze(arr, dtype=np.float32)
device_for_flow, _ = _device_mask(packet, self.shape_hw)
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow)
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
if not np.isfinite(fn):
fn = float(getattr(packet, "normal_force", 0.0) or 0.0)
return SurfaceFieldFrame(
identity=identity,
geometry_id=self.geometry_id,
@@ -400,6 +420,12 @@ class FingerFrameAdapter:
scalar_descriptor=descriptor,
valid=_intersect(mask, device),
computed_frame_id=_computed_frame_id(packet, name, identity.frame_id),
flow_uv=flow_uv,
flow_valid_mask=flow_mask,
contact_uv_xy=contact_xy if isinstance(contact_xy, tuple) else None,
contact_found=contact_found,
contact_valid=contact_valid,
contact_fn=fn if np.isfinite(fn) else 0.0,
)
@@ -462,17 +462,24 @@ class ThreeDCoordinator:
self._set_state("no_data")
def set_settings(self, settings: DisplaySettings) -> None:
"""Apply display settings; a gain change means recomposing the last field."""
"""Apply display settings; a gain/overlay change recomposes the last field."""
previous = self._settings
self._settings = settings
self._widget.set_settings(settings)
gain_changed = (
needs_recompose = (
previous.display_gain != settings.display_gain
or previous.deform_enabled != settings.deform_enabled
or previous.field != settings.field
or previous.flow_arrows_enabled != settings.flow_arrows_enabled
or previous.contact_arrow_enabled != settings.contact_arrow_enabled
or previous.flow_on_px != settings.flow_on_px
or previous.flow_off_px != settings.flow_off_px
or previous.flow_grid_spacing != settings.flow_grid_spacing
or previous.max_arrows != settings.max_arrows
)
if gain_changed and self._assets is not None:
# The scalar is untouched by the gain, but the *positions* are not, so
# the last field frame has to be composed again.
if needs_recompose and self._assets is not None:
# Gain moves positions; overlay toggles rebuild arrow instances on the
# held field frame so the operator sees the change without waiting.
self._settings_dirty = True
def show_rest(self) -> None:
@@ -190,6 +190,31 @@ def shader_header(profile: Profile) -> str:
raise ValueError(f"unknown GL profile: {profile!r}") from None
_ARROW_VERTEX_BODY = """
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec4 a_color;
uniform mat4 u_view;
uniform mat4 u_projection;
out vec4 v_color;
void main() {
v_color = a_color;
gl_Position = u_projection * u_view * vec4(a_position, 1.0);
}
"""
_ARROW_FRAGMENT_BODY = """
in vec4 v_color;
out vec4 frag_color;
void main() {
frag_color = v_color;
}
"""
def shader_source(profile: Profile, program: str) -> tuple[str, str]:
"""``(vertex, fragment)`` source for a profile and a program name.
@@ -199,19 +224,23 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
header = shader_header(profile)
if program == "scalar":
body = _SCALAR_FRAGMENT_BODY
vertex = _VERTEX_BODY
elif program == "diffuse":
body = _DIFFUSE_FRAGMENT_BODY
vertex = _VERTEX_BODY
elif program == "arrow":
return header + _ARROW_VERTEX_BODY, header + _ARROW_FRAGMENT_BODY
else:
raise ValueError(f"unknown shader program: {program!r}")
return header + _VERTEX_BODY, header + body
return header + vertex, header + body
def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
"""The range of the *valid* samples, or ``None`` when there are none.
"""Robust P5/P95 range of *valid* samples, or ``None`` when there are none.
Only valid and finite samples count. Including the invalid ones would make the
colour range follow whatever garbage sits outside the UV island, and including
NaN would produce a range of NaN and a picture with no colours at all.
Using min/max made empty-field noise paint the whole LUT. A percentile span
with a minimum width keeps weak signals visible without amplifying floor
noise into a full contact.
"""
import numpy as np
@@ -222,9 +251,39 @@ def _valid_scalar_range(scalar, valid) -> Optional[tuple[float, float]]:
usable = values[mask & np.isfinite(values)]
if usable.size == 0:
return None
return float(usable.min()), float(usable.max())
if usable.size < 8:
lower = float(usable.min())
upper = float(usable.max())
else:
lower = float(np.percentile(usable, 5.0))
upper = float(np.percentile(usable, 95.0))
# Minimum span: avoid collapsing nearly-constant fields into one colour.
span = upper - lower
min_span = max(abs(lower) * 0.05, abs(upper) * 0.05, 1e-3)
if span < min_span:
mid = 0.5 * (lower + upper)
lower = mid - 0.5 * min_span
upper = mid + 0.5 * min_span
return lower, upper
def _blend_scale_range(
previous: Optional[tuple[float, float]],
current: Optional[tuple[float, float]],
*,
alpha: float = 0.15,
) -> Optional[tuple[float, float]]:
"""Slowly update an automatic colour range to avoid frame-to-frame flicker."""
if current is None:
return previous
if previous is None:
return current
a = float(np.clip(alpha, 0.0, 1.0))
return (
(1.0 - a) * float(previous[0]) + a * float(current[0]),
(1.0 - a) * float(previous[1]) + a * float(current[1]),
)
def _scale_bounds(scale, automatic: Optional[tuple[float, float]]) -> tuple[float, float]:
"""The colour range to draw with: the operator's, or the data's own.
@@ -514,6 +573,10 @@ class GLRenderer:
self._initialized = False
self._paint_calls = 0
self._last_upload_ms = 0.0
#: Overlay VAOs keyed by kind ("flow" / "contact").
self._overlay_vaos: dict[str, tuple[int, int, int, int]] = {}
self._arrow_program: int = 0
self._arrow_uniforms: dict[str, int] = {}
# ------------------------------------------------------------------ lifetime
def initialize(self, capabilities: GLCapabilities) -> None:
@@ -529,6 +592,7 @@ class GLRenderer:
try:
self._programs["scalar"] = _compile_program(GL, capabilities.profile, "scalar")
self._programs["diffuse"] = _compile_program(GL, capabilities.profile, "diffuse")
self._programs["arrow"] = _compile_program(GL, capabilities.profile, "arrow")
except Exception as exc: # noqa: BLE001
self.release()
raise error(
@@ -604,7 +668,74 @@ class GLRenderer:
GL.glBufferSubData(GL.GL_ARRAY_BUFFER, 0, rows.nbytes, rows)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0)
self._last_upload_ms = (time.perf_counter() - started) * 1000.0
self._scalar_range = _valid_scalar_range(frame.scalar, frame.valid)
measured = _valid_scalar_range(frame.scalar, frame.valid)
self._scalar_range = _blend_scale_range(self._scalar_range, measured)
self._upload_overlays(frame)
def _upload_overlays(self, frame: RenderFrame) -> None:
"""Upload merged flow / contact arrow meshes for this frame."""
from orisys_app_shared.scene3d.overlays import arrow_instances_to_mesh
for kind, instances in (
("flow", getattr(frame, "flow_arrows", None)),
("contact", getattr(frame, "contact_arrow", None)),
):
if instances is None or int(instances.origins.shape[0]) == 0:
self._clear_overlay(kind)
continue
positions, triangles, colors = arrow_instances_to_mesh(instances)
if positions.size == 0:
self._clear_overlay(kind)
continue
self._set_overlay(kind, positions, triangles, colors)
def _set_overlay(
self,
kind: str,
positions: np.ndarray,
triangles: np.ndarray,
colors: np.ndarray,
) -> None:
from OpenGL import GL
self._clear_overlay(kind)
count = int(positions.shape[0])
interleaved = np.empty((count, 7), dtype=np.float32)
interleaved[:, 0:3] = positions
interleaved[:, 3:7] = colors
vao = int(GL.glGenVertexArrays(1))
vbo = int(GL.glGenBuffers(1))
ebo = int(GL.glGenBuffers(1))
GL.glBindVertexArray(vao)
GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo)
GL.glBufferData(
GL.GL_ARRAY_BUFFER, interleaved.nbytes, interleaved, GL.GL_DYNAMIC_DRAW
)
GL.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo)
GL.glBufferData(
GL.GL_ELEMENT_ARRAY_BUFFER, triangles.nbytes, triangles, GL.GL_DYNAMIC_DRAW
)
stride = 7 * 4
GL.glEnableVertexAttribArray(0)
GL.glVertexAttribPointer(0, 3, GL.GL_FLOAT, GL.GL_FALSE, stride, None)
GL.glEnableVertexAttribArray(1)
GL.glVertexAttribPointer(1, 4, GL.GL_FLOAT, GL.GL_FALSE, stride, _offset(3))
GL.glBindVertexArray(0)
self._overlay_vaos[kind] = (vao, vbo, ebo, int(triangles.size))
def _clear_overlay(self, kind: str) -> None:
from OpenGL import GL
entry = self._overlay_vaos.pop(kind, None)
if entry is None:
return
vao, vbo, ebo, _ = entry
try:
GL.glDeleteVertexArrays(1, [int(vao)])
GL.glDeleteBuffers(1, [int(vbo)])
GL.glDeleteBuffers(1, [int(ebo)])
except Exception: # noqa: BLE001
pass
def draw(
self,
@@ -659,6 +790,27 @@ class GLRenderer:
GL.GL_TRIANGLES, part.triangles * 3, GL.GL_UNSIGNED_INT, None
)
GL.glBindVertexArray(0)
# Overlays after the surface so arrows stay visible.
arrow_program = self._programs.get("arrow")
if arrow_program and self._overlay_vaos:
locations = self._uniforms.get("arrow", {})
GL.glUseProgram(arrow_program)
_set_mat4(GL, locations.get("u_view"), view)
_set_mat4(GL, locations.get("u_projection"), projection)
GL.glDisable(GL.GL_DEPTH_TEST)
for kind in ("flow", "contact"):
entry = self._overlay_vaos.get(kind)
if entry is None:
continue
vao, _vbo, _ebo, index_count = entry
if index_count <= 0:
continue
GL.glBindVertexArray(vao)
GL.glDrawElements(
GL.GL_TRIANGLES, int(index_count), GL.GL_UNSIGNED_INT, None
)
GL.glBindVertexArray(0)
GL.glEnable(GL.GL_DEPTH_TEST)
GL.glUseProgram(0)
self._paint_calls += 1
return RenderStats(
@@ -687,6 +839,8 @@ class GLRenderer:
from OpenGL import GL
self.release_parts()
for kind in list(self._overlay_vaos):
self._clear_overlay(kind)
for program in self._programs.values():
try:
GL.glDeleteProgram(int(program))
@@ -222,6 +222,16 @@ class ThreeDPanel(QFrame):
self.field_note.setVisible(False)
form.addRow(self.field_note)
self.flow_arrows_check = QCheckBox()
self.flow_arrows_check.setChecked(True)
self.flow_arrows_check.toggled.connect(self._emit_settings)
form.addRow(self.flow_arrows_check)
self.contact_arrow_check = QCheckBox()
self.contact_arrow_check.setChecked(True)
self.contact_arrow_check.toggled.connect(self._emit_settings)
form.addRow(self.contact_arrow_check)
self.scale_label = QLabel()
self.scale_combo = QComboBox()
self.scale_combo.addItem("fixed", "fixed")
@@ -403,6 +413,18 @@ class ThreeDPanel(QFrame):
self.deform_check.setChecked(False)
finally:
self._syncing = False
flow_ok = bool(capabilities and capabilities.flow_arrows_available)
contact_ok = bool(capabilities and capabilities.contact_arrow_available)
self.flow_arrows_check.setEnabled(flow_ok and enabled)
self.contact_arrow_check.setEnabled(contact_ok and enabled)
self.flow_arrows_check.setToolTip(
t("scene3d.flow_arrows_unavailable") if not flow_ok else t("scene3d.flow_arrows_tip")
)
self.contact_arrow_check.setToolTip(
t("scene3d.contact_arrow_unavailable")
if not contact_ok
else t("scene3d.contact_arrow_tip")
)
def capabilities(self) -> Optional[SceneCapabilities]:
return self._capabilities
@@ -604,6 +626,8 @@ class ThreeDPanel(QFrame):
self.housing_check,
self.deform_check,
self.gain_spin,
self.flow_arrows_check,
self.contact_arrow_check,
self.scale_combo,
self.fps_combo,
self.reset_button,
@@ -635,6 +659,8 @@ class ThreeDPanel(QFrame):
index = self.field_combo.findData(str(settings.field))
if index >= 0:
self.field_combo.setCurrentIndex(index)
self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled))
self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled))
scale = settings.color_scale
if scale is not None:
index = self.scale_combo.findData(str(scale.mode))
@@ -678,6 +704,8 @@ class ThreeDPanel(QFrame):
housing_visible=self.housing_check.isChecked(),
max_fps=int(self.fps_combo.currentData()),
field=self.field(),
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
)
# ------------------------------------------------------------------ signals
@@ -730,6 +758,8 @@ class ThreeDPanel(QFrame):
self.field_combo.setItemText(0, t("scene3d.field_depth"))
self.field_combo.setItemText(1, t("scene3d.field_flow"))
self._render_field_note()
self.flow_arrows_check.setText(t("scene3d.flow_arrows"))
self.contact_arrow_check.setText(t("scene3d.contact_arrow"))
self.scale_label.setText(t("scene3d.scale"))
self.range_label.setText(t("scene3d.scale_range"))
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
@@ -80,6 +80,12 @@ def settings_to_prefs(settings: DisplaySettings) -> dict[str, Any]:
"housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps),
"field": str(settings.field),
"flow_arrows_enabled": bool(settings.flow_arrows_enabled),
"contact_arrow_enabled": bool(settings.contact_arrow_enabled),
"flow_on_px": float(settings.flow_on_px),
"flow_off_px": float(settings.flow_off_px),
"flow_grid_spacing": int(settings.flow_grid_spacing),
"max_arrows": int(settings.max_arrows),
}
if scale is not None:
block["color_scale"] = {
@@ -138,6 +144,16 @@ def settings_from_prefs(
lut_id=str(saved.get("lut_id") or "sequential"),
mode=mode, # type: ignore[arg-type]
)
flow_on = max(0.0, _number(raw.get("flow_on_px"), 0.4))
flow_off = max(0.0, _number(raw.get("flow_off_px"), 0.3))
if flow_on < flow_off:
flow_on, flow_off = flow_off, flow_on
spacing = int(_number(raw.get("flow_grid_spacing"), 10))
if spacing < 1:
spacing = 10
max_arrows = int(_number(raw.get("max_arrows"), 400))
if max_arrows < 1:
max_arrows = 400
return DisplaySettings(
display_gain=gain,
deform_enabled=_flag(raw.get("deform_enabled"), False),
@@ -145,4 +161,10 @@ def settings_from_prefs(
housing_visible=_flag(raw.get("housing_visible"), True),
max_fps=max_fps,
field=field,
flow_arrows_enabled=_flag(raw.get("flow_arrows_enabled"), True),
contact_arrow_enabled=_flag(raw.get("contact_arrow_enabled"), True),
flow_on_px=flow_on,
flow_off_px=flow_off,
flow_grid_spacing=spacing,
max_arrows=max_arrows,
)
@@ -415,8 +415,10 @@ class Scene3DView(QWidget):
"""
adapter = self._adapter
descriptor = self._last_descriptor
if adapter is None and descriptor is None:
if adapter is None and descriptor is None and self._assets is None:
return None
mapping = getattr(self._assets, "surface_mapping", None) if self._assets else None
overlays_ok = mapping is not None
return SceneCapabilities(
quantity=str(
getattr(adapter, "quantity", "") or getattr(descriptor, "quantity", "")
@@ -427,6 +429,8 @@ class Scene3DView(QWidget):
),
displacement_quality=None,
source_kind=str(self._source_kind),
flow_arrows_available=overlays_ok,
contact_arrow_available=overlays_ok,
)
def _publish_capabilities(self) -> None:
@@ -18,6 +18,7 @@ from PyQt5.QtWidgets import (
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.force.history import DEFAULT_MAX_FPS
from orisys_app_shared.force.presentation import ForcePresentation
from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
@@ -70,8 +71,12 @@ def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
def format_force(value: float, unit: str) -> str:
def format_force(
value: float, unit: str, *, presentation: ForcePresentation | None = None
) -> str:
"""Render one force reading for the value cards."""
if presentation is not None:
return presentation.format_force(value, unit=unit)
if unit == "N":
return f"{value:.3f}"
return f"{value:.1f}"
@@ -137,6 +142,11 @@ class ForcePlotsPanel(QFrame):
self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
#: Display-only baseline / deadband for Finger raw value cards.
self.presentation = ForcePresentation(enabled=False)
self._sensor_type = "box"
self._display_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
self._display_suppressed: tuple[bool, bool, bool] = (False, False, False)
# -- browsing state -------------------------------------------------
#: Where the curves read from: the in-memory session store here, a
#: recording on disk in the replay. One interface either way.
@@ -484,7 +494,9 @@ class ForcePlotsPanel(QFrame):
self.history.reset()
self._clear_curves()
self._last_values = (0.0, 0.0, 0.0)
self._display_values = (0.0, 0.0, 0.0)
self._available = (True, True, True)
self.presentation.reset_session(unit=self._unit)
self._unit = "raw"
self._mode = "follow"
self._window = (0.0, 0.0)
@@ -500,6 +512,49 @@ class ForcePlotsPanel(QFrame):
self._apply_axis_mode()
self._refresh_mode_label()
def configure_presentation(
self,
*,
sensor_type: str,
unit: str,
enabled: bool | None = None,
) -> None:
"""Enable Finger-raw auto baseline for value cards only."""
self._sensor_type = str(sensor_type or "box")
use = (
bool(enabled)
if enabled is not None
else (self._sensor_type == "finger" and unit != "N")
)
self.presentation.enabled = use
self.presentation.reset_session(unit=unit)
def request_presentation_reestimate(self) -> None:
"""Restart the idle baseline window (display cards only)."""
self.presentation.request_reestimate()
self._display_values = self._last_values
self._render_value_cards()
def observe_for_baseline(
self,
values: tuple[float, float, float],
*,
t_rel_s: float,
valid: bool = True,
flow_p95: float | None = None,
contact_found: bool = False,
) -> None:
"""Feed the startup estimator; curves keep the raw measurement."""
if not self.presentation.enabled:
return
self.presentation.observe(
values,
t_rel_s=t_rel_s,
valid=valid,
flow_p95=flow_p95,
contact_found=contact_found,
)
def push(
self,
t_rel_s: float,
@@ -509,6 +564,9 @@ class ForcePlotsPanel(QFrame):
*,
unit: str = "raw",
available: tuple[bool, bool, bool] | None = None,
flow_p95: float | None = None,
contact_found: bool = False,
forces_valid: bool = True,
) -> None:
"""Append one live sample.
@@ -532,6 +590,18 @@ class ForcePlotsPanel(QFrame):
self._last_values = (float(fn), float(fx), float(fy))
if available is not None:
self._available = tuple(bool(flag) for flag in available)
self.observe_for_baseline(
self._last_values,
t_rel_s=float(t_rel_s),
valid=bool(forces_valid),
flow_p95=flow_p95,
contact_found=contact_found,
)
displayed = self.presentation.accept(
self._last_values, unit=unit, available=self._available
)
self._display_values = displayed.values
self._display_suppressed = displayed.suppressed
if unit != self._unit:
# A unit change mid-stream is a *switch*, never a reinterpretation:
# the buffered values were measured in the old unit and must not be
@@ -1182,6 +1252,8 @@ class ForcePlotsPanel(QFrame):
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled and self.presentation.ready:
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
# presented as a newton value.
@@ -1192,14 +1264,30 @@ class ForcePlotsPanel(QFrame):
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
)
continue
value_label.setText(fmt.format(value=value))
value_label.setToolTip(
t("chart.selection_peak_tip") if selecting is not None else ""
value_label.setText(
format_force(value, self._unit, presentation=self.presentation)
if selecting is None
else fmt.format(value=value)
)
tip = t("chart.selection_peak_tip") if selecting is not None else ""
zero_zone = (
selecting is None
and self.presentation.enabled
and self.presentation.ready
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
)
if zero_zone:
tip = t("chart.zero_zone_tip")
value_label.setToolTip(tip)
unit_label.setText(unit_display(self._unit))
detail_label.setText(
self._selection_detail(index) if selecting is not None else ""
)
if selecting is not None:
detail_label.setText(self._selection_detail(index))
elif zero_zone:
detail_label.setText(t("chart.zero_zone"))
else:
detail_label.setText("")
self.unit_badge.setText(unit_display(self._unit))
def _selection_detail(self, index: int) -> str:
+115
View File
@@ -0,0 +1,115 @@
"""Composer overlays and 2D/3D sample-point parity."""
from __future__ import annotations
import numpy as np
from orisys_app_shared.acquisition.arrows import _policy_from_style
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
DisplaySettings,
FieldDescriptor,
SurfaceFieldFrame,
freeze,
)
from orisys_app_shared.scene3d.synthetic import poc_identity, poc_scene
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
def _depth_frame(assets, *, flow=None, contact_xy=None, fn=0.0):
h, w = assets.shape_hw
scalar = freeze(np.zeros((h, w), dtype=np.float32), dtype=np.float32)
valid = freeze(np.ones((h, w), dtype=bool), dtype=np.bool_)
flow_uv = None if flow is None else freeze(np.asarray(flow, dtype=np.float32), dtype=np.float32)
flow_mask = None if flow is None else freeze(np.asarray(valid, dtype=bool).copy(), dtype=np.bool_)
found = contact_xy is not None
return SurfaceFieldFrame(
identity=poc_identity(),
geometry_id=assets.geometry_id,
field_domain_id=assets.field_domain_id,
scalar=scalar,
valid=valid,
scalar_descriptor=FieldDescriptor(
"depth_response", "1", "approximate", False
),
computed_frame_id=0,
flow_uv=flow_uv,
flow_valid_mask=flow_mask,
contact_uv_xy=None if contact_xy is None else tuple(contact_xy),
contact_found=found,
contact_valid=found,
contact_fn=float(fn),
)
def test_composer_builds_overlays_when_mapping_present() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0)
composed = SceneComposer().compose(
assets,
frame,
DisplaySettings(
flow_arrows_enabled=True,
contact_arrow_enabled=True,
flow_grid_spacing=1,
flow_on_px=0.4,
flow_off_px=0.3,
max_arrows=50,
),
)
assert composed.flow_arrows is not None
assert composed.flow_arrows.origins.shape[0] >= 1
assert composed.contact_arrow is not None
assert composed.contact_arrow.origins.shape[0] == 1
def test_composer_hides_overlays_when_disabled() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
frame = _depth_frame(assets, flow=flow, contact_xy=(1.0, 1.0), fn=800.0)
composed = SceneComposer().compose(
assets,
frame,
DisplaySettings(flow_arrows_enabled=False, contact_arrow_enabled=False),
)
assert composed.flow_arrows is None
assert composed.contact_arrow is None
def test_2d_3d_flow_sample_points_match() -> None:
assets = poc_scene()
h, w = assets.shape_hw
flow = np.zeros((h, w, 2), dtype=np.float64)
flow[1, 1] = (0.7, 0.2)
mask = np.ones((h, w), dtype=bool)
settings = DisplaySettings(
flow_on_px=0.4,
flow_off_px=0.3,
flow_grid_spacing=1,
max_arrows=50,
)
policy_3d = _policy_from_style(
"finger",
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
)
policy_shared = VisualPolicy(
flow_on_px=settings.flow_on_px,
flow_off_px=settings.flow_off_px,
flow_grid_spacing=settings.flow_grid_spacing,
max_arrows=settings.max_arrows,
flow_edge_margin=0,
).normalised()
samples_2d = select_flow_samples(flow, mask, policy_shared)
samples_3d = select_flow_samples(flow, mask, policy_shared)
assert list(zip(samples_2d.rows, samples_2d.cols)) == list(
zip(samples_3d.rows, samples_3d.cols)
)
assert policy_3d.flow_on_px == settings.flow_on_px
+12
View File
@@ -433,6 +433,12 @@ def test_the_display_settings_round_trip():
deform_enabled=True,
housing_visible=False,
max_fps=60,
flow_arrows_enabled=False,
contact_arrow_enabled=True,
flow_on_px=0.55,
flow_off_px=0.35,
flow_grid_spacing=12,
max_arrows=250,
color_scale=ColorScale(
descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
lower=0.25,
@@ -446,6 +452,12 @@ def test_the_display_settings_round_trip():
assert restored.deform_enabled is True
assert restored.housing_visible is False
assert restored.max_fps == 60
assert restored.flow_arrows_enabled is False
assert restored.contact_arrow_enabled is True
assert restored.flow_on_px == 0.55
assert restored.flow_off_px == 0.35
assert restored.flow_grid_spacing == 12
assert restored.max_arrows == 250
assert restored.color_scale is not None
assert (restored.color_scale.lower, restored.color_scale.upper) == (0.25, 4.5)
assert restored.color_scale.mode == "fixed"