Update workstation UI, ROS task control and device integration

This commit is contained in:
cheng
2026-10-20 15:46:13 +08:00
parent 2b74c50ba8
commit e5b06df6da
141 changed files with 91520 additions and 748 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 1016 KiB

+4
View File
@@ -0,0 +1,4 @@
# Project-local ROS discovery defaults. Apply by sourcing scripts/env.sh.
# Keep this project off the network-wide default domain 0.
ORISYS_ROS_DOMAIN_ID=42
ORISYS_ROS_LOCALHOST_ONLY=1
+42 -38
View File
@@ -378,9 +378,9 @@ camera:
name: "d405"
serial: "352122274408" # 全局缺省=左腕;真机以 robots[].camera.serial 为准
# USB3 推荐 848x480@30;USB2 自动降到 640x480@15,深度/右目仍开
width: 848
height: 480 # 改宽高/帧率必须重启相机:bash ~/ros2/scripts/run_camera_d405.sh --preview
fps: 30
width: 1280
height: 720
fps: 15
enable_depth: true
enable_infra: true # 同一 SDK 进程开左右红外;失败则自动退回彩图+深度
preview: true
@@ -401,8 +401,9 @@ cameras:
role: left_wrist
arm: left
label: 左腕D405(89 平行夹爪)
width: 848 # 相机分辨率以本段为准(全局 camera: 段为旧路径,P2 收敛)
height: 480
width: 1280
height: 720
fps: 15
emitter_enabled: false # 腕部必须关投射器,红外点阵会污染跟踪图像
d405_right:
model: d405
@@ -411,8 +412,9 @@ cameras:
role: right_wrist
arm: right
label: 右腕D405(91 旋转夹爪)
width: 848
height: 480
width: 1280
height: 720
fps: 15
emitter_enabled: false
# >>> 仿真那两台(arm: sim)<<<
# 为什么仿真也要在这里登记:应用是"按当前臂"去认相机的 —— 腕部相机取
@@ -447,11 +449,10 @@ cameras:
role: overhead_eye_to_hand
label: 双臂中间手外D435
name: d435
# 本机 D435 与 D405 挂在同一个 5Gbps 外接 hub 上:D435 跑 1280x720@30 时
# D405 只剩 4~6fps 并反复断流重连(二阶段 snap_coarse_1 因此超时);
# 停掉 D435 后 D405 立即稳定 30fps。D435 只做一次性顶视,848x480@15 足够。
width: 848
height: 480
# 彩色 1080p/15,原生深度由采集器选择兼容的 720p/15 并对齐。
# 与 D405 共用 5Gbps Hub;两路同时运行时需核对实际帧率与断流日志。
width: 1920
height: 1080
fps: 15
auto_exposure: true
ae_limit_us: 8000
@@ -772,6 +773,7 @@ hole_detect:
once_ff_look_per_m: 0.0
final_gap_mm: 2.0 # 对准后贴面目标:规尖离面 [mm](不接触)。笛卡尔锁 XY
# 时法兰 Z = 工具长 + 工件面 + 本值。CLI --final-gap 覆盖。
hole_entry_mm: 1.0 # 同步上游:对孔收尾规尖进入上表面下 1mm
observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm],
# 方便看规尖偏没偏。必须 ≥ final_gap_mm。
# 左臂89:悬停间隙约 73.5mm(0.320−0.2245−0.022)⇒ 观测下降约 58mm。
@@ -799,7 +801,7 @@ hole_detect:
max_depth_m: 0.60
linger_sec: 8.0 # 完成后继续发布结果/叠图的时长
# ===== 旋转装配仍读这里的力阈值(stage3_search_node)=====
# ===== 旧 stage3_search_node 的力阈值;首页旋转已使用独立 a7_rotation =====
# 搜孔轻压已取消:不带 --screw 的入口会直接拒绝,整体流程也不再进入这一步。
# bash ~/ros2/scripts/run_stage3_search.sh --arm 89 --screw
stage3:
@@ -919,8 +921,10 @@ gauge_assembly:
# ---- S01 回 home 后按工具系逆时针预绕到极限角 ----
# 预绕是为了给 S08 的棘轮留出回转行程:不预绕,拧两圈就贴关节限位。
extreme_angle_deg: 150.0 # 想转多少度(按工具系逆时针)
extreme_speed_deg_s: 20.0 # 关节角速度 [deg/s],慢一点防冲击
extreme_direction: 1 # +1=逆时针 / -1=顺时针(改符号,别去改代码)
extreme_speed_deg_s: 10.0 # 与上游 A7 首轮顺时针进孔工序一致
extreme_direction: -1 # 上游本机实测:A7 角度减小为逆时针
unwind_open_permille: 400 # 每轮松爪开度,千分比
unwind_lift_mm: 3.0 # 每轮微抬,降回使用本轮实测值
extreme_margin_deg: 3.0 # 距关节限位留的余量 [deg];撞限位不算失败(按 JOINT_LIMIT 继续)
# 但余量太小会让规划器频繁拒收,表现为"偶尔转不到位"
@@ -1085,30 +1089,21 @@ gauge_assembly:
# 不灵敏(压很深才停)→ 调小;同时看启动日志里的基线/实测值再定。
tactile:
backend: "orisys" # orisys | mock
# 传感器型号。必须与 devices/sensors/orisys/models/ 下的子目录名一致。
model: "dd03"
# 型号配置随 SDK 一起放:devices/sensors/orisys/models/<型号>/config.json
config_file: "devices/sensors/orisys/models/dd03/config.json"
# 用 by-path 稳定路径(插哪个 USB 口就认哪个口,不受 video 编号漂移影响)。
# 当前:3.4.1=左指 3.4.2=右指;若左右反了,互换两行即可。
left_video: "/dev/v4l/by-path/pci-0000:00:14.0-usb-0:3.4.1:1.0-video-index0"
right_video: "/dev/v4l/by-path/pci-0000:00:14.0-usb-0:3.4.2:1.0-video-index0"
cuda: false # 两路进程抢 GPU 上下文会互相拖慢;CPU 解算仅 ~9ms/帧,足够
isstitch: true
infer_backend: "cpu" # cupy | opencl | cpu;双路并行用 cpu 最稳
baseline_seconds: 2.0
filter_alpha: 0.25
motion_threshold: 0.0
backend: "station_flow"
arm_id: "89"
# Only low-level optical tactile SDK; no imports from the example gripper app.
sdk_python_path: "/home/cheng/orisys-sdk/src"
config_file: "src/arm_application/config/tactile/ctag2f90d.json"
# auto only selects Sunplus 1bcf tactile UVC cameras; explicit by-path recommended.
# Change these two values to stable device paths to fix the physical left/right order.
left_video: "auto"
right_video: "auto"
target_fps: 30.0
publish_hz: 20.0
# 视触觉图像与力矢量场的发布频率。0 = 关闭(默认)。
# 三力走 30fps 的轻量队列;图像每帧几百 KB,单独走一条 maxsize=1 的队列,
# 打开它不会影响三力的时序。需要看形变图/矢量场时设成 5~10 即可。
image_hz: 0.0
rotation_config: "" # 左右共用;有分指文件则用下面两项
left_rotation: "" # 例:orisys SDK 的 rotation_config_0.json
right_rotation: ""
publish_hz: 30.0
preview_transport: "compressed_flow"
motion_threshold: 0.0
cuda: false
force_units: "raw"
go_home:
pose: "home"
@@ -1144,3 +1139,12 @@ sim:
world: "devices/sim/gazebo/worlds/assembly.world"
physics_enable: true
joint_prefix: "sim_" # 仿真关节前缀,必须与真实臂前缀区分
# 仅 Qt 预览读取远端相机;机械臂/夹爪/传感器继续使用本地控制域。
remote_camera_preview:
enabled: true
host: "192.168.2.10"
interface_address: "192.168.2.50"
domain: 0
cameras: [d405_left, d435]
transport: raw # 远端当前没有 JPEG 预览话题
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Offline STEP -> triangle cache for Qt OpenGL. No robot/ROS/device operations.
Requires gmsh (OpenCascade). Runtime GUI needs only numpy and the generated NPZ.
"""
import argparse
import hashlib
import json
from pathlib import Path
import gmsh
import numpy as np
def convert(source, output):
source=Path(source).resolve();output=Path(output).resolve()
gmsh.initialize(['step-converter','-nopopup'])
try:
gmsh.option.setNumber('General.Terminal',0)
gmsh.option.setString('Geometry.OCCTargetUnit','MM')
gmsh.model.add('gripper')
gmsh.model.occ.importShapes(str(source));gmsh.model.occ.synchronize()
gmsh.option.setNumber('Mesh.MeshSizeMin',.8)
gmsh.option.setNumber('Mesh.MeshSizeMax',2.)
gmsh.option.setNumber('Mesh.MeshSizeFromCurvature',18)
gmsh.option.setNumber('Mesh.ElementOrder',1)
gmsh.model.mesh.generate(2)
tags,coords,_=gmsh.model.mesh.getNodes()
order=np.argsort(tags);sorted_tags=tags[order]
nodes=np.asarray(coords).reshape(-1,3)[order]
parts=[];colors=[];labels=[]
surfaces=gmsh.model.getEntities(2)
for part,(_,tag) in enumerate(surfaces):
kinds,_,element_nodes=gmsh.model.mesh.getElements(2,tag)
r,g,b,a=gmsh.model.getColor(2,tag)
base=np.array([r,g,b],float)/255.
if np.max(base)-np.min(base)<.03: base=np.array([.76,.79,.83])
for kind,indices in zip(kinds,element_nodes):
if kind!=2: raise ValueError('Only first order triangles are supported')
indices=np.asarray(indices).reshape(-1,3)
triangles=nodes[np.searchsorted(sorted_tags,indices)]
parts.append(triangles/1000.)
colors.append(np.tile(base,(len(triangles),1)))
labels.append(np.full(len(triangles),part,np.int32))
triangles=np.concatenate(parts).astype(np.float32)
rgb=np.concatenate(colors).astype(np.float32)
if not np.isfinite(triangles).all() or not len(triangles):raise ValueError('Invalid mesh')
lo=triangles.min(axis=(0,1));hi=triangles.max(axis=(0,1))
output.parent.mkdir(parents=True,exist_ok=True)
np.savez_compressed(output,triangles=triangles,colors=rgb,surface=np.concatenate(labels))
info=dict(source=source.name,source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),
units='m',source_units='mm',triangles=len(triangles),surfaces=len(surfaces),
solids=len(gmsh.model.getEntities(3)),bounds_m=[lo.tolist(),hi.tolist()],
dimensions_mm=((hi-lo)*1000).tolist(),articulated=False,
note='Original assembled STEP pose; no inferred joints or live finger animation.')
output.with_suffix('.json').write_text(json.dumps(info,ensure_ascii=False,indent=2)+'\n')
print(json.dumps(info,ensure_ascii=False))
finally:gmsh.finalize()
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('source');parser.add_argument('output')
args=parser.parse_args();convert(args.source,args.output)
+24 -5
View File
@@ -18,15 +18,34 @@ ACK = Path("/tmp/flexiv_movel_ack")
DONE = Path("/tmp/flexiv_movel_done")
def format_request(seq: int, x: float, y: float, z: float, keep_z: bool, vel: float) -> str:
return (
def format_request(
seq: int, x: float, y: float, z: float, keep_z: bool, vel: float,
zone: str = "", toler: int = -1,
) -> str:
"""一行请求。不带 ``zone`` 时仍是 6 个数,插孔和粗定位保持厂家默认到位。
微升降要带过渡区和到位等级。厂家默认 ``Z50``(50 mm)加等级 3,
几毫米的直线会在还差约 0.5 mm 时就报到位。合法的最细过渡区是 ``ZFine``,
最严且仍会检查到位的等级是 1(0 表示不检查)。
"""
if bool(zone) != (int(toler) >= 0):
raise ValueError("MoveL 过渡区和到位等级必须一起给")
if zone and (zone != "ZFine" or int(toler) != 1):
raise ValueError(f"MoveL 微升降只接受 ZFine 和到位等级 1,收到 {zone} {toler}")
line = (
f"{int(seq)} {float(x):.6f} {float(y):.6f} {float(z):.6f} "
f"{1 if keep_z else 0} {float(vel):.4f}\n"
f"{1 if keep_z else 0} {float(vel):.4f}"
)
if zone:
line += f" {zone} {int(toler)}"
return line + "\n"
def write_request(seq: int, x: float, y: float, z: float, keep_z: bool, vel: float) -> None:
text = format_request(seq, x, y, z, keep_z, vel)
def write_request(
seq: int, x: float, y: float, z: float, keep_z: bool, vel: float,
zone: str = "", toler: int = -1,
) -> None:
text = format_request(seq, x, y, z, keep_z, vel, zone, toler)
tmp = REQ.with_name(REQ.name + ".tmp")
tmp.write_text(text, encoding="utf-8")
tmp.replace(REQ)
+31 -42
View File
@@ -1,48 +1,37 @@
#!/usr/bin/env bash
# =============================================================================
# scripts/lib/ros_domain.sh —— 真机与仿真的 DDS 域隔离
#
# 为什么要单独抽一层
# ----------------
# 仿真侧(scripts/sim_env.sh)**强制** ROS_DOMAIN_ID=77,并且注释里写明了
# "隔离不能靠『环境里恰好没设』"。真机侧当时没有对应保护,于是同一个坑换个
# 方向又踩了一次(2026-09-28 实测):
#
# 先 source 过 sim_env.sh 的 shell(ROS_DOMAIN_ID=77 还 export 着)
# 再起真机栈 → 真机栈落到**仿真的域**上 → 两边节点名撞车
# (/controller_manager、/robot_state_publisher、/arm_status_node…)、
# /joint_states 与 /tf 各有两个来源互相盖。
#
# 现象极具误导性:控制器 feedback 与 reference 完全一致(控制器没坏),
# 但 TF 显示末端**一动不动** —— 顺着现象查会一路查到 IK / TF / 时钟上,
# 全是无辜的。
#
# 真机侧有两个入口(scripts/env.sh 给交互 shell 用;scripts/run_real.sh 自己
# 起 conda + install),两处都得有这个纠正,所以放在这里共用一份。
#
# 用法(在 `set -uo pipefail` 或 `set -eo pipefail` 之后 source):
# source ".../lib/ros_domain.sh"
# ros_domain_force_real
# =============================================================================
# 仿真的域。改这里要同步 scripts/sim_env.sh(那边是硬编码的 77)。
# One explicit discovery policy for the station and every child launch.
# The inherited generic ROS_DOMAIN_ID is never silently adopted.
ROS_SIM_DOMAIN=77
# 把 ROS_DOMAIN_ID 纠正到"真机该用的域"。
# · REAL_ROS_DOMAIN_ID 显式设了 → 用它(要换域就走这个,名字里带 REAL,
# 不会被仿真环境的 export 撞上);
# · 环境里是 77(仿真的域)→ **喊出来**再改回 0;
# · 环境里没设 → 显式写 0,让人一眼能从 `env` 里看出真机跑在哪个域。
ros_domain_force_real() {
if [ -n "${REAL_ROS_DOMAIN_ID:-}" ]; then
export ROS_DOMAIN_ID="${REAL_ROS_DOMAIN_ID}"
elif [ "${ROS_DOMAIN_ID:-0}" = "${ROS_SIM_DOMAIN}" ]; then
echo "[domain] ⚠ 环境里的 ROS_DOMAIN_ID=${ROS_SIM_DOMAIN} 是**仿真**用的域" >&2
echo "[domain] (scripts/sim_env.sh 强制那个值)。已改回 0 起真机栈 ——" >&2
echo "[domain] 真机与仿真同域会让节点名/话题互相盖,现象是'臂没动'" >&2
echo "[domain] 而控制器一切正常。要另指定请设 REAL_ROS_DOMAIN_ID。" >&2
export ROS_DOMAIN_ID=0
elif [ -z "${ROS_DOMAIN_ID:-}" ]; then
export ROS_DOMAIN_ID=0
local runtime_file requested_domain requested_localhost previous_domain
runtime_file="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/config/ros_runtime.env"
requested_domain="${REAL_ROS_DOMAIN_ID:-}"
requested_localhost="${REAL_ROS_LOCALHOST_ONLY:-}"
if [[ -f "${runtime_file}" ]]; then
source "${runtime_file}"
fi
requested_domain="${requested_domain:-${ORISYS_ROS_DOMAIN_ID:-42}}"
requested_localhost="${requested_localhost:-${ORISYS_ROS_LOCALHOST_ONLY:-1}}"
if [[ ! "${requested_domain}" =~ ^[0-9]+$ ]] || (( 10#${requested_domain} > 232 )); then
echo "[domain] ERROR: ROS domain must be an integer in 0..232" >&2
return 2
fi
if [[ "${requested_localhost}" != 0 && "${requested_localhost}" != 1 ]]; then
echo "[domain] ERROR: REAL_ROS_LOCALHOST_ONLY must be 0 or 1" >&2
return 2
fi
if (( 10#${requested_domain} == ROS_SIM_DOMAIN )); then
echo "[domain] ERROR: domain 77 is reserved for simulation" >&2
return 2
fi
previous_domain="${ROS_DOMAIN_ID:-unset}"
export ROS_DOMAIN_ID="$((10#${requested_domain}))"
export ROS_LOCALHOST_ONLY="${requested_localhost}"
# All nested launch scripts inherit exactly the same selection.
export REAL_ROS_DOMAIN_ID="${ROS_DOMAIN_ID}"
export REAL_ROS_LOCALHOST_ONLY="${ROS_LOCALHOST_ONLY}"
if [[ "${previous_domain}" != "${ROS_DOMAIN_ID}" ]]; then
echo "[domain] orisys_arm: ${previous_domain} -> ${ROS_DOMAIN_ID}; localhost_only=${ROS_LOCALHOST_ONLY}" >&2
fi
}
+39
View File
@@ -0,0 +1,39 @@
# arm_application
工位 ROS 2 业务节点包。日常入口是本仓库的 `station/run_station.sh`;本地 Qt 界面位于 `station/src/station`,不在本包内。
## 上游同步
2026-10-20 从 `http://git.orisys-tech.cn/orisys/ros_contorl.git` 的 `exp/hole-keypoint-once` 分支同步,提交 `561d7da21f20d3e63030f81510ae2f29eed01d46`。
同步内容:视觉感知/跟踪、二阶段孔定位、流水线与 A7 工序、通止规装配状态机、夹爪串口修复、双夹爪节点、手眼标定 ROS 工具,以及对应算法、配置、launch、轨迹和节点测试。
未引入远程的 `operator_*` Qt/网页控制台、`realsense_view` Qt 预览、独立权重整理工具、整站 shell 入口和根目录现场配置。相应界面/脚本测试不随本包同步;混合测试文件中的远程整站检查有明确 skip 原因。本地工位与配置继续使用现有版本。
保留本地相机高清采集与压缩预览、每指独立触觉节点和力标定、工位夹爪节点、进程树清理和短结果发布周期;保留 `gauge_pass.py`,本地工位仍依赖它。
## 主要 ROS 入口
- 感知与相机:`realsense_color_pub`、`perception_node`、`servo_node`、`hole_detect`。
- 任务:`stage2_locate`、`stage3_search`、`assembly_pipeline`、`gauge_assembly`、`a7_rotation`。
- 本地设备驱动:`tactile_flow`(每指一个进程)、`station_gripper`(每臂独立命名空间)。
- 显式兼容工具:`dh_pgea`、`dual_gripper`、`tactile` 及对应 CLI。不与本地设备驱动同时占用同一串口/传感器。
- 标定:`handeye_teach`、`handeye_waypoints`、`handeye_calibrate`。
注册入口以 `setup.py` 为准。导入模块不会自动运行这些动作。
## 本地兼容修复
- 一阶段清理只停止自己启动的子进程树,禁止按名称 `pkill` 其他节点。
- 父类机器人描述回调独立命名,避免被装配节点遮蔽。
- A7 失败返回失败;pipeline 子节点非零退出时,launch 同样失败。
- 二阶段 Action 只把 SUCCEEDED 且 error_code=0 判为成功;接单超时不重发并取消迟到任务,执行超时取消并中止流程。
- 保留夹爪 `GripperExecute` 服务及类型化状态,兼容远程六元素旧状态帧的在线标志。
- `run_a7_after_insert` 保留上游工序开关;本地工位在定位进程中显式传 false;“旋转”使用独立 a7_rotation 节点,“开始全部流程”在定位成功后显式进入该节点。对孔采用新版配置下降,不再强制 no_descent。
- 同步 `scripts/lib/flexiv_movel_ipc.py` 的可选 ZFine/到位等级参数,并在本地硬件接口补充对应解析。原先六字段请求保持兼容,本地停止/重新使能位置指令保护保留。
## 验证
在 `ros_flexiv` 环境和已构建的 ROS 工作空间中运行本包 pytest;需要 X11 的工位回归使用 `bash station/run_station.sh --self-test`。测试使用模拟驱动/独立 DDS 域,不下发真实设备指令。
同步后应在现场任务停止时重启工位与驱动,使新节点入口、后端和硬件库一起生效;这次同步没有重启现有进程,也没有执行实机动作。
@@ -1 +1,32 @@
"""arm_application:从旧工位抽出的节点。"""
"""arm_application —— Flexiv Rizon + D405 + DH 夹爪的现场节点包。
对外可直接运行的 ROS 程序(见 ``setup.py`` 的 ``console_scripts``)
------------------------------------------------------------------
相机: realsense_color_pub
感知: perception_node
伺服: servo_node
手眼: handeye_teach、handeye_waypoints、handeye_calibrate
夹爪: dh_pgea、dh_pgea_cli、dual_gripper、dual_gripper_cli
入孔检测: hole_detect
触觉: tactile、tactile_cli
辅助: goto_ee_plane、demo_named_poses、run_joint_trajectory
可复用的 Python 模块(可以在你自己的节点里 import)
------------------------------------------------------------------
``yolo_detector`` YOLO / TensorRT 推理 + ``pick_best`` 挑最优框
``box_tracker`` 像素平面内的匀速模型框预测(丢帧时"滑行"用)
``plane_feature`` 在 YOLO 框内取立体平面的质心
``dh_pgea`` 大寰 PGEA 的官方 Modbus RTU 客户端
``dh_official`` ``dual_gripper`` 用的 DH Modbus 客户端
``dual_gripper`` 左 PGEA + 右 RGI
``hole_detector`` yolo / hough / two_stage 三种孔心检测
``orisys_tactile`` Orisys 视触觉适配器
``geom_ik`` Rizon 几何雅可比 / DLS / 手眼加载
``handeye_lib`` 棋盘格 + OpenCV ``calibrateHandEye``
``handeye_realsense`` D405 的话题 / 序列号辅助
``pid_controller`` 带积分分离的 PID
``kalman_filter`` 三维匀速模型卡尔曼滤波
本仓库日常操作使用 station 工位与 config/site.yaml;远程 Qt/网页控制台不随节点同步。
接口契约见 ``docs/INTERFACES.md``。
"""
@@ -0,0 +1,104 @@
"""Explicit A7 stage, reusing upstream rounds with station-owned typed gripper IO.
Starts at the current aligned pose: no tracking, clearance lift or hole search.
"""
from __future__ import annotations
import math
import os
import time
from arm_msgs.msg import GripperStatus
from arm_msgs.srv import GripperExecute
from arm_application.pipeline_node import AssemblyPipeline, close_ready, main as pipeline_main
class A7Rotation(AssemblyPipeline):
def __init__(self):
super().__init__(node_name='orisys_a7_rotation')
self._grip_feedback=None
self._stroke=float(os.environ.get('SITE_GRIP_STROKE_MM','0'))
self._force=float(os.environ.get('SITE_GRIP_FORCE','33'))
self._speed=float(os.environ.get('SITE_GRIP_SPEED','25'))
if not math.isfinite(self._stroke) or self._stroke<=0:
raise ValueError('未配置有效夹爪行程')
if not 20<=self._force<=100 or not 1<=self._speed<=100:
raise ValueError('夹爪力或速度参数无效')
aid=self._arm_short_id()
if not aid:raise ValueError('未配置当前机械臂号')
endpoint=f'/station/grippers/arm_{aid}'
self._grip_client=self.create_client(GripperExecute,endpoint+'/execute')
self.create_subscription(GripperStatus,endpoint+'/status',self._on_rotation_grip,10)
def _on_rotation_grip(self,msg):
self._grip_feedback=(time.monotonic(),msg)
def _fresh_grip(self,after=0.):
reading=self._grip_feedback
if not reading or reading[0]<=after or time.monotonic()-reading[0]>.75:
return None
return reading[1] if reading[1].connected else None
def _gripper_cli(self,action,value=''):
"""One typed command; await new feedback, never launch a shell or resend."""
target=0. if action=='close' else float(value) if action=='pos' else float('nan')
if not math.isfinite(target) or not 0<=target<=1000:
raise ValueError('A7 夹爪只接受 close 或 pos 0–1000')
before=self._fresh_grip()
if before is None or before.is_moving:
raise RuntimeError('夹爪离线、反馈过期或仍在运动,旋转流程中止')
if not self._grip_client.wait_for_service(timeout_sec=2.):
raise RuntimeError('本站夹爪 execute 服务不可用')
req=GripperExecute.Request()
req.command.command='move';req.command.position=target/1000*self._stroke
req.command.force=self._force;req.command.speed=self._speed
req.command.wait_complete=False;req.command.timeout=8.
self.get_logger().info(f'旋转夹爪:{action},目标开度 {target:.0f}/1000')
sent=time.monotonic();future=self._grip_client.call_async(req)
while not future.done() and time.monotonic()-sent<5.:self._spin(.02)
if not future.done():raise RuntimeError('夹爪命令响应超时,结果未知;中止且不重发')
response=future.result()
if not response.success:raise RuntimeError('夹爪拒绝命令:'+response.message)
while time.monotonic()-sent<12.:
self._spin(.02)
state=self._fresh_grip(after=sent)
if state is None:continue
if state.is_moving:continue
pos=float(state.finger_position_per_mille)
reached=close_ready(state.gripper_state,pos) if action=='close' else state.gripper_state==1 and abs(pos-target)<=40.
if reached:
self.get_logger().info(f'旋转夹爪已到位:{pos:.0f}/1000')
return True
raise RuntimeError('夹爪未收到新鲜到位反馈,旋转流程中止且不重发')
def run(self):
if not self._cli.wait_for_server(timeout_sec=5.):
raise RuntimeError('机械臂轨迹服务不可用')
if not self._grip_client.wait_for_service(timeout_sec=3.):
raise RuntimeError('请先连接本站平行夹爪')
self._spin(.2)
grip=self._fresh_grip()
if grip is None or grip.is_moving:
raise RuntimeError('夹爪未就绪,不能开始 A7 工序')
if not close_ready(grip.gripper_state,float(grip.finger_position_per_mille)):
raise RuntimeError('夹爪未夹紧,请先夹爪闭合再开始旋转')
q,tee=self._wait_state(5.)
if q is None or tee is None:raise RuntimeError('没有机械臂实时位姿')
# The new stage must not turn at the former 15mm observation height.
if not self.tool_tip_below_flange_m>0 or not self.surface_z_m>0:
raise RuntimeError('缺少规长或工件面几何,不能确认旋转起点')
gap=float(tee[2,3])-self.tool_tip_below_flange_m-self.surface_z_m
if not math.isfinite(gap) or not math.isfinite(self.insert_gap_m) or abs(gap-self.insert_gap_m)>.002:
raise RuntimeError(f'当前位置离对孔收尾高度过远(间隙 {gap*1000:.1f}mm),请先完成新版对孔')
if not math.isfinite(float(tee[2,2])) or float(tee[2,2])>-.98:
raise RuntimeError('工具轴未垂直向下,请先完成对孔')
self._state('stage3_a7_rotation')
ok=self._run_a7_seat()
self._state('stage3_a7_done' if ok else 'stage3_a7_failed')
return 0 if ok else 2
def main(args=None):
pipeline_main(args=args,node_factory=A7Rotation)
if __name__=='__main__':main()
@@ -0,0 +1,10 @@
"""Choose only enumerated camera modes; color and native depth may differ."""
def depth_profile(color, available):
available=set(available)
if color in available: return color
width,height,fps=color
matches=[p for p in available if p[2]==fps and p[0]<=width and p[1]<=height]
if not matches: raise ValueError(f'No native depth profile at {fps} fps for color {color}')
return max(matches,key=lambda p:p[0]*p[1])
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""演示:通过 ``/set_named_pose`` 循环切换命名位姿(对任何后端都适用)。"""
from __future__ import annotations
import time
import rclpy
from rclpy.node import Node
from arm_msgs.srv import SetNamedPose
from arm_application.ros_node import TolerantNode
class DemoNamedPoses(TolerantNode):
def __init__(self) -> None:
super().__init__('demo_named_poses')
self.declare_parameter(
'poses', ['home', 'ready', 'inspect', 'pos_left', 'home']
)
self.declare_parameter('velocity_scale', 0.5)
self.declare_parameter('dwell_sec', 0.3)
self.declare_parameter('loops', 1)
self.poses = list(
self.get_parameter('poses').get_parameter_value().string_array_value
or ['home', 'ready', 'inspect', 'home']
)
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
self.dwell_sec = float(self.get_parameter('dwell_sec').value)
self.loops = int(self.get_parameter('loops').value)
self._cli = self.create_client(SetNamedPose, 'set_named_pose')
def run(self) -> int:
if not self._cli.wait_for_service(timeout_sec=15.0):
self.get_logger().error('``/set_named_pose`` 服务不可用')
return 1
for loop in range(max(self.loops, 1)):
self.get_logger().info(f'第 {loop + 1}/{self.loops} 轮')
for pose in self.poses:
req = SetNamedPose.Request()
req.pose_name = pose
req.velocity_scale = self.velocity_scale
req.acceleration_scale = self.velocity_scale
fut = self._cli.call_async(req)
deadline = time.monotonic() + 60.0
while rclpy.ok() and not fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error(f'{pose} 调用超时')
return 2
res = fut.result()
if res is None or not res.success:
self.get_logger().error(
f'{pose} 执行失败:{res.message if res else "无响应"}'
)
return 3
self.get_logger().info(f'{pose}:{res.message}')
time.sleep(self.dwell_sec)
self.get_logger().info('演示结束')
return 0
def main(args=None) -> None:
rclpy.init(args=args)
node = DemoNamedPoses()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,286 @@
"""DH-Robotics 官方 Modbus RTU 控制(非 Flexiv RDK)。
覆盖两类设备:
- PGE/PGEA 平行夹爪:初始化 0x0100,力 0x0101,位置 0x0103(0~1000‰),速度 0x0104。
- RGI 旋转夹爪:在平行夹爪寄存器基础上,增加旋转角度 0x0105、旋转速度 0x0107、
旋转力 0x0108;反馈增加旋转角度 0x0208、旋转初始化状态 0x020A、旋转状态 0x020B。
说明:
- RGI 手册示例中负角度使用“反码/one's complement”:+360 -> 0x0168,-360 -> 0xFE97。
因此这里默认按 one's complement 编解码;若后续厂家确认为 two's complement,只改
`_encode_signed` / `_decode_signed` 两处即可。
- 写位置/角度寄存器后会立即运动,没有 ChangingTek SDK 的 0x0108 触发寄存器。
"""
from __future__ import annotations
import threading
import time
from typing import Iterable
# =============================================================================
# DH-Robotics Modbus 寄存器映射
# =============================================================================
# 基础控制寄存器(保持寄存器,功能码 03/06/10)
REG_INIT = 0x0100 # 0x01 初始化,0xA5 完全初始化
REG_FORCE = 0x0101 # 夹爪闭合力,20~100 %
REG_POSITION = 0x0103 # 手指参考位置,0~1000 千分比
REG_SPEED = 0x0104 # 手指开合速度,1~100 %
# RGI 旋转控制寄存器
REG_ROTATION_ANGLE = 0x0105 # 目标旋转角度,有符号角度值
REG_ROTATION_SPEED = 0x0107 # 旋转速度,1~100 %
REG_ROTATION_FORCE = 0x0108 # 旋转力/力矩,20~100 %
# 反馈寄存器
REG_INIT_STATE = 0x0200 # 0 未初始化,1 已初始化,2 初始化中
REG_GRIPPER_STATE = 0x0201 # 0 运动中,1 已到位,2 已夹到物体,3 物体掉落
REG_CURRENT_POSITION = 0x0202 # 当前手指位置,千分比
REG_ROTATION_ANGLE_FB = 0x0208 # 当前旋转角度,有符号角度值
REG_ROTATION_INIT_STATE = 0x020A # 0 未初始化,1 已初始化,2 初始化中
REG_ROTATION_STATE = 0x020B # 0 运动中,1 已到位,2 受阻,3 曾经受阻
# 配置寄存器
REG_SAVE_PARAMS = 0x0300 # 写入 1 可把所有参数保存到 flash
class DHModbusError(RuntimeError):
"""DH Modbus 通信或协议错误。"""
def _clamp_int(value: float | int, lo: int, hi: int) -> int:
return int(max(lo, min(hi, int(round(float(value))))))
def _encode_signed(value: float | int) -> int:
"""把有符号角度值编码为 DH 的反码(one's complement)寄存器值。
RGI 手册示例:+360 -> 0x0168,-360 -> 0xFE97。
"""
value = _clamp_int(value, -32767, 32767)
if value >= 0:
return value & 0xFFFF
return (~abs(value)) & 0xFFFF
def _decode_signed(raw: int) -> int:
"""把 DH 的反码(one's complement)寄存器值解码为有符号整数。"""
raw = int(raw) & 0xFFFF
if raw & 0x8000:
mag = (~raw) & 0x7FFF
return -mag
return raw & 0x7FFF
class DHModbusClient:
"""面向 DH-Robotics 夹爪的最小线程安全 Modbus RTU 客户端。"""
def __init__(
self,
port: str,
slave_id: int = 1,
baudrate: int = 115200,
timeout: float = 0.2,
*,
bytesize: int = 8,
parity: str = "N",
stopbits: int = 1,
) -> None:
try:
import minimalmodbus
import serial
except ImportError as exc: # pragma: no cover - 取决于运行时环境
raise DHModbusError(
"缺少 minimalmodbus/pyserial。请先安装:pip install minimalmodbus pyserial"
) from exc
parity_map = {
"N": serial.PARITY_NONE,
"E": serial.PARITY_EVEN,
"O": serial.PARITY_ODD,
}
self.instrument = minimalmodbus.Instrument(port, int(slave_id))
self.instrument.serial.baudrate = int(baudrate)
self.instrument.serial.bytesize = int(bytesize)
self.instrument.serial.parity = parity_map.get(str(parity).upper(), serial.PARITY_NONE)
self.instrument.serial.stopbits = int(stopbits)
self.instrument.serial.timeout = float(timeout)
self.instrument.mode = minimalmodbus.MODE_RTU
self.instrument.clear_buffers_before_each_transaction = True
try:
self.instrument.serial.exclusive = True
except Exception:
pass
self._lock = threading.Lock()
self.port = port
def close(self) -> None:
with self._lock:
try:
self.instrument.serial.close()
except Exception:
pass
# ------------------------------------------------------------------
# 底层寄存器读写
# ------------------------------------------------------------------
def write_register(self, address: int, value: int) -> None:
value = _clamp_int(value, 0, 0xFFFF)
last_exc: Exception | None = None
for attempt in range(3):
try:
with self._lock:
self.instrument.write_register(address, value, functioncode=6)
return
except Exception as exc:
last_exc = exc
time.sleep(0.05 * (attempt + 1))
raise DHModbusError(f"写寄存器 0x{address:04X}={value} 失败: {last_exc}") from last_exc
def read_register(self, address: int) -> int:
last_exc: Exception | None = None
for attempt in range(3):
try:
with self._lock:
return int(self.instrument.read_register(address, functioncode=3))
except Exception as exc:
last_exc = exc
time.sleep(0.05 * (attempt + 1))
hint = ""
text = str(last_exc).lower()
if "multiple access" in text or "no data" in text:
hint = f"。串口 {getattr(self, 'port', '')} 可能被其它进程占用,先执行: fuser -k {getattr(self, 'port', '/dev/ttyUSB*')}"
raise DHModbusError(f"读寄存器 0x{address:04X} 失败: {last_exc}{hint}") from last_exc
def write_registers(self, address: int, values: Iterable[int]) -> None:
vals = [_clamp_int(v, 0, 0xFFFF) for v in values]
with self._lock:
self.instrument.write_registers(address, vals)
def read_registers(self, address: int, count: int) -> list[int]:
with self._lock:
return [int(v) for v in self.instrument.read_registers(address, count, functioncode=3)]
def write_signed_register(self, address: int, value: float | int) -> None:
self.write_register(address, _encode_signed(value))
def read_signed_register(self, address: int) -> int:
return _decode_signed(self.read_register(address))
# ------------------------------------------------------------------
# PGE/PGEA/RGI 通用控制
# ------------------------------------------------------------------
def initialize(self, *, full: bool = False, rotation: bool = False, timeout: float = 10.0) -> None:
"""初始化/回零夹爪,并等待其就绪。"""
self.write_register(REG_INIT, 0xA5 if full else 0x01)
start = time.monotonic()
while time.monotonic() - start < timeout:
init_state = self.read_register(REG_INIT_STATE)
rot_ok = True
if rotation:
rot_ok = self.read_register(REG_ROTATION_INIT_STATE) == 1
if init_state == 1 and rot_ok:
return
time.sleep(0.1)
raise DHModbusError(f"夹爪初始化超时(>{timeout:.1f}s)")
def save_parameters(self) -> None:
self.write_register(REG_SAVE_PARAMS, 1)
def set_force_pct(self, force_pct: float | int) -> int:
value = _clamp_int(force_pct, 20, 100)
self.write_register(REG_FORCE, value)
return value
def set_speed_pct(self, speed_pct: float | int) -> int:
value = _clamp_int(speed_pct, 1, 100)
self.write_register(REG_SPEED, value)
return value
def set_position_per_mille(self, position: float | int) -> int:
value = _clamp_int(position, 0, 1000)
# 与 dh_pgea.latched_position_break 同一份:命令已是目标时先写当前位置,
# 读回之后再写最终值。60ms 后写回旧命令,固件采样不到,手指不动。
from arm_application.dh_pgea import latched_position_break
try:
cmd = _clamp_int(self.read_register(REG_POSITION), 0, 1000)
except Exception:
cmd = value
try:
cur = self.get_position_per_mille()
except Exception:
cur = value
bridge = latched_position_break(cmd, cur, value)
if bridge is not None:
self.write_register(REG_POSITION, bridge)
deadline = time.monotonic() + 0.6
echoed = False
while time.monotonic() < deadline:
try:
if _clamp_int(self.read_register(REG_POSITION), 0, 1000) == bridge:
echoed = True
break
except Exception:
pass
time.sleep(0.05)
if not echoed:
raise DHModbusError(
f"位置命令中间值 {bridge} 没有读回,不再写入最终目标 {value}"
)
time.sleep(0.15)
self.write_register(REG_POSITION, value)
return value
def get_position_per_mille(self) -> int:
return _clamp_int(self.read_register(REG_CURRENT_POSITION), 0, 1000)
def get_gripper_state(self) -> int:
return self.read_register(REG_GRIPPER_STATE)
def get_init_state(self) -> int:
return self.read_register(REG_INIT_STATE)
def hold_current_position(self) -> None:
"""在没有专用停止寄存器的 DH 夹爪上尽力停止/保持。"""
self.set_position_per_mille(self.get_position_per_mille())
# ------------------------------------------------------------------
# RGI 旋转控制
# ------------------------------------------------------------------
def set_rotation_speed_pct(self, speed_pct: float | int) -> int:
value = _clamp_int(speed_pct, 1, 100)
self.write_register(REG_ROTATION_SPEED, value)
return value
def set_rotation_force_pct(self, force_pct: float | int) -> int:
value = _clamp_int(force_pct, 20, 100)
self.write_register(REG_ROTATION_FORCE, value)
return value
def set_rotation_angle_deg(self, angle_deg: float | int) -> int:
angle = _clamp_int(angle_deg, -32767, 32767)
self.write_signed_register(REG_ROTATION_ANGLE, angle)
return angle
def get_rotation_angle_raw(self) -> int:
return self.read_register(REG_ROTATION_ANGLE_FB) & 0xFFFF
def get_rotation_angle_deg(self) -> int:
return self.read_signed_register(REG_ROTATION_ANGLE_FB)
def get_rotation_init_state(self) -> int:
return self.read_register(REG_ROTATION_INIT_STATE)
def get_rotation_state(self) -> int:
return self.read_register(REG_ROTATION_STATE)
def hold_current_rotation(self) -> None:
"""重新下发当前反馈角度来停止旋转。"""
self.write_register(REG_ROTATION_ANGLE, self.get_rotation_angle_raw())
def hold_current_all(self) -> None:
self.hold_current_position()
self.hold_current_rotation()
+74 -5
View File
@@ -21,11 +21,15 @@ Flexiv RDK 的 ``Gripper`` 驱动不了这个夹爪:它不是 Studio Device。
from __future__ import annotations
import argparse
import fcntl
import select
import struct
import sys
import termios
import threading
import time
from dataclasses import dataclass
from typing import Optional
from typing import Callable, Optional
try:
import serial
@@ -104,6 +108,21 @@ INIT_STATE_NAME = {
}
def wait_tx_empty(queued: Callable[[], int], deadline: float, pause=time.sleep) -> bool:
"""等到发送缓冲里的字节数变成 0。到 ``deadline`` 还没空就返回 False。
不要改成 ``Serial.flush()`` / ``tcdrain``。USB 串口不收数据时那个调用
不看写超时,会把夹爪节点的回调一直堵住。2026-10-20 合爪:命令 10:40:16
已经交给话题,节点 120s 后才打出 ``ctrl pos=0``,流水线 8s 时手指还停在 400。
"""
while True:
if int(queued()) == 0:
return True
if time.monotonic() >= deadline:
return False
pause(0.01)
def latched_position_break(command: int, current: int, target: int) -> Optional[int]:
"""命令寄存器已经等于目标、手指却不在目标上时,返回要先写入的中间位置。
@@ -204,6 +223,22 @@ class DhPgeaError(RuntimeError):
pass
class DhPgeaBusError(DhPgeaError):
"""串口本身坏了(EIO / 设备被拔 / USB 掉线),不是夹爪没应答。
2026-10-20 之前 ``_transact`` 让 ``termios.error: (5, 'Input/output error')``
直接冒到 timer 回调外,整个夹爪节点带着 traceback 退出。这两种失败必须分开:
没应答可以继续重试;串口坏了要先关再重开。
"""
#: 连续这么多拍读不到状态就重开串口。
#: 2026-10-20 实测:写命令有回显(``ctrl pos=400`` 有记录、手指也动了),
#: 读 ``0x0201`` 却连续 264 次无应答;重启节点立刻恢复。说明串口会话坏了,
#: 光在同一个 fd 上重试没用。按失败拍数(不是秒)计数,是因为每拍耗时随重试变长。
BUS_REOPEN_AFTER_FAILURES = 30
WIRING_HINT = (
"PC 已发出 Modbus,夹爪 0 字节返回。不是力/速度参数问题。按顺序查:\n"
" 1) 夹爪 24 V DC 已上电(指示灯亮)\n"
@@ -362,22 +397,56 @@ class DhPgeaGripper:
if self._ser is None or not self._ser.is_open:
raise DhPgeaError("串口未打开")
bit_s = 10.0 / max(1, self.baudrate)
try:
self._set_tx(True)
if self.rs485_rts:
time.sleep(2.0 * bit_s)
self._ser.reset_input_buffer()
self._ser.write(frame)
self._ser.flush()
# 不用 flush/tcdrain。发送缓冲排不空就在 timeout_s 内失败,回调才能返回。
# 读应答另给一个 timeout_s,不能和排空共用同一个截止时间。
if not wait_tx_empty(self._tx_queued, time.monotonic() + self.timeout_s):
raise DhPgeaError(
f"{self.port} 串口发送缓冲 {self.timeout_s:.2f}s 内没排空"
)
if self.rs485_rts:
time.sleep(len(frame) * bit_s + 0.0008)
self._set_tx(False)
deadline = time.monotonic() + self.timeout_s
buf = self._read_reply(expect, time.monotonic() + self.timeout_s)
except OSError as exc:
raise DhPgeaBusError(
f"{self.port} 串口读写失败({exc})。"
"设备被拔掉或 USB 掉线时要先关串口再重开,不是夹爪没应答。"
) from exc
return bytes(buf) if buf else None
def _tx_queued(self) -> int:
"""内核发送缓冲里还没推出去的字节数。"""
raw = fcntl.ioctl(self._ser.fileno(), termios.TIOCOUTQ, struct.pack("I", 0))
return struct.unpack("I", raw)[0]
def _read_reply(self, expect: int, deadline: float) -> bytes:
"""读应答。单次 ``read`` 不许越过 ``deadline``。
pyserial 的超时在 USB 卡住时不可靠;``select`` 到点就停。
"""
fd = self._ser.fileno()
buf = bytearray()
while time.monotonic() < deadline and len(buf) < expect:
remain = deadline - time.monotonic()
ready, _, _ = select.select([fd], [], [], remain)
if not ready:
break
old = self._ser.timeout
self._ser.timeout = 0
try:
chunk = self._ser.read(expect - len(buf))
if chunk:
finally:
self._ser.timeout = old
if not chunk:
break
buf.extend(chunk)
return bytes(buf) if buf else None
return bytes(buf)
def initialization(self) -> bool:
return self.write_register(REG_INIT, INIT_CMD)
@@ -6,8 +6,9 @@
data: [initialize, position, force, speed]
initialize>0.5 → 写 0xA5;否则设置力/速度/位置
/gripper/states std_msgs/Float32MultiArray
data: [initialized, grip_state, position, target_pos, target_force]
/gripper/joint_states sensor_msgs/JointState
data: [initialized, grip_state, position, target_pos, target_force, online]
online=1 这一帧刚从总线读回来;0=读不到(前 5 个是上一次的值)
/gripper/joint_states sensor_msgs/JointState(只在读到状态时发)
服务(std_srvs/Trigger):
/gripper/initialize /gripper/open /gripper/close
@@ -17,25 +18,29 @@
from __future__ import annotations
import math
from arm_msgs.msg import GripperStatus
from arm_msgs.srv import GripperExecute
import threading
import time
from typing import Optional
import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from arm_msgs.msg import GripperStatus
from arm_msgs.srv import GripperExecute
import math
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import Float32, Float32MultiArray, MultiArrayDimension, MultiArrayLayout
from std_srvs.srv import Trigger
from arm_application.dh_pgea import (
BUS_REOPEN_AFTER_FAILURES,
DhPgeaBusError,
DhPgeaError,
DhPgeaGripper,
GripperState,
WIRING_HINT,
)
from arm_application.gauge_gripper import STATES_ONLINE_INDEX
from arm_application.ros_node import TolerantNode, param_as_str
@@ -48,14 +53,14 @@ def _as_ctrl_array(msg: Float32MultiArray) -> tuple[float, float, float, float]:
class DhPgeaNode(TolerantNode):
def __init__(self) -> None:
super().__init__("dh_pgea", namespace="/gripper")
super().__init__("dh_pgea")
self.declare_parameter("port", "/dev/ttyUSB0")
self.declare_parameter("baud", 115200)
self.declare_parameter("slave_id", 1)
self.declare_parameter("stroke_mm", 20.0)
self.declare_parameter("force_pct", 50)
self.declare_parameter("speed_pct", 50)
self.declare_parameter("auto_init", False)
self.declare_parameter("auto_init", True)
self.declare_parameter("state_hz", 20.0)
self.declare_parameter("open_position", 1000)
self.declare_parameter("close_position", 0)
@@ -67,8 +72,6 @@ class DhPgeaNode(TolerantNode):
self._baud = int(self.get_parameter("baud").value)
self._slave = int(self.get_parameter("slave_id").value)
self._stroke = float(self.get_parameter("stroke_mm").value)
if not math.isfinite(self._stroke) or self._stroke <= 0:
raise DhPgeaError("夹爪行程必须是正数")
self._force = int(self.get_parameter("force_pct").value)
self._speed = int(self.get_parameter("speed_pct").value)
_auto = self.get_parameter("auto_init").value
@@ -89,17 +92,19 @@ class DhPgeaNode(TolerantNode):
)
self._state = GripperState(stroke_mm=self._stroke)
self._cmd_lock = threading.Lock()
self._last_reopen = 0.0
self._fail_streak = 0
self._pub_state = self.create_publisher(Float32MultiArray, "states", 10)
self._pub_state = self.create_publisher(Float32MultiArray, "/gripper/states", 10)
self._pub_typed = self.create_publisher(GripperStatus, "status", 10)
self.create_service(GripperExecute, "execute", self._srv_execute)
self._pub_js = self.create_publisher(JointState, "joint_states", 10)
self.create_subscription(Float32MultiArray, "ctrl", self._on_ctrl, 10)
self.create_subscription(Float32, "set_position", self._on_set_pos, 10)
self._pub_js = self.create_publisher(JointState, "/gripper/joint_states", 10)
self.create_subscription(Float32MultiArray, "/gripper/ctrl", self._on_ctrl, 10)
self.create_subscription(Float32, "/gripper/set_position", self._on_set_pos, 10)
self.create_service(Trigger, "initialize", self._srv_init)
self.create_service(Trigger, "open", self._srv_open)
self.create_service(Trigger, "close", self._srv_close)
self.create_service(Trigger, "/gripper/initialize", self._srv_init)
self.create_service(Trigger, "/gripper/open", self._srv_open)
self.create_service(Trigger, "/gripper/close", self._srv_close)
self.get_logger().info(
f"DH {self._model} SN={self._sn or '?'} "
@@ -154,6 +159,11 @@ class DhPgeaNode(TolerantNode):
def _on_ctrl(self, msg: Float32MultiArray) -> None:
initialize, position, force, speed = _as_ctrl_array(msg)
# 写在执行之前。串口堵住时,只有这一行能看出命令是何时进来的。
self.get_logger().info(
f"收到夹爪命令 initialize={initialize:.0f} pos={position:.0f} "
f"force={force:.0f} speed={speed:.0f}"
)
try:
if initialize > 0.5:
self._grip.initialize()
@@ -164,14 +174,14 @@ class DhPgeaNode(TolerantNode):
info = self._apply_move(int(position), f, s)
self.get_logger().info(f"ctrl {info}")
except DhPgeaError as exc:
self.get_logger().error(str(exc))
self._note_bus_error(exc)
def _on_set_pos(self, msg: Float32) -> None:
try:
info = self._apply_move(int(msg.data))
self.get_logger().info(f"set_position {info}")
except DhPgeaError as exc:
self.get_logger().error(str(exc))
self._note_bus_error(exc)
def _srv_init(self, _req, resp):
try:
@@ -179,29 +189,63 @@ class DhPgeaNode(TolerantNode):
resp.success = True
resp.message = self._grip.get_run_states().summary()
except DhPgeaError as exc:
self._note_bus_error(exc)
resp.success = False
resp.message = str(exc)
return resp
def _srv_open(self, _req, resp):
return self._srv_goto(_req, resp, self._open_pos, self._force, "未全开")
def _srv_close(self, _req, resp):
# 20–33% 力碰到阻力会提前停,固件仍报到位;全闭至少 80%。
return self._srv_goto(_req, resp, self._close_pos, max(int(self._force), 80), "未闭合到位")
def _srv_goto(self, _req, resp, position, force, fail_prefix):
from arm_application.dh_pgea import grip_motion_settled
try:
info = self._apply_move(self._open_pos)
resp.success = True
resp.message = info
info = self._apply_move(int(position), int(force), self._speed)
st = self._grip.wait_settle(int(position), timeout_s=8.0)
if not grip_motion_settled(st.grip_state, st.position, int(position), True):
self._apply_move(int(position), int(force), self._speed)
st = self._grip.wait_settle(int(position), timeout_s=6.0)
self._state = st
ok = grip_motion_settled(st.grip_state, st.position, int(position), True)
resp.success = bool(ok)
resp.message = st.summary() if ok else f"{fail_prefix} {st.summary()}({info})"
except DhPgeaError as exc:
self._note_bus_error(exc)
resp.success = False
resp.message = str(exc)
return resp
def _srv_close(self, _req, resp):
def _reopen_serial(self) -> None:
"""串口 EIO 后先关再重开。1 秒最多一次,避免把 USB 打爆。"""
now = time.monotonic()
if now - self._last_reopen < 1.0:
return
self._last_reopen = now
try:
info = self._apply_move(self._close_pos)
resp.success = True
resp.message = info
except DhPgeaError as exc:
resp.success = False
resp.message = str(exc)
return resp
self._grip.close()
except Exception:
pass
try:
self._grip.open()
self._grip.probe()
except (DhPgeaError, OSError) as exc:
self.get_logger().error(
f"夹爪串口重开失败:{exc}", throttle_duration_sec=10.0
)
return
self._online = True
self.get_logger().info("夹爪串口已重开,总线恢复应答")
def _note_bus_error(self, exc: Exception) -> None:
"""命令回调里的失败也走同一套:标离线,串口坏了就重开。"""
self._online = False
self.get_logger().error(str(exc))
if isinstance(exc, DhPgeaBusError):
self._reopen_serial()
def _typed_status(self, error=""):
state = self._state
@@ -223,6 +267,7 @@ class DhPgeaNode(TolerantNode):
msg.error_msg = error
return msg
def _srv_execute(self, request, response):
command = request.command
try:
@@ -255,28 +300,56 @@ class DhPgeaNode(TolerantNode):
response.status = self._typed_status("" if response.success else response.message)
return response
def _on_timer(self) -> None:
online = True
try:
st = self._grip.get_run_states(retries=2 if self._online else 1)
self._state = st
self._fail_streak = 0
if not self._online:
self._online = True
self.get_logger().info("夹爪总线已恢复")
except DhPgeaBusError as exc:
self._online = False
self._fail_streak = 0
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
self._reopen_serial()
online = False
except DhPgeaError as exc:
self._online = False
self._fail_streak += 1
self.get_logger().warn(str(exc), throttle_duration_sec=5.0)
self._pub_typed.publish(self._typed_status(str(exc)))
return
if self._fail_streak >= BUS_REOPEN_AFTER_FAILURES:
# 写有回显、读一直无应答时,重试同一个串口会话没用,必须重开。
self.get_logger().warn(
f"连续 {self._fail_streak} 拍读不到状态,先重开串口再继续重试"
)
self._fail_streak = 0
self._reopen_serial()
online = False
self._publish_state(online)
self._pub_typed.publish(self._typed_status())
def _publish_state(self, online: bool) -> None:
"""离线也要发一帧,前 5 个是上一次的值、最后一位是 0。
不发的话上层只看到"没有 /gripper/states",会把"总线读不到"当成
"夹爪没到位"。只有 5 个元素的老消费者照旧能用。
"""
self._pub_typed.publish(self._typed_status("" if online else "夹爪总线离线"))
st = self._state
n = STATES_ONLINE_INDEX + 1
arr = Float32MultiArray()
arr.layout = MultiArrayLayout(
dim=[MultiArrayDimension(label="dh_pgea", size=5, stride=5)],
dim=[MultiArrayDimension(label="dh_pgea", size=n, stride=n)],
data_offset=0,
)
arr.data = list(st.as_tuple())
arr.data = list(st.as_tuple()) + [1.0 if online else 0.0]
self._pub_state.publish(arr)
# 关节状态没有"离线"位:宁可不发,也不要把上一次的开口当成现在的。
if not online:
return
js = JointState()
js.header.stamp = self.get_clock().now().to_msg()
js.name = ["gripper_finger1_joint"]
@@ -0,0 +1,803 @@
"""双臂夹爪统一控制架构(DH-Robotics 官方 Modbus RTU,非 Flexiv RDK)。
设计目标:
- 左臂(Rizon4-063989):大寰 PGEA 平行夹爪,控制行程/夹持力/速度。
- 右臂(Rizon4-063991):大寰 RGI-100-30-O-S-M1 旋转夹爪,控制手指夹持与旋转角度/圈数/速度。
- 统一接口,便于后续扩展到 ROS2 节点和新项目脚本。
注意:DH 官方协议是“写位置/角度寄存器后立即运动”,没有 ChangingTek SDK 的 0x0108 触发寄存器。
"""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Any
import numpy as np
from arm_application.dh_official import DHModbusClient, DHModbusError
class GripperType(Enum):
PARALLEL = auto() # 平行夹爪
ROTARY = auto() # 旋转夹爪
VACUUM = auto() # 真空吸盘(预留)
class GripperState(Enum):
IDLE = auto()
MOVING = auto()
GRIPPING = auto()
RELEASING = auto()
ERROR = auto()
@dataclass
class GripperStatus:
"""夹爪状态反馈。"""
state: GripperState = GripperState.IDLE
position: float = 0.0 # 平行:mm;旋转:角度 deg
force: float = 0.0 # 最近一次命令力/力矩 %
velocity: float = 0.0 # 最近一次命令速度 %
is_moving: bool = False
is_gripped: bool = False # 是否夹持到物体
connected: bool = False
initialized: bool = False
finger_position_per_mille: float = 0.0
rotation_deg: float = 0.0
gripper_state: int = -1 # DH 0x0201:0 moving,1 reached,2 caught,3 dropped
rotation_state: int = -1 # RGI 0x020B:0 moving,1 reached,2 blocking,3 blocked
error_msg: str = ""
timestamp: float = field(default_factory=time.time)
@dataclass
class GripperCommand:
"""夹爪控制指令。"""
target_position: float | None = None # 平行:mm;旋转:deg
target_force: float | None = None # 目标力/力矩 %
target_velocity: float | None = None # 目标速度 %
wait_complete: bool = True # 是否等待完成
timeout: float = 10.0 # 超时时间
class BaseGripper(ABC):
"""夹爪基类,定义统一接口。"""
name: str = "base_gripper"
gripper_type: GripperType = GripperType.PARALLEL
def __init__(self, config: dict[str, Any] | None = None) -> None:
self.cfg = config or {}
self._connected = False
self._status = GripperStatus()
@abstractmethod
def connect(self) -> None: ...
@abstractmethod
def disconnect(self) -> None: ...
@abstractmethod
def execute(self, cmd: GripperCommand) -> GripperStatus: ...
@abstractmethod
def get_status(self) -> GripperStatus: ...
def is_connected(self) -> bool:
return self._connected
def open(self, **kwargs) -> GripperStatus:
raise NotImplementedError
def close(self, **kwargs) -> GripperStatus:
raise NotImplementedError
def stop(self) -> None:
raise NotImplementedError
# =============================================================================
# 大寰平行夹爪(PGE/PGEA 系列)
# =============================================================================
@dataclass
class ParallelGripperConfig:
"""大寰平行夹爪配置。"""
port: str = "/dev/ttyUSB0"
slave_id: int = 1
baudrate: int = 115200
timeout: float = 0.2
# PGEA-100-40 行程为 40mm;若实际是 PGEA-100-26,请把 stroke_max 改成 26.0。
stroke_min: float = 0.0
stroke_max: float = 40.0
# 默认参数(DH 百分比)
default_speed: int = 50
default_force: int = 50
force_min: int = 20
force_max: int = 100
# 控制行为
auto_initialize: bool = True
init_timeout: float = 10.0
position_tolerance_per_mille: int = 15
status_poll_interval: float = 0.05
class ParallelGripper(BaseGripper):
"""大寰 PGE/PGEA 平行夹爪。"""
name = "dh_pgea_parallel"
gripper_type = GripperType.PARALLEL
def __init__(self, config: ParallelGripperConfig | None = None) -> None:
super().__init__()
self.cfg = config or ParallelGripperConfig()
self._client: DHModbusClient | None = None
self._last_position_mm = float(self.cfg.stroke_min)
self._last_force = int(self.cfg.default_force)
self._last_speed = int(self.cfg.default_speed)
def connect(self) -> None:
if self._client is not None:
self.disconnect()
self._client = DHModbusClient(
self.cfg.port,
slave_id=self.cfg.slave_id,
baudrate=self.cfg.baudrate,
timeout=self.cfg.timeout,
)
self._connected = True
self._status.connected = True
self._status.state = GripperState.IDLE
self._status.error_msg = ""
if self.cfg.auto_initialize:
self.initialize()
def disconnect(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
self._connected = False
self._status.connected = False
self._status.state = GripperState.IDLE
def initialize(self, full: bool = False) -> None:
self._require_client()
assert self._client is not None
self._client.initialize(full=full, rotation=False, timeout=self.cfg.init_timeout)
self._status.initialized = True
def _require_client(self) -> None:
if not self._connected or self._client is None:
self._status.state = GripperState.ERROR
self._status.error_msg = "平行夹爪未连接"
raise DHModbusError(self._status.error_msg)
def _mm_to_per_mille(self, position_mm: float) -> int:
lo = float(self.cfg.stroke_min)
hi = float(self.cfg.stroke_max)
if hi <= lo:
hi = lo + 1.0
ratio = (float(position_mm) - lo) / (hi - lo)
return int(np.clip(round(ratio * 1000.0), 0, 1000))
def _per_mille_to_mm(self, position: float | int) -> float:
lo = float(self.cfg.stroke_min)
hi = float(self.cfg.stroke_max)
return lo + (float(position) / 1000.0) * (hi - lo)
def execute(self, cmd: GripperCommand) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
position_mm = self._last_position_mm if cmd.target_position is None else float(cmd.target_position)
position_mm = float(np.clip(position_mm, self.cfg.stroke_min, self.cfg.stroke_max))
speed = int(np.clip(cmd.target_velocity or self.cfg.default_speed, 1, 100))
force = int(np.clip(cmd.target_force or self.cfg.default_force, self.cfg.force_min, self.cfg.force_max))
self._client.set_speed_pct(speed)
self._client.set_force_pct(force)
target_per_mille = self._client.set_position_per_mille(self._mm_to_per_mille(position_mm))
self._last_position_mm = position_mm
self._last_speed = speed
self._last_force = force
self._status.state = GripperState.MOVING
self._status.error_msg = ""
if cmd.wait_complete:
self._wait_complete(target_per_mille, cmd.timeout)
return self.get_status()
def _wait_complete(self, target_per_mille: int, timeout: float) -> None:
assert self._client is not None
start = time.monotonic()
time.sleep(0.05)
while time.monotonic() - start < timeout:
state = self._client.get_gripper_state()
pos = self._client.get_position_per_mille()
self._status.gripper_state = state
self._status.finger_position_per_mille = float(pos)
self._status.position = self._per_mille_to_mm(pos)
self._status.is_moving = state == 0
self._status.is_gripped = state == 2
if state in (1, 2):
self._status.state = GripperState.GRIPPING if state == 2 else GripperState.IDLE
return
if state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "夹持后物体掉落"
return
if abs(pos - target_per_mille) <= self.cfg.position_tolerance_per_mille and state != 0:
self._status.state = GripperState.IDLE
return
time.sleep(self.cfg.status_poll_interval)
self._status.state = GripperState.ERROR
self._status.error_msg = f"平行夹爪运动超时(>{timeout:.1f}s)"
def get_status(self) -> GripperStatus:
if not self._connected or self._client is None:
self._status.connected = False
return self._status
try:
pos = self._client.get_position_per_mille()
state = self._client.get_gripper_state()
init_state = self._client.get_init_state()
self._status.connected = True
self._status.initialized = init_state == 1
self._status.finger_position_per_mille = float(pos)
self._status.position = self._per_mille_to_mm(pos)
self._status.velocity = float(self._last_speed)
self._status.force = float(self._last_force)
self._status.gripper_state = state
self._status.is_moving = state == 0
self._status.is_gripped = state == 2
if self._status.state != GripperState.ERROR:
self._status.state = GripperState.MOVING if state == 0 else (
GripperState.GRIPPING if state == 2 else GripperState.IDLE
)
self._status.timestamp = time.time()
except Exception as exc:
self._status.state = GripperState.ERROR
self._status.error_msg = str(exc)
self._status.connected = False
return self._status
def open(self, force: int | None = None, speed: int | None = None, **kwargs) -> GripperStatus:
# DH 千分比:0‰=闭合,1000‰=张开,对应行程 0mm -> stroke_max。
return self.execute(GripperCommand(
target_position=float(self.cfg.stroke_max),
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def close(self, force: int | None = None, speed: int | None = None, **kwargs) -> GripperStatus:
return self.execute(GripperCommand(
target_position=float(self.cfg.stroke_min),
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def move_to(self, position_mm: float, force: int | None = None, speed: int | None = None,
**kwargs) -> GripperStatus:
return self.execute(GripperCommand(
target_position=position_mm,
target_force=force or self.cfg.default_force,
target_velocity=speed or self.cfg.default_speed,
**kwargs,
))
def set_force(self, force_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_force = self._client.set_force_pct(force_pct)
def set_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_speed = self._client.set_speed_pct(speed_pct)
def stop(self) -> None:
if self._client is not None:
self._client.hold_current_position()
self._status.state = GripperState.IDLE
# =============================================================================
# 大寰旋转夹爪(RGI-100-30-O-S-M1)
# =============================================================================
@dataclass
class RotaryGripperConfig:
"""大寰 RGI 旋转夹爪配置。"""
port: str = "/dev/ttyUSB1"
slave_id: int = 1
baudrate: int = 115200
timeout: float = 0.2
# RGI 旋转角度寄存器是 signed 16bit;为避免 one's-complement 负零歧义,默认限制在 ±32767。
angle_min: float = -32767.0
angle_max: float = 32767.0
max_step_deg: float = 30000.0 # rotate_revolutions 分段旋转的最大单步角度
# DH 手册:手指位置 0~1000‰。0=闭合,1000=张开。RGI-100 行程约 70mm。
finger_open_per_mille: int = 1000
finger_close_per_mille: int = 0
# 默认参数(DH 百分比)
default_speed: int = 50 # 旋转速度 %
default_torque: int = 50 # 旋转力/力矩 %
default_finger_speed: int = 50 # 手指速度 %
default_finger_force: int = 50 # 手指夹持力 %
torque_min: int = 20
torque_max: int = 100
# 控制行为
auto_initialize: bool = True
init_timeout: float = 15.0 # RGI 初始化包含旋转找零,时间更长
angle_tolerance_deg: float = 1.0
status_poll_interval: float = 0.05
class RotaryGripper(BaseGripper):
"""大寰 RGI-100-30-O-S-M1 旋转夹爪。"""
name = "dh_rgi_rotary"
gripper_type = GripperType.ROTARY
def __init__(self, config: RotaryGripperConfig | None = None) -> None:
super().__init__()
self.cfg = config or RotaryGripperConfig()
self._client: DHModbusClient | None = None
self._last_angle = 0.0
self._last_torque = int(self.cfg.default_torque)
self._last_speed = int(self.cfg.default_speed)
self._last_finger_force = int(self.cfg.default_finger_force)
self._last_finger_speed = int(self.cfg.default_finger_speed)
self._last_finger_per_mille = int(self.cfg.finger_open_per_mille)
def connect(self) -> None:
if self._client is not None:
self.disconnect()
self._client = DHModbusClient(
self.cfg.port,
slave_id=self.cfg.slave_id,
baudrate=self.cfg.baudrate,
timeout=self.cfg.timeout,
)
self._connected = True
self._status.connected = True
self._status.state = GripperState.IDLE
self._status.error_msg = ""
if self.cfg.auto_initialize:
self.initialize()
def disconnect(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
self._connected = False
self._status.connected = False
self._status.state = GripperState.IDLE
def initialize(self, full: bool = False) -> None:
self._require_client()
assert self._client is not None
self._client.initialize(full=full, rotation=True, timeout=self.cfg.init_timeout)
self._status.initialized = True
def _require_client(self) -> None:
if not self._connected or self._client is None:
self._status.state = GripperState.ERROR
self._status.error_msg = "旋转夹爪未连接"
raise DHModbusError(self._status.error_msg)
def execute(self, cmd: GripperCommand) -> GripperStatus:
"""执行旋转指令;cmd.target_position 单位为度。"""
return self.rotate_to(
self._last_angle if cmd.target_position is None else float(cmd.target_position),
speed=cmd.target_velocity,
torque=cmd.target_force,
wait_complete=cmd.wait_complete,
timeout=cmd.timeout,
)
def _wait_complete(self, target_angle: float, timeout: float) -> None:
assert self._client is not None
start = time.monotonic()
start_angle = float(self._client.get_rotation_angle_deg())
expected = abs(float(target_angle) - start_angle) + 30.0
settled = 0
last_angle = start_angle
time.sleep(0.05)
while time.monotonic() - start < timeout:
rot_state = self._client.get_rotation_state()
angle = float(self._client.get_rotation_angle_deg())
self._status.rotation_state = rot_state
self._status.rotation_deg = angle
self._status.position = angle
self._status.is_moving = rot_state == 0
if abs(angle - start_angle) > expected:
self._client.hold_current_rotation()
self._status.state = GripperState.ERROR
self._status.error_msg = (
f"旋转超程保护:目标 {target_angle:.1f}°,从 {start_angle:.1f}° 跑到 {angle:.1f}°,已发停止"
)
return
if rot_state in (1, 2):
self._status.state = GripperState.IDLE if rot_state == 1 else GripperState.GRIPPING
if rot_state == 2:
self._status.error_msg = "旋转受阻(blocking)"
return
if rot_state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "旋转曾受阻且未到目标位"
return
if abs(angle - float(target_angle)) <= self.cfg.angle_tolerance_deg:
settled += 1
if settled >= 3:
self._status.state = GripperState.IDLE
self._status.is_moving = False
return
elif abs(angle - last_angle) <= self.cfg.angle_tolerance_deg:
settled += 1
if settled >= 8 and abs(angle - float(target_angle)) <= 15.0:
self._status.state = GripperState.IDLE
self._status.is_moving = False
return
else:
settled = 0
last_angle = angle
time.sleep(self.cfg.status_poll_interval)
self._client.hold_current_rotation()
self._status.state = GripperState.ERROR
self._status.error_msg = f"旋转运动超时(>{timeout:.1f}s),已发停止"
def get_status(self) -> GripperStatus:
if not self._connected or self._client is None:
self._status.connected = False
return self._status
try:
finger_pos = self._client.get_position_per_mille()
finger_state = self._client.get_gripper_state()
init_state = self._client.get_init_state()
rot_angle = self._client.get_rotation_angle_deg()
rot_state = self._client.get_rotation_state()
rot_init = self._client.get_rotation_init_state()
self._status.connected = True
self._status.initialized = init_state == 1 and rot_init == 1
self._status.finger_position_per_mille = float(finger_pos)
self._status.rotation_deg = float(rot_angle)
self._status.position = float(rot_angle)
self._status.velocity = float(self._last_speed)
self._status.force = float(self._last_torque)
self._status.gripper_state = finger_state
self._status.rotation_state = rot_state
self._status.is_moving = finger_state == 0 or rot_state == 0
self._status.is_gripped = finger_state == 2
if self._status.state != GripperState.ERROR:
if finger_state == 2 or rot_state == 2:
self._status.state = GripperState.GRIPPING
elif finger_state == 0 or rot_state == 0:
self._status.state = GripperState.MOVING
else:
self._status.state = GripperState.IDLE
self._status.timestamp = time.time()
except Exception as exc:
self._status.state = GripperState.ERROR
self._status.error_msg = str(exc)
self._status.connected = False
return self._status
# -----------------------------
# 手指夹持
# -----------------------------
def move_fingers(self, position_per_mille: int, force: int | None = None,
speed: int | None = None, wait_complete: bool = True,
timeout: float = 10.0) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
target = int(np.clip(position_per_mille, 0, 1000))
f = int(np.clip(force or self.cfg.default_finger_force, 20, 100))
s = int(np.clip(speed or self.cfg.default_finger_speed, 1, 100))
self._client.set_speed_pct(s)
self._client.set_force_pct(f)
self._client.set_position_per_mille(target)
self._last_finger_per_mille = target
self._last_finger_force = f
self._last_finger_speed = s
if wait_complete:
start = time.monotonic()
time.sleep(0.05)
while time.monotonic() - start < timeout:
state = self._client.get_gripper_state()
pos = self._client.get_position_per_mille()
self._status.gripper_state = state
self._status.finger_position_per_mille = float(pos)
if state in (1, 2):
break
if state == 3:
self._status.state = GripperState.ERROR
self._status.error_msg = "夹持后物体掉落"
break
time.sleep(self.cfg.status_poll_interval)
return self.get_status()
def open_fingers(self, **kwargs) -> GripperStatus:
return self.move_fingers(self.cfg.finger_open_per_mille, **kwargs)
def close_fingers(self, **kwargs) -> GripperStatus:
return self.move_fingers(self.cfg.finger_close_per_mille, **kwargs)
# -----------------------------
# 旋转
# -----------------------------
def rotate_to(self, angle_deg: float, speed: int | None = None, torque: int | None = None,
wait_complete: bool = True, timeout: float = 10.0, **kwargs) -> GripperStatus:
try:
self._require_client()
except DHModbusError:
return self._status
assert self._client is not None
angle = float(np.clip(angle_deg, self.cfg.angle_min, self.cfg.angle_max))
s = int(np.clip(speed or self.cfg.default_speed, 1, 100))
t = int(np.clip(torque or self.cfg.default_torque, self.cfg.torque_min, self.cfg.torque_max))
self._client.set_rotation_speed_pct(s)
self._client.set_rotation_force_pct(t)
target = self._client.set_rotation_angle_deg(angle)
self._last_angle = float(target)
self._last_speed = s
self._last_torque = t
self._status.state = GripperState.MOVING
self._status.error_msg = ""
if wait_complete:
self._wait_complete(float(target), timeout)
return self.get_status()
def rotate_by(self, delta_deg: float, **kwargs) -> GripperStatus:
current = self.get_status().rotation_deg
return self.rotate_to(current + float(delta_deg), **kwargs)
def rotate_revolutions(self, revolutions: float, **kwargs) -> GripperStatus:
"""旋转指定圈数;超过单步角度范围时自动分段,并按实际反馈角度结算。"""
remaining = float(revolutions) * 360.0
status = self.get_status()
max_step = abs(float(self.cfg.max_step_deg)) or 30000.0
while abs(remaining) > 1e-6:
before = float(self.get_status().rotation_deg)
step = float(np.clip(remaining, -max_step, max_step))
status = self.rotate_to(before + step, **kwargs)
if status.state == GripperState.ERROR:
return status
after = float(status.rotation_deg)
achieved = after - before
if abs(achieved) < max(1.0, abs(step) * 0.2):
status.state = GripperState.ERROR
status.error_msg = (
"旋转未达到预期步进,可能超出角度寄存器范围或反馈异常:"
f"step={step:.3f}, before={before:.3f}, after={after:.3f}"
)
return status
remaining -= achieved
return status
def home_rotation(self, **kwargs) -> GripperStatus:
return self.rotate_to(0.0, **kwargs)
def set_rotation_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_speed = self._client.set_rotation_speed_pct(speed_pct)
def set_rotation_torque(self, torque_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_torque = self._client.set_rotation_force_pct(torque_pct)
def set_finger_speed(self, speed_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_finger_speed = self._client.set_speed_pct(speed_pct)
def set_finger_force(self, force_pct: int) -> None:
self._require_client()
assert self._client is not None
self._last_finger_force = self._client.set_force_pct(force_pct)
def open(self, **kwargs) -> GripperStatus:
"""对于 RGI,open 定义为张开手指;旋转请用 rotate_to/rotate_revolutions。"""
return self.open_fingers(**kwargs)
def close(self, **kwargs) -> GripperStatus:
"""对于 RGI,close 定义为闭合手指;旋转请用 rotate_to/rotate_revolutions。"""
return self.close_fingers(**kwargs)
def stop(self) -> None:
if self._client is not None:
self._client.hold_current_all()
self._status.state = GripperState.IDLE
# =============================================================================
# 双臂夹爪管理器
# =============================================================================
@dataclass
class DualArmGripperConfig:
"""双臂夹爪配置。
这里**没有** ``left_arm_sn`` / ``right_arm_sn``:它们曾经存在但从来没有被读过
(只写不读的死字段,R5.5),而且默认值是本机的 SN —— 换台臂就会误导读者。
哪个侧别是哪台臂,由 ``load_from_site()`` 从 site.yaml 的 ``robots[].arm`` 决定。
"""
left_gripper: ParallelGripperConfig = field(default_factory=ParallelGripperConfig)
right_gripper: RotaryGripperConfig = field(default_factory=RotaryGripperConfig)
class DualArmGripperManager:
"""双臂夹爪管理器:统一控制左右臂夹爪。"""
def __init__(self, config: DualArmGripperConfig | None = None) -> None:
self.cfg = config or DualArmGripperConfig()
self._left: ParallelGripper | None = None
self._right: RotaryGripper | None = None
@property
def left(self) -> ParallelGripper:
if self._left is None:
self._left = ParallelGripper(self.cfg.left_gripper)
return self._left
@property
def right(self) -> RotaryGripper:
if self._right is None:
self._right = RotaryGripper(self.cfg.right_gripper)
return self._right
def connect_all(self) -> None:
self.left.connect()
self.right.connect()
def disconnect_all(self) -> None:
if self._left:
self._left.disconnect()
if self._right:
self._right.disconnect()
def initialize_all(self, full: bool = False) -> None:
self.left.initialize(full=full)
self.right.initialize(full=full)
def get_all_status(self) -> dict[str, GripperStatus]:
return {
"left": self.left.get_status(),
"right": self.right.get_status(),
}
def emergency_stop(self) -> None:
self.left.stop()
self.right.stop()
def load_dual_arm_gripper_config(path: str | Path) -> DualArmGripperConfig:
"""从 configs/dual_arm_robot.yaml 加载双臂夹爪配置。"""
import yaml
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
left = data.get("left_arm", {}) or {}
right = data.get("right_arm", {}) or {}
grip = data.get("gripper", {}) or {}
parallel = grip.get("parallel", {}) or {}
rotary = grip.get("rotary", {}) or {}
left_cfg = ParallelGripperConfig(
port=left.get("gripper_port", "/dev/ttyUSB0"),
slave_id=int(left.get("gripper_slave_id", 1)),
baudrate=int(left.get("gripper_baudrate", 115200)),
stroke_min=float(parallel.get("stroke_min", 0.0)),
stroke_max=float(parallel.get("stroke_max", 40.0)),
default_speed=int(parallel.get("default_speed", 50)),
default_force=int(parallel.get("default_force", 50)),
force_min=int(parallel.get("force_min", 20)),
force_max=int(parallel.get("force_max", 100)),
auto_initialize=bool(parallel.get("auto_initialize", True)),
)
right_cfg = RotaryGripperConfig(
port=right.get("gripper_port", "/dev/ttyUSB1"),
slave_id=int(right.get("gripper_slave_id", 1)),
baudrate=int(right.get("gripper_baudrate", 115200)),
angle_min=float(rotary.get("angle_min", -32767.0)),
angle_max=float(rotary.get("angle_max", 32767.0)),
max_step_deg=float(rotary.get("max_step_deg", 30000.0)),
finger_open_per_mille=int(rotary.get("finger_open_per_mille", 1000)),
finger_close_per_mille=int(rotary.get("finger_close_per_mille", 0)),
default_speed=int(rotary.get("default_speed", 50)),
default_torque=int(rotary.get("default_torque", 50)),
default_finger_speed=int(rotary.get("default_finger_speed", 50)),
default_finger_force=int(rotary.get("default_finger_force", 50)),
torque_min=int(rotary.get("torque_min", 20)),
torque_max=int(rotary.get("torque_max", 100)),
auto_initialize=bool(rotary.get("auto_initialize", True)),
)
return DualArmGripperConfig(
left_gripper=left_cfg,
right_gripper=right_cfg,
)
def load_from_site(data: dict) -> DualArmGripperConfig:
"""从 ``~/ros2/config/site.yaml`` 构建 ``DualArmGripperConfig``。
左右靠 ``robots[].arm`` 认,**不靠 SN 里的数字**:``"063989 就是左臂"``
是站点数据,写进代码后换个站点就会把左右夹爪搞反
(症状是"右臂的旋转爪去开左臂的平行爪",串口对不上才报错)。
"""
robots = list((data.get("robot") or {}).get("robots") or [])
left = next((r for r in robots if str(r.get("arm") or "").lower() == "left"), {})
right = next((r for r in robots if str(r.get("arm") or "").lower() == "right"), {})
g = data.get("gripper") or {}
parallel = g.get("parallel") or {}
rotary = g.get("rotary") or {}
lg = left.get("gripper") or {}
rg = right.get("gripper") or {}
stroke = float(lg.get("stroke_mm") or parallel.get("stroke_max") or g.get("stroke_mm") or 20.0)
left_cfg = ParallelGripperConfig(
port=str(lg.get("port") or "/dev/ttyUSB0"),
slave_id=int(lg.get("slave_id") or g.get("slave_id") or 1),
baudrate=int(lg.get("baud") or g.get("baud") or 115200),
stroke_min=float(parallel.get("stroke_min") or 0.0),
stroke_max=stroke,
default_speed=int(parallel.get("default_speed") or g.get("speed_pct") or 50),
default_force=int(parallel.get("default_force") or g.get("force_pct") or 50),
force_min=int(parallel.get("force_min") or 20),
force_max=int(parallel.get("force_max") or 100),
auto_initialize=bool(lg.get("auto_init", parallel.get("auto_initialize", True))),
init_timeout=float(parallel.get("init_timeout") or 10.0),
)
right_cfg = RotaryGripperConfig(
port=str(rg.get("port") or "/dev/ttyUSB1"),
slave_id=int(rg.get("slave_id") or 1),
baudrate=int(rg.get("baud") or g.get("baud") or 115200),
angle_min=float(rotary.get("angle_min") or -32767.0),
angle_max=float(rotary.get("angle_max") or 32767.0),
max_step_deg=float(rotary.get("max_step_deg") or 30000.0),
finger_open_per_mille=int(rotary.get("finger_open_per_mille") or 1000),
finger_close_per_mille=int(rotary.get("finger_close_per_mille") or 0),
default_speed=int(rotary.get("default_speed") or 50),
default_torque=int(rotary.get("default_torque") or 50),
default_finger_speed=int(rotary.get("default_finger_speed") or 50),
default_finger_force=int(rotary.get("default_finger_force") or 50),
torque_min=int(rotary.get("torque_min") or 20),
torque_max=int(rotary.get("torque_max") or 100),
auto_initialize=bool(rg.get("auto_init", rotary.get("auto_initialize", False))),
init_timeout=float(rotary.get("init_timeout") or 15.0),
)
return DualArmGripperConfig(
left_gripper=left_cfg,
right_gripper=right_cfg,
)
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""DH PGEA(左臂)与 RGI(右臂)的命令行工具。从 site.yaml 读取串口。"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
import yaml
from arm_application.dual_gripper import DualArmGripperManager, load_from_site
def _site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return Path.home() / "ros2" / "config" / "site.yaml"
def _print_status(label: str, status) -> None:
print(
f" {label}: state={status.state.name} "
f"pos={status.position:.2f} finger={status.finger_position_per_mille:.0f}‰ "
f"rot={status.rotation_deg:.2f}° moving={status.is_moving} "
f"gripped={status.is_gripped} init={status.initialized} "
f"err={status.error_msg or '-'}"
)
def _load_site_exporter():
"""复用 ``scripts/lib/export_site_env.py`` —— 它才是 --arm 别名的权威定义。
这里**不自己再抄一份** ``89 -> SN`` 的映射:抄一份就多一处会漂移的地方(M2)。
"""
root = Path(__file__).resolve().parents[3]
lib = str(root / "scripts" / "lib")
if lib not in sys.path:
sys.path.insert(0, lib)
import export_site_env # type: ignore
return export_site_env
def _normalize_arm(raw: str, data: dict) -> str:
"""把 --arm 的写法归一成 ``left`` / ``right`` / ``both``。
别名(``89`` 这类)由 ``export_site_env`` 从 site.yaml 的 ``robots[]`` 现算
(臂号 = 该臂 SN 末两位)。代码里**不写死** SN,否则换台机器人后会指错臂,
而症状是"给右臂发命令,左臂动了"。
"""
t = (raw or "").strip()
if t.lower() in ("both", "dual", "all"):
return "both"
site_env = _load_site_exporter()
sn, matched = site_env.resolve_robot_sn(data, t)
if not matched:
raise SystemExit(
f"未知的 --arm {raw!r}:不在 site.yaml 的 robot.robots[] 里。\n"
f"可用:\n{site_env.describe_arms(data)}\n"
f"也可以直接写 left|right|both。"
)
for row in (data.get("robot") or {}).get("robots") or []:
if str(row.get("sn") or "").strip() != sn:
continue
arm = str(row.get("arm") or "").strip().lower()
if arm in ("left", "right"):
return arm
raise SystemExit(
f"--arm {raw!r} 选中了 {sn},但 site.yaml 里这台臂没写 arm: left|right,"
f"无法判断操作哪个夹爪。请补上这个键。"
)
raise SystemExit(
f"--arm {raw!r} 解析出 {sn},但它不在 site.yaml 的 robot.robots[] 里,"
f"无法判断是哪个夹爪。"
)
def test_parallel(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
gripper = manager.left
print(f"=== PGEA 平行夹爪({gripper.cfg.port})===")
gripper.connect()
_print_status("已连接", gripper.get_status())
if args.test in ("init", "probe", "all"):
if args.test != "probe":
gripper.initialize(full=args.init_full)
_print_status("初始化", gripper.get_status())
if args.test in ("open", "all"):
_print_status("张开", gripper.open(force=args.force, speed=args.speed))
if args.test in ("close", "all"):
_print_status("闭合", gripper.close(force=args.force, speed=args.speed))
if args.test in ("move", "pos") and args.pos is not None:
_print_status("移动", gripper.move_to(args.pos, force=args.force, speed=args.speed))
if args.test in ("status", "probe"):
_print_status("状态", gripper.get_status())
def test_rotary(manager: DualArmGripperManager, args: argparse.Namespace) -> None:
gripper = manager.right
print(f"=== RGI 旋转夹爪({gripper.cfg.port})===")
gripper.connect()
_print_status("已连接", gripper.get_status())
if args.test in ("init", "all"):
gripper.initialize(full=args.init_full)
_print_status("初始化", gripper.get_status())
if args.test in ("open", "all"):
_print_status("张开", gripper.open_fingers(force=args.force, speed=args.speed))
if args.test in ("close", "all"):
_print_status("闭合", gripper.close_fingers(force=args.force, speed=args.speed))
if args.test in ("move", "pos") and args.pos is not None:
_print_status("手指", gripper.move_fingers(int(args.pos), force=args.force, speed=args.speed))
if args.test in ("rotate", "rotate_to", "all"):
if args.revolutions is not None:
_print_status(
"圈数",
gripper.rotate_revolutions(args.revolutions, speed=args.speed, torque=args.force),
)
else:
angle = args.angle if args.angle is not None else 90.0
_print_status("旋转", gripper.rotate_to(angle, speed=args.speed, torque=args.force))
if args.test == "rotate_by":
delta = args.angle if args.angle is not None else 45.0
_print_status("相对旋转", gripper.rotate_by(delta, speed=args.speed, torque=args.force))
if args.test in ("status", "probe"):
_print_status("状态", gripper.get_status())
def main() -> int:
parser = argparse.ArgumentParser(description="DH PGEA / RGI 命令行工具(串口来自 site.yaml)")
parser.add_argument(
"--arm",
# 默认取当前臂(parse_arm_args.sh / load_site_config.sh 已经把它放进环境)。
# **不**兜底成某个具体臂号:那等于"没选臂时也能跑,跑的是哪台看运气"。
default=(
os.environ.get("SITE_ROBOT_ARM")
or os.environ.get("ROBOT_SN")
or os.environ.get("SITE_ROBOT_SN")
or ""
),
help="left|right|both,或该臂的臂号(= SN 末两位,由 site.yaml 决定)",
)
parser.add_argument(
"--test",
default="status",
help="probe|init|open|close|move|pos|rotate|rotate_by|status",
)
parser.add_argument("--pos", type=float, default=None, help="PGEA 为 mm;RGI 为手指 0-1000")
parser.add_argument("--angle", type=float, default=None)
parser.add_argument("--revolutions", type=float, default=None)
parser.add_argument("--speed", type=int, default=25)
parser.add_argument("--force", type=int, default=33)
parser.add_argument("--init-full", action="store_true")
parser.add_argument("--site", default="")
args = parser.parse_args()
site = Path(args.site).expanduser() if args.site else _site_path()
if not site.is_file():
print(f"错误:site.yaml 缺失:{site}", file=sys.stderr)
return 1
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
manager = DualArmGripperManager(load_from_site(data))
arm = _normalize_arm(str(args.arm), data)
print(f"site={site} arm={arm} test={args.test}")
try:
if arm == "left":
test_parallel(manager, args)
elif arm == "right":
test_rotary(manager, args)
else:
test_parallel(manager, args)
test_rotary(manager, args)
except KeyboardInterrupt:
print("收到中断 —— 停止")
manager.emergency_stop()
return 130
except Exception as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
finally:
manager.disconnect_all()
time.sleep(0.05)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,320 @@
"""ROS 2 双臂 DH 夹爪节点(左臂 PGEA + 右臂 RGI)。使用 arm_msgs,而非 Flexiv RDK。"""
from __future__ import annotations
import os
import threading
from pathlib import Path
import rclpy
import yaml
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from arm_application.dual_gripper import (
DualArmGripperManager,
GripperState,
load_dual_arm_gripper_config,
load_from_site,
)
from arm_msgs.msg import GripperCommand as GripperCommandMsg
from arm_msgs.msg import GripperStatus as GripperStatusMsg
from arm_msgs.srv import GripperExecute
from arm_application.ros_node import TolerantNode
def _site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return Path.home() / "ros2" / "config" / "site.yaml"
def _as_bool(val, default: bool = True) -> bool:
if val is None:
return default
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
return bool(val)
class DualGripperNode(TolerantNode):
def __init__(self) -> None:
super().__init__("dual_gripper_driver")
self.declare_parameter("config_file", "")
self.declare_parameter("auto_connect", True)
self.declare_parameter("allow_offline", True)
self.declare_parameter("publish_hz", 10.0)
self.declare_parameter("sides", "left,right")
self._cb_group = ReentrantCallbackGroup()
self._locks = {"left": threading.Lock(), "right": threading.Lock()}
sides_raw = str(self.get_parameter("sides").value or "left,right")
self._sides = [s.strip().lower() for s in sides_raw.split(",") if s.strip()]
if not self._sides:
self._sides = ["left", "right"]
cfg_path = self.get_parameter("config_file").get_parameter_value().string_value.strip()
if cfg_path:
self._manager = DualArmGripperManager(load_dual_arm_gripper_config(cfg_path))
else:
site = _site_path()
data = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
self._manager = DualArmGripperManager(load_from_site(data))
self.get_logger().info(f"夹爪配置来自 site.yaml:{site}")
self._connect_errors: dict[str, str] = {}
if _as_bool(self.get_parameter("auto_connect").value, True):
self._connect_all()
self._left_pub = self.create_publisher(GripperStatusMsg, "~/left/status", 10)
self._right_pub = self.create_publisher(GripperStatusMsg, "~/right/status", 10)
if "left" in self._sides:
self.create_subscription(
GripperCommandMsg,
"~/left/command",
lambda msg: self._on_command("left", msg),
10,
callback_group=self._cb_group,
)
self.create_service(
GripperExecute,
"~/left/execute",
lambda req, resp: self._on_execute("left", req, resp),
callback_group=self._cb_group,
)
if "right" in self._sides:
self.create_subscription(
GripperCommandMsg,
"~/right/command",
lambda msg: self._on_command("right", msg),
10,
callback_group=self._cb_group,
)
self.create_service(
GripperExecute,
"~/right/execute",
lambda req, resp: self._on_execute("right", req, resp),
callback_group=self._cb_group,
)
period = 1.0 / max(1e-3, float(self.get_parameter("publish_hz").value))
self.create_timer(period, self._publish_status, callback_group=self._cb_group)
self.get_logger().info(
f"dual_gripper_driver ready sides={self._sides} "
"~/left|right/command status execute"
)
def _connect_all(self) -> None:
mapping = (("left", self._manager.left), ("right", self._manager.right))
for side, gripper in mapping:
if side not in self._sides:
continue
try:
gripper.connect()
self.get_logger().info(f"{side} 夹爪已连接:{gripper.cfg.port}")
except Exception as exc:
self._connect_errors[side] = str(exc)
self.get_logger().error(f"{side} 夹爪连接失败:{exc}")
if self._connect_errors and not _as_bool(self.get_parameter("allow_offline").value, True):
raise RuntimeError(f"夹爪连接失败:{self._connect_errors}")
def _gripper(self, side: str):
return self._manager.left if side == "left" else self._manager.right
def _execute(self, side: str, msg: GripperCommandMsg):
gripper = self._gripper(side)
command = (msg.command or "").strip().lower()
timeout = float(msg.timeout or 10.0)
wait = bool(msg.wait_complete)
if side in self._connect_errors and not gripper.is_connected():
status = gripper.get_status()
status.error_msg = self._connect_errors[side]
return status
if command == "connect":
gripper.connect()
self._connect_errors.pop(side, None)
return gripper.get_status()
if command == "init":
gripper.initialize(full=False)
return gripper.get_status()
if command == "init_full":
gripper.initialize(full=True)
return gripper.get_status()
if command == "stop":
gripper.stop()
return gripper.get_status()
if side == "left":
if command == "open":
return gripper.open(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "close":
return gripper.close(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "move":
return gripper.move_to(
float(msg.position),
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "set_force":
gripper.set_force(int(msg.force))
return gripper.get_status()
if command == "set_speed":
gripper.set_speed(int(msg.speed))
return gripper.get_status()
else:
if command == "open":
return gripper.open_fingers(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "close":
return gripper.close_fingers(
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command == "move":
return gripper.move_fingers(
int(msg.position),
force=int(msg.force) if msg.force else None,
speed=int(msg.speed) if msg.speed else None,
wait_complete=wait,
timeout=timeout,
)
if command in ("rotate", "rotate_to"):
return gripper.rotate_to(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "rotate_by":
return gripper.rotate_by(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "rotate_revolutions":
return gripper.rotate_revolutions(
float(msg.position),
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "home_rotation":
return gripper.home_rotation(
speed=int(msg.speed) if msg.speed else None,
torque=int(msg.force) if msg.force else None,
wait_complete=wait,
timeout=timeout,
)
if command == "set_force":
gripper.set_rotation_torque(int(msg.force))
return gripper.get_status()
if command == "set_speed":
gripper.set_rotation_speed(int(msg.speed))
return gripper.get_status()
if command == "set_finger_force":
gripper.set_finger_force(int(msg.force))
return gripper.get_status()
if command == "set_finger_speed":
gripper.set_finger_speed(int(msg.speed))
return gripper.get_status()
status = gripper.get_status()
status.state = GripperState.ERROR
status.error_msg = f"不支持的命令 {command!r}(side={side})"
return status
def _on_command(self, side: str, msg: GripperCommandMsg) -> None:
with self._locks[side]:
status = self._execute(side, msg)
if status.state == GripperState.ERROR:
self.get_logger().warn(f"{side} 命令 {msg.command!r} 失败:{status.error_msg}")
def _on_execute(self, side: str, request: GripperExecute.Request, response: GripperExecute.Response):
with self._locks[side]:
status = self._execute(side, request.command)
response.success = status.state != GripperState.ERROR and status.connected
response.message = status.error_msg or "OK"
response.status = self._to_status_msg(side, status)
return response
def _to_status_msg(self, side: str, status) -> GripperStatusMsg:
gripper = self._gripper(side)
msg = GripperStatusMsg()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = f"{side}_gripper"
msg.name = gripper.name
msg.gripper_type = gripper.gripper_type.name.lower()
msg.state = status.state.name
msg.connected = bool(status.connected)
msg.initialized = bool(status.initialized)
msg.is_moving = bool(status.is_moving)
msg.is_gripped = bool(status.is_gripped)
msg.position = float(status.position)
msg.finger_position_per_mille = float(status.finger_position_per_mille)
msg.rotation_deg = float(status.rotation_deg)
msg.gripper_state = int(status.gripper_state)
msg.rotation_state = int(status.rotation_state)
msg.velocity = float(status.velocity)
msg.force = float(status.force)
msg.error_msg = status.error_msg or ""
return msg
def _publish_status(self) -> None:
if "left" in self._sides:
left_status = self._manager.left.get_status()
if "left" in self._connect_errors and not left_status.connected:
left_status.error_msg = self._connect_errors["left"]
self._left_pub.publish(self._to_status_msg("left", left_status))
if "right" in self._sides:
right_status = self._manager.right.get_status()
if "right" in self._connect_errors and not right_status.connected:
right_status.error_msg = self._connect_errors["right"]
self._right_pub.publish(self._to_status_msg("right", right_status))
def destroy_node(self) -> bool:
try:
self._manager.disconnect_all()
finally:
return super().destroy_node()
def main() -> None:
rclpy.init()
node = DualGripperNode()
executor = MultiThreadedExecutor(num_threads=4)
try:
rclpy.spin(node, executor=executor)
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,615 @@
"""通止规装配的配置:从 ``site.yaml`` 的 ``gauge_assembly:`` 段解析 + 校验。
为什么单独一个模块
------------------
这一段的键**全是会动的量**(力阈值、角度、悬高、超时),也是现场最常改的地方。
散在节点里读的话会有三个已知坑(都被这个仓库踩过):
1. **名字与单位不一致** —— ``hover`` 填了毫米、代码按米用(规则 M3);
2. **键没接线** —— YAML 里写着 ``screw_step_deg``,代码读的是 ``screw_steps``,
于是改了没效果,而报错也没有(``config_guard`` 模块的头注释就是这件事);
3. **拼错键名** —— YAML 静默接受,行为退回默认值,现场"改了参数没反应"。
所以:所有键在一张表里声明(含单位),解析时**逐条校验**,多出来的键直接
报错并给出最相近的候选名,缺的键按 required/default 明确处理。
``describe()`` 把**派生**结果(比如"逆时针最多能转 147°")打出来,让现场
看到的是"实际生效的值",而不是"我填的值"。
"""
from __future__ import annotations
import difflib
from dataclasses import dataclass, fields
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
from arm_application.gauge_fsm import descend_tol_m
#: S05 稳定判据可用的特征来源。``auto`` 按 ``perception.source`` 自动选。
#:
#: 为什么需要有"motion"这一路:判据原本是**工件检测框中心**的抖动(像素),
#: 那要求检测器真的出框。仿真里合成图过不了真机训练出来的模型(实测最高分
#: 0.017),于是 S05 会永远"不稳定"地空转 —— 而真正该等的是"相机不再动"。
#: 用法兰位移(mm)当特征既直接又不依赖模型,且判据本身(到均值的最远距离)不变。
SNAP_STABLE_FEATURES = ("auto", "bbox", "motion")
def snap_feature_of(want: str, perception_source: str) -> str:
"""把 ``snap_stable_feature``(可能是 ``auto``)+ 感知来源 → 实际用哪个特征。
抽成纯函数是为了能脱离 ROS 节点测:这个选择错了,S05 会在真机上"永远不稳定"
或者在仿真里空转到超时,而两种表现都像是"机械臂在晃",很难往配置上想。
"""
want = str(want or "auto")
if want != "auto":
return want
return "bbox" if perception_source == "yolo" else "motion"
#: 合法的单位后缀。键名必须以 ``_<单位>`` 结尾(``au`` = Orisys 任意单位增量)。
_UNIT_SUFFIX: Dict[str, str] = {
"s": "_s",
"deg": "_deg",
"mm": "_mm",
"m": "_m",
"px": "_px",
"pct": "_pct",
"permille": "_permille",
"au": "_delta",
# 腕部 FT 的**真牛顿**。与 au 分开一个后缀是刻意的:两个口径混用不会报错,
# 只会"判据不触发"或"一碰就炸",看起来像算法问题(见 site.yaml 的注释)。
"N": "_n",
}
#: 显式无量纲的键(布尔、计数、比例、枚举)。**必须在这里列出来** ——
#: 没有单位又不登记的键,会让"单位自检"变成一句空话(规则 M1)。
_UNITLESS = frozenset(
{
"extreme_direction",
"grip_check",
"coarse_enabled",
"coarse_camera",
"coarse_conf",
"snap_min_frames",
"snap_stable_feature",
"screw_max_cycles",
"unscrew_extra_cycles",
"holes_total",
"dry_run",
}
)
#: 每个键的**类型**(用于类型校验,不做隐式转换:``"20"`` 不当 20 用)。
_TYPES: Dict[str, type] = {
"boot_timeout_s": float,
"extreme_angle_deg": float,
"extreme_speed_deg_s": float,
"extreme_direction": int,
"extreme_margin_deg": float,
"grip_check": bool,
"grip_hold_s": float,
"coarse_enabled": bool,
"coarse_camera": str,
"coarse_hover_m": float,
"coarse_conf": float,
"coarse_timeout_s": float,
"snap_stable_s": float,
"snap_tol_px": float,
"snap_tol_mm": float,
"snap_min_frames": int,
"snap_stable_feature": str,
"align_tol_mm": float,
"contact_speed_m_s": float,
"contact_step_mm": float,
"contact_max_depth_mm": float,
"tool_tip_below_flange_m": float,
"workpiece_surface_z_m": float,
"contact_tangential_delta": float,
"contact_axial_delta_n": float,
"contact_relief_mm": float,
"contact_hold_n": float,
"contact_safe_delta": float,
"screw_step_deg": float,
"screw_speed_deg_s": float,
"screw_back_speed_deg_s": float,
"screw_max_cycles": int,
"screw_normal_delta": float,
"screw_shear_delta": float,
"screw_grip_force_pct": float,
"screw_grip_speed_pct": float,
"screw_release_wait_s": float,
"unscrew_extra_cycles": int,
"holes_total": int,
"dry_run": bool,
"reload_timeout_s": float,
# 首次咬合之后的空回:松爪开度 + 微抬量(见 pipeline_node._run_a7_seat)
"unwind_open_permille": int,
"unwind_lift_mm": float,
}
#: 每个键的单位。``""`` 表示无量纲(须同时登记在 :data:`_UNITLESS`)。
_UNITS: Dict[str, str] = {
"boot_timeout_s": "s",
"extreme_angle_deg": "deg",
"extreme_speed_deg_s": "deg/s",
"extreme_direction": "",
"extreme_margin_deg": "deg",
"grip_check": "",
"grip_hold_s": "s",
"coarse_enabled": "",
"coarse_camera": "",
"coarse_hover_m": "m",
"coarse_conf": "",
"coarse_timeout_s": "s",
"snap_stable_s": "s",
"snap_tol_px": "px",
"snap_tol_mm": "mm",
"snap_min_frames": "",
"snap_stable_feature": "",
"align_tol_mm": "mm",
"contact_speed_m_s": "m/s",
"contact_step_mm": "mm",
"contact_max_depth_mm": "mm",
# 这两个是**长度**不是"毫米量":用 m 口径,与 hover_z 同一把尺
"tool_tip_below_flange_m": "m",
"workpiece_surface_z_m": "m",
"contact_tangential_delta": "au",
# 腕部 FT 是**真牛顿**(与触觉的 au 不是一个口径,所以键名也不带 taut 前缀)
"contact_axial_delta_n": "N",
# 卸载回退是**长度**(与 step_mm 同一把尺)
"contact_relief_mm": "mm",
# 残余轴向力是**牛顿**(腕部 FT 口径,不是触觉的 au)
"contact_hold_n": "N",
"contact_safe_delta": "au",
"screw_step_deg": "deg",
"screw_speed_deg_s": "deg/s",
"screw_back_speed_deg_s": "deg/s",
"screw_max_cycles": "",
"screw_normal_delta": "au",
"screw_shear_delta": "au",
"screw_grip_force_pct": "pct",
"screw_grip_speed_pct": "pct",
"screw_release_wait_s": "s",
"unscrew_extra_cycles": "",
"holes_total": "",
"dry_run": "",
"reload_timeout_s": "s",
"unwind_open_permille": "permille",
"unwind_lift_mm": "mm",
}
#: 键名后缀与单位必须一致(防"填毫米当米用")。``contact_speed_m_s`` 这类
#: 复合单位只检查主单位段。
_SUFFIX_OVERRIDE: Dict[str, str] = {
# 复合单位:后缀取"最后一个单位段",键名里写全
"contact_speed_m_s": "_m_s",
"extreme_speed_deg_s": "_deg_s",
"screw_speed_deg_s": "_deg_s",
"screw_back_speed_deg_s": "_deg_s",
}
class GaugeConfigError(ValueError):
"""``gauge_assembly:`` 段配置错误。消息里必须给出**改哪里**。"""
# =============================================================================
# 默认值
# =============================================================================
#: 缺省值。**只有真的有现场依据的才写默认值**:力阈值这类必须现场标定的,
#: 默认值只是"能跑起来的起点",必须在 ``describe()`` 里标出来让现场知道要改。
DEFAULTS: Dict[str, Any] = {
"boot_timeout_s": 30.0,
# S01:逆时针预绕。符号靠 extreme_direction 定,不靠改代码里的负号。
"extreme_angle_deg": 150.0,
"extreme_speed_deg_s": 20.0,
"extreme_direction": 1,
"extreme_margin_deg": 3.0,
# S02(预留)
"grip_check": False,
"grip_hold_s": 0.5,
# S03
"coarse_enabled": True,
"coarse_camera": "d435",
"coarse_hover_m": 0.25,
"coarse_conf": 0.35,
"coarse_timeout_s": 20.0,
# S05
"snap_stable_s": 1.0,
"snap_tol_px": 2.0,
"snap_tol_mm": 0.4,
"snap_min_frames": 6,
"snap_stable_feature": "auto",
# S06
"align_tol_mm": 1.0,
# S07
"contact_speed_m_s": 0.02,
"contact_step_mm": 0.5,
"contact_max_depth_mm": 35.0,
"tool_tip_below_flange_m": 0.243,
"workpiece_surface_z_m": 0.0, # 0 = 未知,运行期用 stage2 实测孔位兜底
"contact_tangential_delta": 300.0,
# 腕部 FT 的轴向力阈值 [N]。**这是 S07 的首选判据**,触觉只是兜底。
#
# 为什么默认是 8N 这么小:这是"接触了没有"的判据,不是"压到位了没有"。
# 台肩一落面,接触刚度 5e4 N/m 意味着过压 0.1mm 就有 5N、0.2mm 有 10N ——
# 所以 8N 对应约 0.16mm 的过压,**在打滑之前**就能判到(打滑上限由
# µ·N 决定:µ=0.25、N≈46N → 两指合计 ~23N ≈ 0.46mm)。留了 3 倍余量。
# 现场标定法:空载慢慢压到台肩落面,看日志里"腕部轴向"从基线涨到多少,
# 取那个值的 1/3 作为阈值(要能区分"擦到孔壁"和"台肩落面")。
"contact_axial_delta_n": 8.0,
# 接触到之后的**卸载回退** [mm]。
#
# 为什么必须有这一步(2026-09-24 一整轮排查换来的):
# 位置控制的臂在"撞上硬面"时会把力顶到自己的**出力上限**才停住 ——
# 仿真实测:一步 0.5mm 的指令压在工件上,腕部 FT 读数直接跳到 **+244N**,
# 而臂这一步根本没走完("实测只走了 +0.09mm")。这不是仿真特有的:
# 接触刚度越高、步长越大,峰值力就越大,而位置环**不限制**它。
#
# 规是**靠夹持摩擦**抱住的,轴向能传的力上限是 µ·N(两指合计 ~73N@µ=0.8)。
# 244N 远超这个上限 ⇒ 规被轴向力**顶出爪外**(本次就是:S08 一转,
# 规直接掉到地上,报 E1)。所以"判到接触"之后不能就那样顶着,
# 必须先把下压量放掉,再交给 S08 拧入。
#
# 0.5mm 的来历:判到接触时的**真实**过压量通常只有 0.0x mm(刚度 5e4 N/m
# 时,10N 对应 0.2mm),回退 0.5mm 足够把力放干净,而规尖仍在孔里
# (通端穿板 18mm),不会脱出。
"contact_relief_mm": 0.5,
# 卸载后允许的残余轴向力 [N]。
# 超了不失败(可能只是回退量不够),但要**大声报**:它说明"夹持摩擦力不够
# 承受这次接触",接着 S08 一转就会掉件。调法只有两条 —— 降峰值(调小
# contact_step_mm / contact_relief_mm)或加握力(改夹爪合拢力度/指垫)。
"contact_hold_n": 5.0,
"contact_safe_delta": 2000.0,
# S08
# 步长必须满足 2×step + 2×余量 ≤ 关节7 行程(Rizon4/10 ±175° ⇒ step ≤ 167°):
# 180° 会必然贴限位(棘轮一个来回要 360° > 350°),实测第 3 轮 ABORT。
"screw_step_deg": 120.0,
"screw_speed_deg_s": 30.0,
"screw_back_speed_deg_s": 120.0,
"screw_max_cycles": 12,
"screw_normal_delta": 400.0,
"screw_shear_delta": 600.0,
"screw_grip_force_pct": 50.0,
"screw_grip_speed_pct": 50.0,
"screw_release_wait_s": 0.3,
# S09
"unscrew_extra_cycles": 1,
# 全局
"holes_total": 2,
"dry_run": False,
# E1(掉件)重放:等操作员放好并确认 /gauge/reload_ok 的最长时间。
# 超时 = 中止(臂在 home,人不在旁边时"继续等"比"停"更危险)。
"reload_timeout_s": 180.0,
# 首轮咬合之后的空回:只张开到这个开度(约 8mm),微抬这么多 Z 再转回去。
# 实际下发在 pipeline_node._run_a7_seat(0–5mm、1–1000‰ 由那边也会再挡一次)。
"unwind_open_permille": 400,
"unwind_lift_mm": 3.0,
}
@dataclass(frozen=True)
class GaugeConfig:
"""通止规装配的全部可调量(解析并校验之后)。"""
boot_timeout_s: float
extreme_angle_deg: float
extreme_speed_deg_s: float
extreme_direction: int
extreme_margin_deg: float
grip_check: bool
grip_hold_s: float
coarse_enabled: bool
coarse_camera: str
coarse_hover_m: float
coarse_conf: float
coarse_timeout_s: float
snap_stable_s: float
snap_tol_px: float
snap_tol_mm: float
snap_min_frames: int
snap_stable_feature: str
align_tol_mm: float
contact_speed_m_s: float
contact_step_mm: float
contact_max_depth_mm: float
tool_tip_below_flange_m: float
workpiece_surface_z_m: float
contact_tangential_delta: float
contact_axial_delta_n: float
contact_relief_mm: float
contact_hold_n: float
contact_safe_delta: float
screw_step_deg: float
screw_speed_deg_s: float
screw_back_speed_deg_s: float
screw_max_cycles: int
screw_normal_delta: float
screw_shear_delta: float
screw_grip_force_pct: float
screw_grip_speed_pct: float
screw_release_wait_s: float
unscrew_extra_cycles: int
holes_total: int
dry_run: bool
reload_timeout_s: float
unwind_open_permille: int
unwind_lift_mm: float
# -- 派生量(不占 YAML 键,免得两处各写一份,规则 M2)-------------------
@property
def screw_step_rad(self) -> float:
import math
return math.radians(self.screw_step_deg)
@property
def total_screw_deg(self) -> float:
"""一根规要拧进去的总角度 = 单次转角 × 循环数。"""
return self.screw_step_deg * self.screw_max_cycles
@property
def unscrew_cycles(self) -> int:
"""拧出要多转几轮:多留 ``unscrew_extra_cycles`` 轮,确保螺纹完全脱开。"""
return self.screw_max_cycles + max(0, self.unscrew_extra_cycles)
def describe(self) -> List[str]:
"""启动时打印的行:**实际生效的**值与派生结论。
特别是 S01 的"能转多少度"和 S08 的"要拧多少轮"—— 这两个数是现场
判断"参数是否真的生效"最直接的依据,比打印"我读了 site.yaml"有用。
"""
return [
f"通止规装配:每孔孔数 {self.holes_total},干跑={self.dry_run}",
f" S01 预绕 {self.extreme_angle_deg:.1f}°(方向={'逆时针' if self.extreme_direction > 0 else '顺时针'},"
f"限位余量 {self.extreme_margin_deg:.1f}°,关节速度 {self.extreme_speed_deg_s:.1f}°/s)",
f" S03 粗定位={'开' if self.coarse_enabled else '关'} 相机={self.coarse_camera} "
f"悬高 {self.coarse_hover_m * 1000:.0f}mm 置信度≥{self.coarse_conf:.2f}",
f" S05 稳定 {self.snap_stable_s:.1f}s / 至少 {self.snap_min_frames} 帧 / "
f"特征={self.snap_stable_feature}"
f"(检测框 ≤{self.snap_tol_px:.1f}px、法兰位移 ≤{self.snap_tol_mm:.2f}mm)"
"(稳定判据用「到均值的最远距离」,不是相邻帧之差)",
# 悬停高度**不在这里**:它取自 servo.hole_hover_z_m(stage2 那一层),
# 这里只印本段的容差 —— 一段配置里出现"另一个段的键"会让人改错地方。
f" S06 对位容差 {self.align_tol_mm:.1f}mm(悬停高取自 servo.hole_hover_z_m)",
f" S07 腕部轴向突变阈值 +{self.contact_axial_delta_n:.0f}N(**首选判据**,"
"腕部 FT 在夹持摩擦的上游,工具打滑也测得准),"
f"切向突变阈值 +{self.contact_tangential_delta:.0f}au(触觉兜底,两通道任一,现场标定),"
f"安全上限 +{self.contact_safe_delta:.0f},最多下探 {self.contact_max_depth_mm:.0f}mm",
f" S07 接触后卸载:回退 {self.contact_relief_mm:.2f}mm,"
f"残余轴向力要求 ≤{self.contact_hold_n:.1f}N"
"(位置控制的臂撞上硬面会把力顶到出力上限再停住,实测能到 200N+;"
"规靠夹持摩擦抱住,轴向上限只有 µ·N≈73N,顶着不放 S08 一转就掉件)",
f" S07 工具几何:尖端在法兰下方 {self.tool_tip_below_flange_m * 1000:.1f}mm,"
f"工件上表面 z={self.workpiece_surface_z_m:.3f}m"
"(启动时会按这两个值 + servo.hole_hover_z_m 算「悬停时工具到底入没入工件」,"
"入了会**明确报错**——见 gauge_fsm.tool_clearance_m)",
f" S07 步进 {self.contact_step_mm:.2f}mm @ {self.contact_speed_m_s * 1000:.0f}mm/s,"
f"到位容差 {descend_tol_m(self.contact_step_mm / 1000.0) * 1000:.3f}mm"
"(=步长的 40%,**推导出来的、不是可调项**:容差 ≥ 步长会让每一步都被判成"
"「已在容差内」,臂一步都不走)",
f" S08 棘轮 {self.screw_step_deg:.0f}°×{self.screw_max_cycles} 轮 = 累计 "
f"{self.total_screw_deg:.0f}°;停止判据「切向 >+{self.screw_shear_delta:.0f} 且 "
f"法向 >+{self.screw_normal_delta:.0f}」",
f" S09 拧出 {self.unscrew_cycles} 轮(比拧入多 {self.unscrew_extra_cycles} 轮)",
f" 首轮咬合后空回:松爪到 {self.unwind_open_permille}‰、微抬 Z "
f"{self.unwind_lift_mm:.1f}mm,转回后按实测降回",
]
def _resolve_one(key: str, raw: Mapping[str, Any]) -> Tuple[Any, bool]:
"""取一个键的值,返回 ``(值, 是否来自 site.yaml)``。"""
if key in raw:
value = raw[key]
elif key in DEFAULTS:
return DEFAULTS[key], False
else:
raise GaugeConfigError(
f"gauge_assembly.{key} 既没在 site.yaml 里给,也没有默认值。"
f"请在 config/site.yaml 的 gauge_assembly: 段补上"
f"(改完跑一次 bash scripts/show_site_config.sh 确认解析)"
)
want = _TYPES[key]
if want is bool and not isinstance(value, bool):
raise GaugeConfigError(_type_msg(key, value, "布尔 true/false"))
if want is int and (isinstance(value, bool) or not isinstance(value, int)):
raise GaugeConfigError(_type_msg(key, value, "整数"))
if want is float and (isinstance(value, bool) or not isinstance(value, (int, float))):
raise GaugeConfigError(_type_msg(key, value, "数字(可带小数)"))
if want is str and not isinstance(value, str):
raise GaugeConfigError(_type_msg(key, value, "字符串"))
return (float(value) if want is float else value), True
def _type_msg(key: str, value: Any, human: str) -> str:
unit = _UNITS[key]
hint = f",单位 {unit}" if unit else ""
return (
f"gauge_assembly.{key} 的类型不对:期望 {human}{hint},"
f"实际读到 {value!r}({type(value).__name__})。"
"本模块不做隐式转换 —— \"20\" 不会被当成 20 用,"
"因为那正是「填了字符串、跑起来是默认值」这类静默失效的入口"
)
def from_site(site: Mapping[str, Any], *, overrides: Optional[Mapping[str, Any]] = None) -> GaugeConfig:
"""解析并校验 ``site.yaml`` 的 ``gauge_assembly:`` 段。
:param site: ``site.yaml`` 解析后的整份字典。
:param overrides: CLI/launch 覆盖(只允许覆盖已知键,未知键报错)。
:raises GaugeConfigError: 缺键、类型错、未知键、单位与键名不符。
整段缺失**不报错**(全部走默认值),但会在 :meth:`GaugeConfig.describe`
之外由调用方打一条告警;理由是"默认值能不能用"取决于现场,
由节点决定要不要拒绝启动,配置模块只负责如实解析。
"""
raw_any = site.get("gauge_assembly") or {}
if not isinstance(raw_any, Mapping):
raise GaugeConfigError(
"site.yaml 的 gauge_assembly: 应该是一段映射(键值对),"
f"实际是 {type(raw_any).__name__}"
)
raw: Dict[str, Any] = dict(raw_any)
if overrides:
bad = [k for k in overrides if k not in _TYPES]
if bad:
raise GaugeConfigError(
f"未知的覆盖参数: {', '.join(sorted(bad))}。"
f"可在 gauge_assembly 段覆盖的键见 gauge_config._TYPES"
)
raw.update({k: v for k, v in overrides.items() if v is not None})
_reject_unknown(raw)
values = {key: _resolve_one(key, raw)[0] for key in _TYPES}
cfg = GaugeConfig(**values)
_sanity(cfg)
return cfg
def _reject_unknown(raw: Mapping[str, Any]) -> None:
"""多余的键直接报错,并给出最相近的候选名。
静默忽略多余键的代价是:现场写 ``screw_step_degs``,改参数没反应,
而日志里一切正常。报错里带候选名,是因为拼错比写错新键常见得多。
"""
unknown = sorted(set(raw) - set(_TYPES))
if not unknown:
return
lines: List[str] = []
for key in unknown:
close = difflib.get_close_matches(key, list(_TYPES), n=1, cutoff=0.6)
hint = f",是不是想写 {close[0]}?" if close else ""
lines.append(f" - {key}{hint}")
raise GaugeConfigError(
"site.yaml 的 gauge_assembly: 段里有本框架不认识的键:\n"
+ "\n".join(lines)
+ "\n多余的键不会被忽略,因为「写了没生效」比「写错报错」难查得多。"
"确认要新增配置项时,先在 gauge_config._TYPES / _UNITS 里登记"
)
def _sanity(cfg: GaugeConfig) -> None:
"""跨键的合理性检查:单键合法、组合起来没意义的,在这里挡住。"""
if cfg.holes_total < 1:
raise GaugeConfigError(f"gauge_assembly.holes_total 至少为 1,收到 {cfg.holes_total}")
if cfg.extreme_direction not in (-1, 1):
raise GaugeConfigError(
f"gauge_assembly.extreme_direction 只能是 +1(逆时针)或 -1(顺时针),"
f"收到 {cfg.extreme_direction}"
)
if cfg.screw_step_deg <= 0.0:
raise GaugeConfigError(
f"gauge_assembly.screw_step_deg 必须为正,收到 {cfg.screw_step_deg}"
)
if cfg.screw_max_cycles < 1:
raise GaugeConfigError(
f"gauge_assembly.screw_max_cycles 至少为 1,收到 {cfg.screw_max_cycles}"
)
if cfg.extreme_angle_deg <= 0.0:
raise GaugeConfigError(
f"gauge_assembly.extreme_angle_deg 必须为正(方向由 extreme_direction 决定,"
f"不要用负角度表方向),收到 {cfg.extreme_angle_deg}"
)
if cfg.contact_safe_delta <= cfg.contact_tangential_delta:
raise GaugeConfigError(
f"contact_safe_delta({cfg.contact_safe_delta}) 必须大于 "
f"contact_tangential_delta({cfg.contact_tangential_delta}):"
"前者是「立刻抬臂」的安全上限,后者只是「停止下压」。"
"两者相等的话,接触一开始就会触发安全上限"
)
if cfg.tool_tip_below_flange_m <= 0.0:
raise GaugeConfigError(
f"tool_tip_below_flange_m 必须为正(工具尖端在法兰**下方**多少米),"
f"收到 {cfg.tool_tip_below_flange_m}。"
"填 0 会让「悬停时工具会不会已经插进工件」这个判断失效"
)
if cfg.snap_tol_px <= 0.0:
raise GaugeConfigError(f"gauge_assembly.snap_tol_px 必须为正,收到 {cfg.snap_tol_px}")
if not 0.0 <= cfg.unwind_lift_mm <= 5.0:
raise GaugeConfigError(
f"gauge_assembly.unwind_lift_mm 只接受 0–5mm 的微抬,"
f"收到 {cfg.unwind_lift_mm}。空回只需要让规离开孔口,抬多了会撞上方"
)
if not 1 <= cfg.unwind_open_permille <= 1000:
raise GaugeConfigError(
f"gauge_assembly.unwind_open_permille 只接受 1–1000,"
f"收到 {cfg.unwind_open_permille}(0=合,1000=全开)"
)
if cfg.contact_relief_mm < 0.0:
raise GaugeConfigError(
f"gauge_assembly.contact_relief_mm 不能为负(0 = 接触到之后不卸载),"
f"收到 {cfg.contact_relief_mm}。\n"
" 它为什么值得一条检查:位置控制的臂撞上硬面会把轴向下压顶到**出力上限**"
"才停住(实测 200N+),而规靠夹持摩擦抱住、轴向上限只有 µ·N(≈73N)。"
"卸载这一步省掉,S08 一转就掉件(报 E1)—— 而那时的日志只会说"
"「通止规掉了」,看不到真因。"
)
if cfg.contact_hold_n <= 0.0:
raise GaugeConfigError(
f"gauge_assembly.contact_hold_n 必须为正(卸载后的残余轴向力上限 [N]),"
f"收到 {cfg.contact_hold_n}。0 会让「卸载干不干净」永远判不过。"
)
if cfg.snap_tol_mm <= 0.0:
raise GaugeConfigError(f"gauge_assembly.snap_tol_mm 必须为正,收到 {cfg.snap_tol_mm}")
if cfg.snap_stable_feature not in SNAP_STABLE_FEATURES:
raise GaugeConfigError(
f"gauge_assembly.snap_stable_feature={cfg.snap_stable_feature!r} 不认识,"
f"只支持 {sorted(SNAP_STABLE_FEATURES)}。\n"
f" auto = 按 perception.source 自动选(真机 yolo→bbox、仿真/外部→motion)\n"
f" bbox = 用工件检测框中心(像素),需要检测器真的出框\n"
f" motion = 用法兰位移(mm),不依赖检测器"
)
# =============================================================================
# 自检:键名必须体现单位(防"填了毫米按米用")
# =============================================================================
def unit_violations(keys: Optional[Iterable[str]] = None) -> List[str]:
"""返回"键名与单位不符"的清单(空 = 全部合规)。
规则:单位非空的键,**键名必须以对应的单位后缀结尾**(见 :data:`_UNIT_SUFFIX`)。
例外只有 :data:`_SUFFIX_OVERRIDE` 里显式登记的复合单位。
为什么值得一条专门的检查:``hover`` 填毫米、代码按米用,会给出一个
"看起来完全合理"的运动(悬停 5 变成悬停 5 米或 5 毫米),
而 :mod:`arm_interfaces.config_guard` 的头注释记录的正是这类事故。
"""
bad: List[str] = []
for key in keys if keys is not None else _UNITS:
unit = _UNITS[key]
if not unit:
if key not in _UNITLESS:
bad.append(f"{key}: 无量纲但未登记在 _UNITLESS")
continue
suffix = _SUFFIX_OVERRIDE.get(key)
if suffix is None:
base = unit.split("/")[0]
suffix = _UNIT_SUFFIX.get(base)
if suffix is None:
bad.append(f"{key}: 单位 {unit} 没有登记后缀(补 _UNIT_SUFFIX 或 _SUFFIX_OVERRIDE)")
continue
if not key.endswith(suffix):
bad.append(f"{key}: 单位是 {unit},键名应以 {suffix} 结尾")
return bad
def missing_entries() -> List[str]:
"""三张表(类型/单位/默认值)是否互相覆盖完整。"""
problems: List[str] = []
for key in _TYPES:
if key not in _UNITS:
problems.append(f"{key}: 缺 _UNITS 登记")
if key not in DEFAULTS:
problems.append(f"{key}: 缺 DEFAULTS(required 的键也应写一个起点值并在文档里标注必标定)")
for key in _UNITS:
if key not in _TYPES:
problems.append(f"{key}: 在 _UNITS 里但不在 _TYPES 里")
for key in DEFAULTS:
if key not in _TYPES:
problems.append(f"{key}: 在 DEFAULTS 里但不在 _TYPES 里")
return problems
def dataclass_keys() -> List[str]:
""":class:`GaugeConfig` 的字段名 —— 与配置表比对,防"解析了但没存进对象"。"""
return [f.name for f in fields(GaugeConfig)]
@@ -0,0 +1,251 @@
"""夹爪客户端:把"真机今天的接口"和"框架契约里的接口"收敛到一个出入口。
为什么要这一层(这不是多余抽象)
--------------------------------
现场查证(2026-09-24)发现**同一个服务名后面挂着两种类型**:
=============== ========================== ================================
服务 A:真机今天跑的 B:框架契约 / 仿真
=============== ========================== ================================
``/gripper/open`` ``std_srvs/Trigger`` ``arm_msgs/srv/GripperExecute``
``/gripper/close`` ``std_srvs/Trigger`` ``arm_msgs/srv/GripperExecute``
状态 ``/gripper/states`` ``/gripper/status``
(Float32MultiArray,5 元) (arm_msgs/GripperStatus)
=============== ========================== ================================
A 来自 ``arm_application/dh_pgea_node.py``(真机在跑的那个节点,grip_state 直接
带"咬住/掉落");B 来自 ``devices/grippers/dh_pgea/manifest.yaml`` 声明的
``arm_driver_dh_gripper`` —— **那个包还不存在**(``src/`` 下没有),目前只有
仿真适配器实现了它。
于是有两个错法,都得避免:
* 只按契约(B)写:真机上服务类型对不上,装配流程一步都走不了,
而且报错会像是"服务没起"(实际起了,只是类型不同);
* 只按真机(A)写:仿真跑不了,而且把"临时接口"固化成事实(规则 M2)。
所以:**运行时看服务类型自己选**(:func:`pick_backend`),装配节点只依赖
:class:`GripperClient` 的 ``open/close/wait/is_gripped`` 四个方法。
等 ``arm_driver_dh_gripper`` 落地后,把 ``gauge_assembly.gripper_backend``
改回 ``auto`` 即可自动切过去,应用层不用改一行。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Mapping, Optional, Sequence
#: 契约后端用到的服务名(``arm_msgs/srv/GripperExecute``)。
CONTRACT_TYPE = "arm_msgs/srv/GripperExecute"
#: 真机今天用的后端(``std_srvs/srv/Trigger``)。
LEGACY_TYPE = "std_srvs/srv/Trigger"
CONTRACT_OPEN = "/gripper/open"
CONTRACT_CLOSE = "/gripper/close"
CONTRACT_STATUS_TOPIC = "/gripper/status"
LEGACY_STATUS_TOPIC = "/gripper/states"
#: ``/gripper/states`` 第 6 个元素:1=这一帧刚从总线读回来;0=总线无应答,
#: 前 5 个是上一次读到的值。老节点只发 5 个元素 —— 那时它只在读成功才发,
#: 所以 5 元素帧按有效帧处理。
#: 为什么要这一位:2026-10-20 读 0x0201 一连 264 次无应答,节点干脆不发状态,
#: 流水线只看到「没有 /gripper/states」,把「总线读不到」显示成「夹爪没到位」。
STATES_ONLINE_INDEX = 5
#: DH 状态字(与 ``arm_msgs/GripperStatus.msg`` 里的注释一致)。
GRIP_STATE_MOVING = 0
GRIP_STATE_REACHED = 1
GRIP_STATE_CAUGHT = 2
GRIP_STATE_DROPPED = 3
GRIP_STATE_NAME = {
GRIP_STATE_MOVING: "运动中",
GRIP_STATE_REACHED: "到位",
GRIP_STATE_CAUGHT: "咬住物体",
GRIP_STATE_DROPPED: "物体掉落",
}
BACKENDS = ("contract", "legacy")
class GripperInterfaceError(RuntimeError):
"""夹爪接口无法确定。消息里必须写清"现在是什么状态、怎么修"。"""
@dataclass(frozen=True)
class BackendChoice:
"""选择结果。"""
backend: str
"""``contract`` 或 ``legacy``。"""
reason: str
"""为什么这么选 —— 要能直接打进启动日志。"""
status_topic: str
"""该后端下状态从哪个话题读。"""
def pick_backend(
services: Mapping[str, Sequence[str]], prefer: str = "auto"
) -> BackendChoice:
"""按**实际服务类型**决定用哪种夹爪接口。
:param services: ``{服务名: [类型, ...]}``,直接来自
``Node.get_service_names_and_types()``。
:param prefer: ``auto`` / ``contract`` / ``legacy``。
非 auto 时若实际类型与之不符,**不静默降级**,而是报错 ——
现场明确指定了后端却跑在另一个上,是最难查的一类"改了没生效"。
:raises GripperInterfaceError: 认不出来、两种都有、或指定的后端对不上。
用类型而不是名字判断,是因为两种后端的**服务名完全一样**(都是
``/gripper/open``):只看名字必然选不出来,而"随便选一个"的后果是
调用一个类型不匹配的服务,报出来的错与真实原因无关(规则 M1)。
"""
if prefer not in ("auto",) + BACKENDS:
raise GripperInterfaceError(
f"gripper_backend 只能是 auto / {' / '.join(BACKENDS)},收到 {prefer!r}"
)
open_types = list(services.get(CONTRACT_OPEN, ()))
close_types = list(services.get(CONTRACT_CLOSE, ()))
has_contract = CONTRACT_TYPE in open_types and CONTRACT_TYPE in close_types
has_legacy = LEGACY_TYPE in open_types and LEGACY_TYPE in close_types
if prefer == "contract" and not has_contract:
raise GripperInterfaceError(
f"配置要求 contract 后端,但 {CONTRACT_OPEN} 的类型是 "
f"{open_types or '(服务不存在)'},不是 {CONTRACT_TYPE}。\n"
" 怎么修:① 起仿真(它有契约服务)或等 arm_driver_dh_gripper 落地;"
"② 改用 gauge_assembly.gripper_backend: legacy(真机今天的接口);"
"③ 改回 auto 让本函数自己选"
)
if prefer == "legacy" and not has_legacy:
raise GripperInterfaceError(
f"配置要求 legacy 后端,但 {CONTRACT_OPEN} 的类型是 "
f"{open_types or '(服务不存在)'},不是 {LEGACY_TYPE}。\n"
" 怎么修:确认真机侧起的是 arm_application 的 dh_pgea 节点"
"(bash scripts/run_gripper.sh --arm 89)"
)
if has_contract and has_legacy:
# 同名服务两种类型同时在线:ROS 2 允许,但调用方只能撞运气。
raise GripperInterfaceError(
f"{CONTRACT_OPEN} 同时存在两种类型:{open_types}。"
"是仿真适配器和真机节点同时起着(或旧的没停干净)。\n"
" 怎么修:只留一个 —— 仿真用 scripts/stop_sim_gz.sh 停干净,"
"真机 pkill -f dh_pgea"
)
if prefer == "auto":
if has_contract:
return BackendChoice("contract", f"{CONTRACT_OPEN} 是 {CONTRACT_TYPE}", CONTRACT_STATUS_TOPIC)
if has_legacy:
return BackendChoice(
"legacy",
f"{CONTRACT_OPEN} 是 {LEGACY_TYPE}(真机 dh_pgea 节点;"
"契约驱动 arm_driver_dh_gripper 还没落地)",
LEGACY_STATUS_TOPIC,
)
raise GripperInterfaceError(_unrecognised_msg(services, open_types))
if prefer == "contract":
return BackendChoice("contract", "配置指定 contract", CONTRACT_STATUS_TOPIC)
return BackendChoice("legacy", "配置指定 legacy", LEGACY_STATUS_TOPIC)
def _unrecognised_msg(
services: Mapping[str, Sequence[str]], open_types: Sequence[str]
) -> str:
"""认不出来时,把"服务不在"和"服务在但类型不对"分开说。
两者都报成"服务没起"是这个仓库反复踩过的坑:现场明明起了夹爪节点,
却被提示"去起夹爪节点",然后开始怀疑人生(规则 M1、R5.4)。
"""
missing = [n for n in (CONTRACT_OPEN, CONTRACT_CLOSE) if n not in services]
if missing:
return (
f"{' / '.join(missing)} 不在,夹爪节点没起来。\n"
" 怎么修:真机 bash scripts/run_gripper.sh --arm 89;"
"仿真 bash scripts/run_sim_gz.sh(仿真自带契约服务)"
)
return (
f"{CONTRACT_OPEN} 在,但类型是 {list(open_types)},"
f"既不认识 {CONTRACT_TYPE}(契约)也不认识 {LEGACY_TYPE}(真机 dh_pgea)。\n"
" 常见原因:这个服务名被别的节点占用了。查是谁:\n"
" ros2 service list -t | grep gripper\n"
" 怎么修:停掉那个节点,或改 gauge_assembly.gripper_backend 明确指定后端"
)
# =============================================================================
# 状态解读(纯函数)
# =============================================================================
def is_gripped_from_state(grip_state: int) -> Optional[bool]:
"""DH 状态字 → "爪里有没有东西"。
``2`` 咬住 / ``3`` 掉落 是明确的;``0`` 运动中、``1`` 到位(空爪)都要
小心:**"到位"不等于"夹住了"** —— 空爪闭合也会到位。所以只有 2/3 给
明确答案,其余返回 None("不知道"),由调用方决定要不要等。
返回 None 而不是 False 很重要:把"不知道"当成"没夹住"会让 E1 掉件检测
在任何一次开合过程中误报。
"""
if grip_state == GRIP_STATE_CAUGHT:
return True
if grip_state == GRIP_STATE_DROPPED:
return False
return None
def is_dropped_from_state(grip_state: int, was_gripped: bool) -> bool:
"""E1 掉件判据:**曾经夹住过**,现在状态字变成"掉落"才是掉件。
为什么要 ``was_gripped``:装配前爪里本来就是空的,那时 ``grip_state``
可能是 3(掉落/无物),直接判成掉件会让流程一开始就报 E1。
"""
return was_gripped and grip_state == GRIP_STATE_DROPPED
def states_frame_online(data: Sequence[float]) -> bool:
"""这一帧是不是刚从总线读回来的。
5 元素帧(老节点)视为有效:老节点只在读成功时发。6 元素帧看最后一位。
"""
if len(data) <= STATES_ONLINE_INDEX:
return True
try:
return float(data[STATES_ONLINE_INDEX]) >= 0.5
except (TypeError, ValueError):
return False
def legacy_states_to_fields(data: Sequence[float]) -> Dict[str, float]:
"""``/gripper/states`` 的 6 元素数组 → 具名字段。
超出长度、为空、或这一帧是离线帧时返回空字典(而不是抛异常):这是
**状态话题**,收到畸形数据时应该让上层走"不知道"分支,而不是让整个
节点崩掉。离线帧里的前 5 个是上一次的值,不能当现在的状态用。
"""
if not data or len(data) < 5:
return {}
if not states_frame_online(data):
return {}
return {
"initialized": float(data[0]),
"grip_state": float(data[1]),
"position": float(data[2]),
"target_position": float(data[3]),
"target_force": float(data[4]),
}
def describe_backend_choice(choice: BackendChoice) -> List[str]:
"""给启动日志用的多行说明(含"契约驱动还没落地"这类现场事实)。"""
lines = [f"夹爪后端 = {choice.backend}({choice.reason})"]
if choice.backend == "legacy":
lines.append(
" 注意:legacy 后端下 /gripper/open|close 是 std_srvs/Trigger,"
"开口是 1000‰ 的百分比语义,不是毫米;装配流程只用它开合,"
"不靠它量开口"
)
return lines
@@ -0,0 +1,284 @@
"""通止规装配用的小几何/解析工具 —— **纯函数**,可离线单测。
放这里的都是"算错了不会抛异常、只会让动作做错"的东西(规则 M3、R5.1):
* ``parse_joint_limits``:从 URDF 文本里抠关节限位,给 S01 的"转到极限角"
做软夹取。为什么不用 ``urdf_parser_py``:本机没装,多一个依赖就多一个
"在真机上 import 失败"的机会;限位信息在 URDF 里是极规整的一行
``<limit lower=... upper=...>``,用标准库解析足够,而且**可控**。
* ``prewind``:算"逆时针转到不触发关节极限的极限角"到底能转多少度。
* ``rotation_about_axis_deg``:事后验证那个动作**真的**是绕工具 Z 转的。
这条是 S01 的验收判据:不验证的话,"绕错轴"会表现成"转是转了,孔位
偏了",而且要到几个阶段之后才看出来。
"""
from __future__ import annotations
import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Dict, Iterable, Optional, Sequence, Tuple
import numpy as np
#: 限位夹取时留的余量(度)。贴到限位上再下发,驱动器会因为"位置超出限位"
#: 直接拒绝整条轨迹 —— 那不是我们要的"极限角",是报错。
DEFAULT_LIMIT_MARGIN_DEG = 3.0
#: 厂家 A7。``Robot::SendJointPosition`` 的关节向量按 A1..A7 排列,A7 是最后一轴。
A7_INDEX = 6
_JOINT_RE = re.compile(r"<joint\s+name\s*=\s*\"([^\"]+)\"")
_ATTR_RE = re.compile(r"(lower|upper)\s*=\s*\"([-+0-9.eE]+)\"")
def _limits_by_lines(text: str) -> Dict[str, Tuple[float, float]]:
"""逐行兜底解析:记住最近一个 ``<joint name=...>``,紧跟的 ``<limit>`` 归它。
为什么要"记住"而不是"要求同一行":正常格式化的 URDF 里关节名和 limit
**不在同一行**,只认可同一行的兜底等于永远不生效(一个不会触发的兜底,
和没有兜底是一样的)。属性顺序也按出现顺序读,不假设 lower 在 upper 前。
"""
out: Dict[str, Tuple[float, float]] = {}
current: Optional[str] = None
for line in text.splitlines():
m = _JOINT_RE.search(line)
if m:
current = m.group(1)
if current is None or "<limit" not in line:
continue
attrs = dict(_ATTR_RE.findall(line))
if "lower" in attrs and "upper" in attrs:
out[current] = (float(attrs["lower"]), float(attrs["upper"]))
current = None
return out
@dataclass(frozen=True)
class PrewindResult:
"""S01 预绕量的计算结果。"""
target_rad: float
"""目标关节角 [rad]。"""
applied_deg: float
"""实际能转的角度(带符号,符合"逆时针为正"的约定)[deg]。"""
want_deg: float
"""希望转的角度 [deg]。"""
clamped: bool
"""是否因为关节限位被夹取过。被夹取时 ``applied_deg < want_deg``。"""
has_limits: bool
"""是否拿到了可信的关节限位。False 表示没有任何夹取依据。"""
def parse_joint_limits(urdf_xml: str) -> Dict[str, Tuple[float, float]]:
"""从 URDF 文本里解析 ``关节名 -> (lower, upper)``(弧度)。
关节名**原样保留**(含 ``Rizon4-063989_`` 前缀),调用方自己决定怎么匹配;
这里不做"去前缀"之类的猜测 —— 猜测会在换臂时静默失配,然后限位夹取
悄悄失效(所有关节都"查不到限位",于是不夹取,于是贴着限位下发)。
解析失败的关节直接不进结果集:``continuous`` 关节本来就没有限位,
不该被当成"解析失败"。调用方用"查不到 = 不夹取"来处理。
"""
out: Dict[str, Tuple[float, float]] = {}
if not urdf_xml or "<robot" not in urdf_xml:
return out
try:
root = ET.fromstring(urdf_xml)
except ET.ParseError:
# URDF 里出现未展开的 xacro 或未转义的 ``&`` 是常见的(拿错话题就是
# 这种内容)。降级成逐行扫描仍能救回限位;救不回就返回空表(= 不夹取),
# 由调用方告警 —— **不会**返回半套数据。
return _limits_by_lines(urdf_xml)
for joint in root.findall(".//joint"):
name = joint.get("name")
limit = joint.find("limit")
if not name or limit is None:
continue
lo, up = limit.get("lower"), limit.get("upper")
if lo is None or up is None:
continue
try:
out[name] = (float(lo), float(up))
except ValueError:
continue
return out
def match_limits(
limits: Dict[str, Tuple[float, float]], joint_names: Sequence[str]
) -> Optional[Tuple[float, float]]:
"""按名字给一串关节名找限位,**要求全部找到**才返回。
只有一部分找到时返回 None 而不是"把找到的那部分用上":半套限位会让
"能不能转到极限角"的结论变得不可解释(有些关节夹了、有些没夹),
与其给出一个说不清的边界,不如明确地说"这次没有限位依据"。
"""
found = [limits.get(n) for n in joint_names]
if any(v is None for v in found):
return None
return found[-1] # 最后一个 = 我们要绕的那根轴(调用方传的顺序有意义)
def prewind(
q_now_rad: float,
want_deg: float,
direction: int,
limits: Optional[Tuple[float, float]] = None,
margin_deg: float = DEFAULT_LIMIT_MARGIN_DEG,
) -> PrewindResult:
"""算 S01 的预绕目标角。
:param q_now_rad: 当前关节角 [rad]。
:param want_deg: 想转的角度(**正值**)[deg]。
:param direction: ``+1`` = 绕 +Z 逆时针(右手定则);``-1`` = 顺时针。
符号到底对应现场的哪个方向,用真机确认一次后改 ``site.yaml`` 即可,
不要靠"改代码里的负号"—— 那种改动没人 review 得到(规则 M2)。
:param limits: ``(lower, upper)`` [rad]。给 None 时不做夹取。
:param margin_deg: 距离限位保留的余量 [deg]。
:returns: :class:`PrewindResult`。**无论有没有限位,都返回目标角**,
因为"贴到限位再继续转"在现场是预期行为(用户要求转到极限角),
驱动器真拒绝时会由 :func:`arm_application.gauge_fsm.is_joint_limit_error`
认成 JOINT_LIMIT 事件继续流程。
为什么要在这里夹取(而不是直接下发、等驱动器报错):
驱动器拒绝一条轨迹时,整个 action 失败,连带把已经转过的那部分**也**
变成"没转过"的语义(角度实际到位了,但流程认为失败)。夹取到
"限位 − 余量"能拿到一个明确成功、且确实接近极限的姿态度。
"""
if want_deg <= 0.0:
raise ValueError(f"预绕角度必须为正,收到 {want_deg}")
if direction not in (-1, 1):
raise ValueError(f"方向只能是 +1 / -1,收到 {direction}")
signed = direction * abs(want_deg)
target = q_now_rad + math.radians(signed)
has_limits = limits is not None
clamped = False
if limits is not None:
lo = limits[0] + math.radians(margin_deg)
up = limits[1] - math.radians(margin_deg)
if lo > up: # 限位本身比余量还窄(不该发生,但别让它变成静默走偏)
lo = up = 0.5 * (limits[0] + limits[1])
if target > up:
target, clamped = up, True
elif target < lo:
target, clamped = lo, True
applied = math.degrees(target - q_now_rad)
return PrewindResult(
target_rad=target,
applied_deg=applied,
want_deg=abs(want_deg),
clamped=clamped,
has_limits=has_limits,
)
def a7_limit_target(
q: Sequence[float],
limits: Optional[Tuple[float, float]],
direction: int,
margin_deg: float = DEFAULT_LIMIT_MARGIN_DEG,
) -> Optional[np.ndarray]:
"""只把 A7 收到软件限位内侧。前六轴原样拷回,不许在这里改。
厂家接口是 ``Robot::SendJointPosition``(模式 ``NRT_JOINT_POSITION``)。
向量按 A1..A7 排,A7 是下标 ``A7_INDEX``。限位是 ``RobotInfo::q_min/q_max``
的那一对,真机上与 URDF ``joint7`` 的 lower/upper 是同一组数。
``direction`` 与 ``prewind`` 相同:``+1`` 关节角增大 = 逆时针,目标取上限;
``-1`` 关节角减小 = 顺时针,目标取下限。现场若发现正负反了,改
``site.yaml`` 的 ``extreme_direction``,不要改这里的符号。
没有限位、方向不是 ±1、或者余量把上下限挤没了,返回 None。
调用方必须停,不许猜一个角度发出去。
"""
if direction not in (-1, 1):
raise ValueError(f"方向只能是 +1 / -1,收到 {direction}")
arr = np.asarray(q, dtype=float).reshape(-1)
if arr.size != 7:
raise ValueError(f"A7 目标要 7 个关节角,收到 {arr.size}")
if limits is None:
return None
lo = float(limits[0])
up = float(limits[1])
margin = math.radians(float(margin_deg))
if not math.isfinite(lo) or not math.isfinite(up) or up - lo <= 2.0 * margin:
return None
target = (up - margin) if direction > 0 else (lo + margin)
out = arr.copy()
out[A7_INDEX] = target
return out
def rotation_about_axis_deg(R_before: np.ndarray, R_after: np.ndarray, axis: np.ndarray) -> float:
"""从两个旋转矩阵算出"绕 ``axis`` 转了多少度"(带符号)。
S01 的验收判据就靠它:绕工具 Z 转 Δ 之后,如果实际绕的是别的轴,
这个函数给出的角度会与 Δ 明显不符(或者 axis 分量不占优)。
做法是把相对旋转投影到 ``axis`` 上:
``θ = atan2( axis·(R_b·R_a⁻¹ 的反对称部分) , (trace-1)/2 )``
:param axis: **单位**轴(工具 Z 在世界系下的方向 = ``R[:, 2]``)。
"""
a = np.asarray(axis, dtype=float)
n = float(np.linalg.norm(a))
if n < 1e-9:
raise ValueError("轴向量长度为 0,无法判定旋转")
a = a / n
Rb = np.asarray(R_before, dtype=float)[:3, :3]
Ra = np.asarray(R_after, dtype=float)[:3, :3]
Rrel = Ra @ Rb.T
# 反对称部分:sin(θ)·[axis]_×
v = np.array([Rrel[2, 1] - Rrel[1, 2], Rrel[0, 2] - Rrel[2, 0], Rrel[1, 0] - Rrel[0, 1]])
sin_t = 0.5 * float(np.dot(v, a))
cos_t = 0.5 * (float(np.trace(Rrel)) - 1.0)
return math.degrees(math.atan2(sin_t, cos_t))
def axis_misalignment_deg(R_before: np.ndarray, R_after: np.ndarray, axis: np.ndarray) -> float:
"""相对旋转的转轴与 ``axis`` 的夹角 [deg]。
纯绕轴旋转时这个值≈0;转错轴时它会接近 90°。用作 S01 的判错依据:
只看"转了多少度"是不够的 —— 绕正确的轴转 90° 和绕错误的轴转 90°
给出的角度读数一样。
"""
Rb = np.asarray(R_before, dtype=float)[:3, :3]
Ra = np.asarray(R_after, dtype=float)[:3, :3]
Rrel = Ra @ Rb.T
# 旋转轴 = 反对称部分对应的向量(sinθ 因子不影响方向)
v = np.array([Rrel[2, 1] - Rrel[1, 2], Rrel[0, 2] - Rrel[2, 0], Rrel[1, 0] - Rrel[0, 1]])
nv = float(np.linalg.norm(v))
if nv < 1e-9:
return 0.0 # 几乎没转,谈不上"转错轴"
a = np.asarray(axis, dtype=float)
a = a / float(np.linalg.norm(a))
cos = abs(float(np.dot(v / nv, a))) # 取绝对值:轴线的两个方向等价
return math.degrees(math.acos(max(-1.0, min(1.0, cos))))
def unit_tool_z(R: np.ndarray) -> np.ndarray:
"""工具坐标系的 Z 轴在世界系下的方向(旋转矩阵的第三列)。"""
return np.asarray(R, dtype=float)[:3, 2]
def vertical_down_error_deg(R: np.ndarray, world_down: Iterable[float] = (0.0, 0.0, -1.0)) -> float:
"""工具 Z 与"垂直向下"的夹角 [deg]。0 = 完全垂直向下。
看板里"工具系全程垂直向下、位姿全局锁定"这句话,只有能算出这个数才算
真的在执行 —— 否则每个阶段都得靠人眼看画面判断臂歪没歪。
"""
z = unit_tool_z(R)
d = np.asarray(list(world_down), dtype=float)
d = d / float(np.linalg.norm(d))
cos = float(np.dot(z / max(1e-12, float(np.linalg.norm(z))), d))
return math.degrees(math.acos(max(-1.0, min(1.0, cos))))
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""感知/伺服启动前的开机回初始位流程。
流程:
[A] 若法兰低于 ``servo.safe_z_m``,先让它竖直上升(闭环 Z 小步跳),
以免回 home 时扫到墙面/工装;
[B] 调用 ``/set_named_pose``(Flexiv 命名位姿,默认 "home")—— 由 Flexiv
自己的规划器把机械臂送回 Flexiv 初始位姿;
[C] 保持(pin)住,让伺服从一个干净、静止的位姿接手。
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
import rclpy
from arm_msgs.srv import SetNamedPose
from control_msgs.action import FollowJointTrajectory
from rclpy.action import ActionClient
from rclpy.node import Node
from sensor_msgs.msg import JointState
from tf2_ros import Buffer, TransformListener
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
from arm_application.geom_ik import (
RIZON_LINK_AXES,
dls_translate_hold_ori,
dls_twist,
geometric_jacobian,
quat_xyzw_to_R,
)
from arm_application.site_io import (
base_frame_default,
ee_frame_default,
joint_prefix_default,
)
from arm_application.ros_node import TolerantNode
def _load_ee_cfg() -> dict:
root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(root / "scripts" / "lib"))
from export_site_env import load_site, resolve_robot # type: ignore
data = load_site()
servo = (data.get("servo") or {})
robot = resolve_robot(data)
ee_z = float(servo.get("ee_z_m") or 0.24)
return {
"prefix": str(robot.get("joint_prefix") or joint_prefix_default()),
"base": str(robot.get("base_frame") or base_frame_default()),
"ee": str(robot.get("ee_frame") or ee_frame_default()),
"z": ee_z,
# 回 home 前先竖直升到这个 Z(避免撞到墙面/工装);
# 然后再回到 Flexiv 的命名初始位姿。
"safe_z": float(servo.get("safe_z_m") or max(0.40, ee_z + 0.10)),
"home_pose": str(servo.get("home_pose_name") or "home"),
"home_speed": float(servo.get("home_pose_speed") or 0.30),
"v_z": float(servo.get("v_max_z") or 0.04),
# 仅用于(目前未使用的)verticalize 规划路径
"kp": float(servo.get("ee_hold_kp") or 2.0),
"w_max": float(servo.get("ee_w_max") or 0.50),
}
def _pt(q: np.ndarray, qdot: np.ndarray, t_sec: float) -> JointTrajectoryPoint:
pt = JointTrajectoryPoint()
pt.positions = [float(v) for v in q.tolist()]
pt.velocities = [float(v) for v in qdot.tolist()]
pt.accelerations = [0.0] * int(q.size)
ns = int(round(max(0.0, float(t_sec)) * 1e9))
pt.time_from_start.sec = ns // 1_000_000_000
pt.time_from_start.nanosec = ns % 1_000_000_000
return pt
class GotoEePlane(TolerantNode):
def __init__(self, cfg: dict) -> None:
super().__init__("goto_ee_plane")
self.cfg = cfg
prefix = cfg["prefix"]
self.joint_names = [f"{prefix}joint{i}" for i in range(1, 8)]
self.link_frames = [f"{prefix}link{i}" for i in range(1, 8)]
self._js: Optional[JointState] = None
self.create_subscription(JointState, "joint_states", self._on_js, 10)
self._tf = Buffer()
TransformListener(self._tf, self, spin_thread=True)
self._cli = ActionClient(
self, FollowJointTrajectory, "rizon_arm_controller/follow_joint_trajectory"
)
def _on_js(self, msg: JointState) -> None:
self._js = msg
def _spin(self, dt: float = 0.05) -> None:
rclpy.spin_once(self, timeout_sec=dt)
def _lookup(self, parent: str, child: str) -> Optional[np.ndarray]:
try:
tf = self._tf.lookup_transform(
parent, child, rclpy.time.Time(),
timeout=rclpy.duration.Duration(nanoseconds=80_000_000),
)
except Exception:
return None
t = tf.transform.translation
o = tf.transform.rotation
T = np.eye(4)
T[:3, :3] = quat_xyzw_to_R([o.x, o.y, o.z, o.w])
T[:3, 3] = [t.x, t.y, t.z]
return T
def _q(self) -> Optional[np.ndarray]:
if self._js is None:
return None
m = {n: p for n, p in zip(self._js.name, self._js.position)}
try:
return np.array([float(m[n]) for n in self.joint_names], dtype=float)
except KeyError:
return None
def _jacobian(self, p_ee: np.ndarray) -> Optional[np.ndarray]:
link_R: List[np.ndarray] = []
link_p: List[np.ndarray] = []
for fr in self.link_frames:
Tl = self._lookup(self.cfg["base"], fr)
if Tl is None:
return None
link_R.append(Tl[:3, :3])
link_p.append(Tl[:3, 3])
return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES)
def _w_down(self, R: np.ndarray) -> Tuple[np.ndarray, float]:
z_now = R[:, 2]
z_des = np.array([0.0, 0.0, -1.0])
c = float(np.clip(np.dot(z_now, z_des), -1.0, 1.0))
ang = float(np.arccos(c))
if ang < 0.02:
return np.zeros(3), ang
axis = np.cross(z_now, z_des)
n = float(np.linalg.norm(axis))
axis = axis / n if n > 1e-9 else np.array([1.0, 0.0, 0.0])
mag = float(np.clip(self.cfg["kp"] * ang, -self.cfg["w_max"], self.cfg["w_max"]))
return axis * mag, ang
def _target_err(
self, p: np.ndarray, R: np.ndarray, target: np.ndarray, verticalize: bool
) -> Tuple[np.ndarray, float, float]:
dp = np.asarray(target, dtype=float).reshape(3) - np.asarray(p, dtype=float).reshape(3)
_, ang = (self._w_down(R) if verticalize else (np.zeros(3), 0.0))
xy = float(np.hypot(dp[0], dp[1]))
return dp, ang, xy
def _wait_state(self, timeout_s: float) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
t0 = time.monotonic()
q = None
Tee = None
while time.monotonic() - t0 < timeout_s:
self._spin(0.05)
q = self._q()
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if q is not None and Tee is not None and self._jacobian(Tee[:3, 3]) is not None:
return q, Tee
return q, Tee
def _send_action(self, traj: JointTrajectory, wait_s: float) -> bool:
goal = FollowJointTrajectory.Goal()
goal.trajectory = traj
fut = self._cli.send_goal_async(goal)
t0 = time.monotonic()
while not fut.done() and time.monotonic() - t0 < 4.0:
self._spin(0.05)
handle = fut.result() if fut.done() else None
if handle is None or not handle.accepted:
print(" 错误:JTC 拒绝了抬升轨迹", flush=True)
return False
res = handle.get_result_async()
t0 = time.monotonic()
last = 0.0
while not res.done() and time.monotonic() - t0 < wait_s:
self._spin(0.05)
now = time.monotonic()
if now - last > 0.4:
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if Tee is not None:
p = Tee[:3, 3]
print(
f" 运动中 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f}) "
f"z_err={(self.cfg['z'] - p[2]) * 1000:+.0f}mm",
flush=True,
)
last = now
if not res.done():
print(" 警告:JTC 结果超时 —— 仍然继续检查 TF", flush=True)
return False
return True
def _plan_and_send(
self,
q0: np.ndarray,
Tee: np.ndarray,
target: np.ndarray,
verticalize: bool,
tag: str = "",
max_seg: float = 0.08,
v: float = 0.05,
) -> bool:
"""规划并执行一段朝向 target 的短笛卡尔位移(≤ max_seg)。
长距离移动由调用方以闭环小步跳的方式完成,因为雅可比是在当前位姿
处冻结的 —— 超过 10 cm 后线性化误差会让一次性规划严重偏离目标。"""
p = Tee[:3, 3].copy()
R = Tee[:3, :3]
dp, ang, _ = self._target_err(p, R, target, verticalize)
dist = float(np.linalg.norm(dp))
if dist > max_seg: # 限制本次小步跳的长度;调用方会用最新的 TF 循环执行
dp = dp * (max_seg / dist)
dist = max_seg
print(
f" 规划 {tag} Δxyz=({dp[0]*1000:+.0f},{dp[1]*1000:+.0f},{dp[2]*1000:+.0f})mm "
f"倾角={ang * 180 / np.pi:.1f}deg",
flush=True,
)
J = self._jacobian(p)
if J is None:
print("错误:缺少用于雅可比的 link TF", flush=True)
return False
q = q0.copy()
waypoints = [q.copy()]
remain = dp.copy()
w_step, _ = (self._w_down(R) if verticalize else (np.zeros(3), 0.0))
n_max = int(np.ceil(dist / 0.012)) + 4
for _ in range(n_max):
if float(np.linalg.norm(remain)) < 0.0015 and (
(not verticalize) or ang < 0.03
):
break
nseg = max(1, int(np.ceil(float(np.linalg.norm(remain)) / 0.012)))
step = remain / nseg
if verticalize and ang > 0.03:
qdot = dls_twist(
J, step / 0.12, w_base=w_step, damping=0.08, max_qdot=0.45
)
dq = np.clip(qdot * 0.12, -0.06, 0.06)
else:
dq = dls_translate_hold_ori(J, step, damping=0.08, max_dq=0.06)
if float(np.max(np.abs(dq))) < 1e-5:
break
q = q + dq
waypoints.append(q.copy())
remain = remain - (J[:3, :] @ dq)
ang = max(0.0, ang - 0.05)
planned = dist - float(np.linalg.norm(remain))
if len(waypoints) < 2 or planned < 0.4 * dist:
print(
f" 警告 {tag}:规划器停滞 "
f"(已规划 {planned*1000:.0f}/{dist*1000:.0f}mm)—— 跳过本次小步跳",
flush=True,
)
return False
# 关节偏移量的合理性闸门:拒绝过于夸张的规划
if float(np.max(np.abs(waypoints[-1] - q0))) > 1.0:
print(f" 错误 {tag}:关节偏移 >1 rad —— 放弃本次小步跳", flush=True)
return False
duration = max(1.6, dist / max(min(v, 0.06), 1e-3))
n = len(waypoints) - 1
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points.append(_pt(waypoints[0], np.zeros(7), 0.08))
zeros = np.zeros(7)
for i, qi in enumerate(waypoints[1:], start=1):
t = 0.08 + duration * (i / n)
qdot = zeros if i == n else (waypoints[min(i + 1, n)] - qi) / max(
duration / n, 0.05
)
qdot = np.clip(qdot, -0.40, 0.40)
traj.points.append(_pt(qi, qdot, t))
# 强制最后一个点的速度为零
traj.points[-1].velocities = [0.0] * 7
print(
f" 发送 JTC action {tag},{n} 步 T={duration:.1f}s",
flush=True,
)
return self._send_action(traj, wait_s=duration + 6.0)
def _goto(
self,
q: np.ndarray,
Tee: np.ndarray,
target: np.ndarray,
verticalize: bool,
tol_m: float,
tol_tilt: float,
tag: str,
v: float,
max_hops: int = 8,
) -> Tuple[bool, np.ndarray, np.ndarray]:
"""闭环点到点:规划 ≤8 cm 的小步跳 → 执行 → 重新测量 TF → 重复,
直到落在容差内。这样对长距离下的雅可比线性化误差更鲁棒。"""
for hop in range(max_hops):
p = Tee[:3, 3]
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
dist = float(np.linalg.norm(dp))
if dist < tol_m and ((not verticalize) or ang < tol_tilt):
return True, q, Tee
print(
f" {tag} 第 {hop + 1} 跳 remain={dist*1000:.0f}mm "
f"倾角={ang * 180 / np.pi:.1f}deg",
flush=True,
)
if not self._plan_and_send(q, Tee, target, verticalize, tag, v=v):
return False, q, Tee
t_end = time.monotonic() + 0.5
while time.monotonic() < t_end:
self._spin(0.05)
q2, Tee2 = self._wait_state(2.0)
if q2 is None or Tee2 is None:
print("错误:移动过程中丢失状态", flush=True)
return False, q, Tee
q, Tee = q2, Tee2
p = Tee[:3, 3]
dp, ang, _ = self._target_err(p, Tee[:3, :3], target, verticalize)
ok = float(np.linalg.norm(dp)) < (tol_m * 2.0) and (
(not verticalize) or ang < (tol_tilt * 2.0)
)
return ok, q, Tee
def _pin(self, q: np.ndarray) -> None:
if not self._cli.wait_for_server(timeout_sec=3.0):
return
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points = [_pt(q, np.zeros(7), 0.08), _pt(q, np.zeros(7), 0.45)]
self._send_action(traj, wait_s=3.0)
def _named_pose(self, name: str, speed: float) -> bool:
"""调用 Flexiv 的 ``/set_named_pose``,并等待机械臂静止。"""
cli = self.create_client(SetNamedPose, "/set_named_pose")
if not cli.wait_for_server(timeout_sec=8.0):
print("错误:``/set_named_pose`` 不可用 —— 请启动 run_real.sh", flush=True)
return False
req = SetNamedPose.Request()
req.pose_name = name
req.velocity_scale = float(min(max(speed, 0.05), 1.0))
req.acceleration_scale = 0.0
print(f" [B] set_named_pose '{name}' speed={req.velocity_scale:.2f}", flush=True)
fut = cli.call_async(req)
t0 = time.monotonic()
while not fut.done() and time.monotonic() - t0 < 90.0:
self._spin(0.1)
if not fut.done():
print("错误:set_named_pose 超时", flush=True)
return False
resp = fut.result()
print(
f" [B] 响应 success={resp.success} msg={resp.message}",
flush=True,
)
if not resp.success:
return False
# 等待法兰停止运动(约 0.8 s 内 Δp < 1 mm)
last = None
still = 0.0
t0 = time.monotonic()
while time.monotonic() - t0 < 30.0:
self._spin(0.2)
Tee = self._lookup(self.cfg["base"], self.cfg["ee"])
if Tee is not None:
p = Tee[:3, 3]
if last is not None and float(np.linalg.norm(p - last)) < 0.001:
still += 0.2
if still >= 0.8:
return True
else:
still = 0.0
last = p.copy()
print(" 警告:等待静止超时 —— 仍然继续", flush=True)
return True
def run(self, timeout_s: float = 25.0) -> int:
cfg = self.cfg
q, Tee = self._wait_state(4.0)
if q is None or Tee is None:
print("错误:没有 joint_states / TF —— 请启动 run_real.sh", flush=True)
return 2
if not self._cli.wait_for_server(timeout_sec=8.0):
print("错误:JTC action 不可用 —— 请启动 run_real.sh", flush=True)
return 3
p = Tee[:3, 3]
safe_z = float(cfg["safe_z"])
print(
f"=== 回到 Flexiv 初始位姿 '{cfg['home_pose']}' "
f"safe_z={safe_z*1000:.0f}mm ===",
flush=True,
)
print(
f" 当前 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
flush=True,
)
# --- 阶段 A:回 home 前先沿纯 Z 方向升到安全高度 ---
if p[2] < safe_z - 0.01:
lift_tgt = np.array([p[0], p[1], safe_z], dtype=float)
print(f" [A] 抬升 Z {p[2]*1000:.0f} → {safe_z*1000:.0f}mm", flush=True)
ok, q, Tee = self._goto(
q, Tee, lift_tgt, False, 0.010, 0.0, "[A]lift", v=0.05
)
if not ok:
print("错误:抬升阶段失败 —— 就地 pin 住", flush=True)
self._pin(q)
return 4
# --- 阶段 B:Flexiv 命名位姿 → 真正的初始位姿,姿态良好 ---
if not self._named_pose(cfg["home_pose"], cfg["home_speed"]):
print("错误:回命名位姿失败 —— 就地 pin 住", flush=True)
self._pin(q)
return 4
# --- 阶段 C:保持住,让伺服从一个静止位姿接手 ---
q, Tee = self._wait_state(2.0)
if q is not None:
self._pin(q)
if Tee is not None:
p = Tee[:3, 3]
print(
f" 已到初始位姿 ee=({p[0]:.3f},{p[1]:.3f},{p[2]:.3f})",
flush=True,
)
return 0
def main() -> int:
cfg = _load_ee_cfg()
rclpy.init()
node = GotoEePlane(cfg)
try:
return node.run()
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,125 @@
"""Station-owned PGE/PGEA node: one serial owner, typed service and status only."""
import math
import time
import rclpy
from arm_application.ros_node import TolerantNode
from arm_msgs.msg import GripperStatus
from arm_msgs.srv import GripperExecute
from arm_application.dh_pgea import DhPgeaGripper, GripperState
class GripperNode(TolerantNode):
def __init__(self):
super().__init__('orisys_gripper')
params={'port':'/dev/ttyUSB0','baud':115200,'slave_id':1,'stroke_mm':20.,'auto_init':False,'rs485_rts':''}
for key,value in params.items(): self.declare_parameter(key,value)
value=lambda key:self.get_parameter(key).value
if value('auto_init'): raise ValueError('本站驱动禁止连接时自动初始化')
self.stroke=float(value('stroke_mm'))
if not math.isfinite(self.stroke) or self.stroke<=0: raise ValueError('夹爪行程无效')
self.driver=DhPgeaGripper(port=value('port'),baudrate=value('baud'),slave_id=value('slave_id'),
stroke_mm=self.stroke,rs485_rts=value('rs485_rts'))
self._reading=GripperState(stroke_mm=self.stroke)
self._online=False; self._error='未连接';self._speed=0.;self._stamp=0.
self.publisher=self.create_publisher(GripperStatus,'status',10)
self.create_service(GripperExecute,'execute',self._execute)
# Bus operations are serialized by the default mutually exclusive callback group.
# Do not open the serial port here: Qt probe calls execute as soon as the
# service exists, and a second open of the USB2 RS485 dongle leaves 0x0201 empty.
self.create_timer(.1,self._poll)
def _connect(self):
try:
self.driver.close()
self.driver.open()
self._reading=self.driver.get_run_states(retries=8)
self._online=True;self._error='';self._stamp=time.monotonic()
self.get_logger().info(self._reading.summary())
return True
except Exception as exc:
self._offline(exc);return False
def _offline(self, exc):
self._online=False;self._error=str(exc)
self.get_logger().warn(self._error)
self.driver.close()
def _status(self):
state=self._reading;msg=GripperStatus();msg.header.stamp=self.get_clock().now().to_msg()
msg.name='DH PGE/PGEA';msg.gripper_type='parallel'
msg.connected=self._online and time.monotonic()-self._stamp<1.
msg.initialized=bool(state.initialized)
msg.is_moving=bool(msg.connected and state.grip_state==0)
msg.is_gripped=bool(msg.connected and state.grip_state==2)
msg.state='OFFLINE' if not msg.connected else 'MOVING' if msg.is_moving else 'IDLE'
msg.position=float(state.position)/1000.*self.stroke
msg.finger_position_per_mille=float(state.position)
msg.gripper_state=int(state.grip_state);msg.rotation_state=-1
msg.velocity=self._speed;msg.force=float(state.target_force);msg.error_msg=self._error
return msg
def _poll(self):
if self._online:
try:
self._reading=self.driver.get_run_states(retries=2);self._stamp=time.monotonic();self._error=''
except Exception as exc:
if not self._recover(exc):
self._offline(exc)
self.publisher.publish(self._status())
def _recover(self, exc):
"""Empty 0x0201 replies on a live fd need a new serial session, not more retries."""
self.get_logger().warn(f'状态读取失败,重开串口: {exc}')
try:
self.driver.close()
self.driver.open()
self._reading=self.driver.get_run_states(retries=4)
self._stamp=time.monotonic();self._error='';self._online=True
return True
except Exception:
return False
def _execute(self, request, response):
command=request.command
try:
if command.command in ('connect','probe'):
if not self._connect(): raise RuntimeError(self._error)
elif command.command=='move':
numbers=(command.position,command.force,command.speed)
if not all(math.isfinite(v) for v in numbers): raise ValueError('参数必须是有限数值')
if not 0<=command.position<=self.stroke: raise ValueError('位置超出夹爪行程')
if not 20<=command.force<=100 or not 1<=command.speed<=100: raise ValueError('力 20–100%,速度 1–100%')
if not self._online or time.monotonic()-self._stamp>=1: raise RuntimeError('请先连接夹爪')
self._reading=self.driver.get_run_states(retries=2)
if self._reading.grip_state==0: raise RuntimeError('夹爪仍在运动,拒绝覆盖')
if not self.driver.set_target_force(int(command.force)): raise RuntimeError('设置夹持力失败,未下发位置')
if not self.driver.set_target_speed(int(command.speed)): raise RuntimeError('设置速度失败,未下发位置')
target=round(command.position/self.stroke*1000.)
if not self.driver.set_target_position(target): raise RuntimeError('位置写入失败,结果未知')
self._speed=command.speed
else: raise ValueError('本站平行夹爪仅提供 connect/probe/move,不支持旋转或隐式初始化')
response.success=True
response.message='夹爪已连接,未执行初始化或运动' if command.command in ('connect','probe') else '位置指令已接收,等待实际到位反馈'
except ValueError as exc:
response.success=False;response.message=str(exc)
except Exception as exc:
self._offline(exc);response.success=False;response.message=str(exc)
response.status=self._status()
self.publisher.publish(response.status)
return response
def destroy_node(self):
self.driver.close()
return super().destroy_node()
def main():
rclpy.init();node=None
try:
node=GripperNode();rclpy.spin(node)
except KeyboardInterrupt: pass
finally:
if node: node.destroy_node()
if rclpy.ok(): rclpy.shutdown()
if __name__=='__main__': main()
@@ -0,0 +1,769 @@
#!/usr/bin/env python3
"""完整手眼标定流水线:移动 → 棋盘稳定判定 → 采集 → 求解 → 清理。
支持 eye_in_hand / eye_to_hand。
相机内参优先级:
1) ROS 的 CameraInfo 话题(若正在发布)
2) 通过 pyrealsense2 读取 RealSense D405/D435 的 EEPROM(camera_source)
3) YAML 中手写的 camera_fx/fy/cx/cy
棋盘尺寸(board_cols / board_rows / square_size_m)可由用户配置。
"""
from __future__ import annotations
import json
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
import rclpy
from geometry_msgs.msg import PoseStamped
from rclpy.action import ActionClient
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo, Image, JointState
from std_msgs.msg import String
from arm_application.handeye_lib import (
BoardConfig,
CameraIntrinsics,
ChessboardStabilizer,
T_from_flexiv_tcp,
calibrate_hand_eye,
cam_to_flange_via_tool,
detect_chessboard,
format_matrix_yaml,
format_transform_yaml,
is_pose_filled,
pose_to_Rt,
solve_board_pose,
)
from arm_application.handeye_realsense import (
is_realsense_source,
query_realsense_color_intrinsics,
resolve_realsense_topics,
)
from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_msgs.srv import GetArmState
from arm_application.ros_node import TolerantNode
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
def _image_to_bgr(msg: Image) -> np.ndarray:
h, w = int(msg.height), int(msg.width)
enc = (msg.encoding or '').lower()
data = np.frombuffer(msg.data, dtype=np.uint8)
if enc in ('bgr8', 'rgb8'):
img = data.reshape(h, w, 3)
return img[:, :, ::-1].copy() if enc == 'rgb8' else img
if enc in ('mono8', '8uc1'):
gray = data.reshape(h, w)
return np.stack([gray, gray, gray], axis=-1)
if enc in ('bgra8', 'rgba8'):
img = data.reshape(h, w, 4)
bgr = img[:, :, :3]
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
def _camera_info_to_intrinsics(msg: CameraInfo) -> CameraIntrinsics:
K = np.array(msg.k, dtype=float).reshape(3, 3)
dist = np.array(msg.d, dtype=float).reshape(-1, 1) if msg.d else np.zeros((5, 1))
return CameraIntrinsics(K=K, dist=dist)
class HandeyeCalibrate(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_calibrate')
self.declare_parameter('mode', 'eye_in_hand') # eye_in_hand | eye_to_hand
self.declare_parameter('joint_prefix', joint_prefix_default())
self.declare_parameter('joint_names', DEFAULT_JOINTS)
self.declare_parameter('velocity_scale', 0.10)
self.declare_parameter('action_name', 'follow_joint_trajectory')
self.declare_parameter('move_time_sec', 4.0)
self.declare_parameter('start_index', 0)
self.declare_parameter('num_poses', 12)
self.declare_parameter('waypoint_positions', [0.0] * (12 * 7))
self.declare_parameter('min_poses', 5)
# 相机来源:manual | d405 | d435
self.declare_parameter('camera_source', 'manual')
self.declare_parameter('camera_serial', '')
self.declare_parameter('camera_namespace', '')
self.declare_parameter('realsense_width', 1280)
self.declare_parameter('realsense_height', 720)
self.declare_parameter('image_topic', '')
self.declare_parameter('camera_info_topic', '')
self.declare_parameter('camera_fx', 0.0)
self.declare_parameter('camera_fy', 0.0)
self.declare_parameter('camera_cx', 0.0)
self.declare_parameter('camera_cy', 0.0)
self.declare_parameter('camera_dist', [0.0, 0.0, 0.0, 0.0, 0.0])
# 棋盘(可自定义)
# 默认值必须与 site.yaml 的 handeye.board_* 及结果文件头里的实际板子一致。
# 真实使用的板:A5 内角点 11x8,方格 15mm(site.yaml handeye.boards.eye_in_hand)。
# 这里原先写的是 9x6 / 0.025,后来是 8x11;漏配就会静默用错尺寸。
self.declare_parameter('board_cols', 11) # 内角点
self.declare_parameter('board_rows', 8)
self.declare_parameter('square_size_m', 0.015)
self.declare_parameter('stable_window', 8)
self.declare_parameter('stable_pixel_std', 0.45)
self.declare_parameter('stable_timeout_sec', 25.0)
self.declare_parameter('settle_sec', 0.4)
# 到位后的"真的停住了"判据 —— 直接看关节速度,比固定 sleep 可靠。
# 这几个参数原先 site.yaml 和 run_handeye.sh 一直在传,但本节点从未声明,
# ROS 2 会静默忽略未声明的覆盖参数,于是"等静止再拍照"这件事其实没实现,
# 实际只靠一次固定 settle_sec。现在补上。
self.declare_parameter('settle_qd_max', 0.015) # |qd|_max [rad/s] 低于此值算静
self.declare_parameter('settle_stable_sec', 0.6) # 需要连续静多久
self.declare_parameter('settle_timeout_sec', 8.0) # 等静止的总超时
self.declare_parameter('pin_holds', 2) # 静止后再保持几拍
self.declare_parameter('pin_hold_sec', 0.45) # 每拍多长
self.declare_parameter('skip_unstable_poses', True)
self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp')
self.declare_parameter('result_file', '')
self.declare_parameter('cleanup_images', True)
# 眼在手上:AX=XB 的 gripper 用 Studio 工具 TCP,再折成 cam→法兰写 T_4x4。
# 空 = 旧口径(ee_pose 当 gripper,T_4x4 原样写出)。配了就必须带 TCP。
self.declare_parameter('gripper_tool', '')
self.declare_parameter('gripper_tcp_xyz', [0.0, 0.0, 0.0])
self.declare_parameter('gripper_tcp_qwxyz', [1.0, 0.0, 0.0, 0.0])
prefix = str(self.get_parameter('joint_prefix').value or '')
base = list(
self.get_parameter('joint_names').get_parameter_value().string_array_value
or DEFAULT_JOINTS
)
self.joint_names = [f'{prefix}{n}' for n in base]
self.mode = str(self.get_parameter('mode').value).strip().lower()
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
self.action_name = str(self.get_parameter('action_name').value)
self.move_time_sec = float(self.get_parameter('move_time_sec').value)
self.start_index = max(0, int(self.get_parameter('start_index').value))
self.min_poses = int(self.get_parameter('min_poses').value)
self.cleanup_images = bool(self.get_parameter('cleanup_images').value)
self.gripper_tool = str(self.get_parameter('gripper_tool').value or '').strip()
tcp_xyz = list(
self.get_parameter('gripper_tcp_xyz').get_parameter_value().double_array_value
or [0.0, 0.0, 0.0]
)
tcp_q = list(
self.get_parameter('gripper_tcp_qwxyz').get_parameter_value().double_array_value
or [1.0, 0.0, 0.0, 0.0]
)
self.T_flange_to_tool = T_from_flexiv_tcp(tcp_xyz[:3], tcp_q[:4])
if self.gripper_tool and float(np.linalg.norm(self.T_flange_to_tool[:3, 3])) < 1e-4:
raise RuntimeError(
f'gripper_tool={self.gripper_tool!r} 但 gripper_tcp_xyz 几乎是零:'
'等于没把 Studio TCP 传进来。不许按法兰口径假装在用工具系。'
)
self.camera_source = str(self.get_parameter('camera_source').value).strip().lower()
self.camera_serial = str(self.get_parameter('camera_serial').value or '').strip()
self.camera_namespace = str(
self.get_parameter('camera_namespace').value or ''
).strip()
self.rs_width = int(self.get_parameter('realsense_width').value)
self.rs_height = int(self.get_parameter('realsense_height').value)
self.board = BoardConfig(
cols=int(self.get_parameter('board_cols').value),
rows=int(self.get_parameter('board_rows').value),
square_size=float(self.get_parameter('square_size_m').value),
)
self.stabilizer = ChessboardStabilizer(
window=int(self.get_parameter('stable_window').value),
max_pixel_std=float(self.get_parameter('stable_pixel_std').value),
)
self.stable_timeout = float(self.get_parameter('stable_timeout_sec').value)
self.settle_sec = float(self.get_parameter('settle_sec').value)
self.settle_qd_max = float(self.get_parameter('settle_qd_max').value)
self.settle_stable_sec = float(self.get_parameter('settle_stable_sec').value)
self.settle_timeout = float(self.get_parameter('settle_timeout_sec').value)
self.pin_holds = max(0, int(self.get_parameter('pin_holds').value))
self.pin_hold_sec = max(0.0, float(self.get_parameter('pin_hold_sec').value))
self.skip_unstable = bool(self.get_parameter('skip_unstable_poses').value)
out = Path(str(self.get_parameter('output_dir').value)).expanduser()
self.output_dir = out
self.images_dir = out / 'images'
rf = str(self.get_parameter('result_file').value or '').strip()
self.result_file = Path(rf).expanduser() if rf else (out / 'handeye_result.yaml')
self._client = ActionClient(self, FollowJointTrajectory, self.action_name)
self._get_state = self.create_client(GetArmState, 'get_arm_state')
self._capture_pub = self.create_publisher(String, 'handeye/capture', 10)
self._latest_image: Optional[Image] = None
self._latest_info: Optional[CameraInfo] = None
self._latest_js: Optional[JointState] = None
self._intrinsics: Optional[CameraIntrinsics] = None
img_topic = str(self.get_parameter('image_topic').value or '').strip()
info_topic = str(self.get_parameter('camera_info_topic').value or '').strip()
if is_realsense_source(self.camera_source):
img_topic, info_topic = resolve_realsense_topics(
self.camera_source,
camera_namespace=self.camera_namespace,
image_topic=img_topic,
camera_info_topic=info_topic,
)
self._info_topic = info_topic
self.create_subscription(JointState, '/joint_states', self._on_js, 10)
if not img_topic:
self.get_logger().error(
'image_topic 为空。请设置 camera_source:=d405|d435,或填写 image_topic。'
)
else:
self.create_subscription(
Image, img_topic, self._on_image, qos_profile_sensor_data
)
self.get_logger().info(f'已订阅图像:{img_topic}(BEST_EFFORT)')
if info_topic:
self.create_subscription(
CameraInfo, info_topic, self._on_info, qos_profile_sensor_data
)
self.get_logger().info(f'已订阅 camera_info:{info_topic}(BEST_EFFORT)')
def _on_js(self, msg: JointState) -> None:
self._latest_js = msg
def _on_image(self, msg: Image) -> None:
self._latest_image = msg
def _on_info(self, msg: CameraInfo) -> None:
self._latest_info = msg
def run(self) -> int:
if self.mode not in ('eye_in_hand', 'eye_to_hand'):
self.get_logger().error(
f'未知的 mode={self.mode!r};请使用 eye_in_hand 或 eye_to_hand'
)
return 1
poses = self._load_poses()
if len(poses) < self.min_poses:
self.get_logger().error(
f'只有 {len(poses)} 个已填写位姿(需要 >= {self.min_poses})。'
'示教命令:bash ~/ros2/scripts/run_handeye.sh teach && merge。'
)
return 1
if self.start_index >= len(poses):
self.get_logger().error(
f'start_index {self.start_index} >= 已填写位姿数 {len(poses)}'
)
return 1
if not self._resolve_intrinsics():
return 1
if not self._client.wait_for_server(timeout_sec=15.0):
self.get_logger().error(
f'Action [{self.action_name}] 不可用。'
'请启动:bash ~/ros2/scripts/run_real.sh'
)
return 2
self.output_dir.mkdir(parents=True, exist_ok=True)
self.images_dir.mkdir(parents=True, exist_ok=True)
eye_to_hand = self.mode == 'eye_to_hand'
grip = self.gripper_tool or '(ee_pose 原样,无工具折算)'
self.get_logger().info(
f'手眼标定 [{self.mode}] camera_source={self.camera_source}:'
f'{len(poses)} 个位姿,棋盘={self.board.cols}x{self.board.rows} '
f'方格={self.board.square_size}m,v={self.velocity_scale} '
f'gripper_tool={grip}'
)
self.get_logger().warn('清理工作空间;确认急停就绪。Ctrl+C 可中止。')
Rg: List[np.ndarray] = []
tg: List[np.ndarray] = []
Rc: List[np.ndarray] = []
tc: List[np.ndarray] = []
meta = []
try:
for i in range(self.start_index, len(poses)):
q = poses[i]
self._log_progress(i, len(poses), '移动中...')
code = self._move_one(q, self.move_time_sec)
if code != 0:
return code
if not self._verify_joints(q, tol=0.08):
self.get_logger().error(
f'[{i + 1}] 机械臂未到达目标关节位置 '
'(控制器报告成功,但关节没有变化)。'
'请重启 run_real.sh;示教器切 AUTO Remote;清除故障。'
'不要相信这次标定结果。'
)
return 10
end = time.monotonic() + max(0.0, self.settle_sec)
while rclpy.ok() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.05)
# 固定 settle_sec 之后再看关节速度:真停了才算数,然后 pin 一拍。
self._wait_settled()
self._pin_hold()
ok, std, corners, pattern, bgr = self._wait_stable()
if not ok or corners is None or bgr is None:
msg = f'[{i + 1}] 棋盘不稳定(std={std:.3f})'
if self.skip_unstable:
self.get_logger().warn(f'{msg} —— 跳过')
continue
self.get_logger().error(f'{msg}。中止。')
return 7
ee = self._read_ee_pose()
if ee is None:
self.get_logger().error('读取 ee_pose 失败')
return 8
assert self._intrinsics is not None
R_t2c, t_t2c = solve_board_pose(
corners, self.board, self._intrinsics, pattern=pattern
)
xyz = [ee.pose.position.x, ee.pose.position.y, ee.pose.position.z]
quat = [
ee.pose.orientation.x,
ee.pose.orientation.y,
ee.pose.orientation.z,
ee.pose.orientation.w,
]
R_g2b, t_g2b = pose_to_Rt(xyz, quat)
stamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
img_path = self.images_dir / f'pose_{i:02d}_{stamp}.png'
self._save_png(bgr, img_path)
msg = String()
msg.data = f'capture:{i}:{len(poses)}'
self._capture_pub.publish(msg)
Rg.append(R_g2b)
tg.append(t_g2b)
Rc.append(R_t2c)
tc.append(t_t2c)
meta.append(
{
'index': i,
'joints': list(q),
'ee_xyz': xyz,
'ee_quat_xyzw': quat,
'image': str(img_path),
'corner_std_px': std,
}
)
self.get_logger().info(
f'[{i + 1}/{len(poses)}] 已采集 '
f'(corner_std={std:.3f}px) -> {img_path.name}'
)
except KeyboardInterrupt:
self.get_logger().info('已中断')
return 0
if len(Rg) < self.min_poses:
self.get_logger().error(
f'只采集到 {len(Rg)} 次;求解至少需要 {self.min_poses} 次'
)
return 9
self.get_logger().info(f'用 {len(Rg)} 个视角求解手眼标定...')
R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=eye_to_hand)
extra = ''
if eye_to_hand:
name = 'T_cam_to_base (eye_to_hand)'
yaml_txt = format_transform_yaml(R, t, name)
if self.gripper_tool:
extra = (
f'gripper_tool: {self.gripper_tool}\n'
+ format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool')
)
self.get_logger().info(
f'gripper={self.gripper_tool}:AX=XB 的手是 TCP;'
f'T_4x4 是 cam→基座,不折 t={t.reshape(3)*1000} mm'
)
elif self.gripper_tool:
T_cam_tool = np.eye(4)
T_cam_tool[:3, :3] = R
T_cam_tool[:3, 3] = t.reshape(3)
T_cam_flange = cam_to_flange_via_tool(T_cam_tool, self.T_flange_to_tool)
delta = float(np.linalg.norm(T_cam_flange[:3, 3] - T_cam_tool[:3, 3]))
tool_len = float(np.linalg.norm(self.T_flange_to_tool[:3, 3]))
if tool_len > 0.05 and delta < 1e-4:
self.get_logger().error(
'T_cam_to_flange 与 T_cam_to_tool 平移几乎相同,'
'折算没生效。结果作废。'
)
return 11
# 腕部相机在法兰前方(法兰 +Z)。乘反了 Z 会落到 −200 mm 一带。
if (
self.T_flange_to_tool[2, 3] > 0.05
and float(T_cam_flange[2, 3]) < 0.0
):
self.get_logger().error(
f'T_cam_to_flange tz={float(T_cam_flange[2, 3])*1000:.1f} mm '
'在法兰后方,乘序反了。结果作废。'
)
return 12
R, t = T_cam_flange[:3, :3], T_cam_flange[:3, 3].reshape(3, 1)
name = 'T_cam_to_flange (eye_in_hand,由工具 TCP 折算)'
yaml_txt = format_transform_yaml(R, t, name)
extra = (
f'gripper_tool: {self.gripper_tool}\n'
+ format_matrix_yaml(self.T_flange_to_tool, 'T_flange_to_tool')
+ format_matrix_yaml(T_cam_tool, 'T_cam_to_tool')
)
self.get_logger().info(
f'gripper={self.gripper_tool}:'
f'T_cam_to_tool t={T_cam_tool[:3, 3]*1000} mm → '
f'T_cam_to_flange t={T_cam_flange[:3, 3]*1000} mm'
)
else:
name = 'T_cam_to_gripper (eye_in_hand)'
yaml_txt = format_transform_yaml(R, t, name)
header = (
f'# 手眼标定结果\n'
f'# mode: {self.mode}\n'
f'# camera_source: {self.camera_source}\n'
f'# views: {len(Rg)}\n'
f'# utc: {datetime.now(timezone.utc).isoformat()}\n'
f'# board: {self.board.cols}x{self.board.rows} '
f'square_m={self.board.square_size}\n'
)
if self.gripper_tool and eye_to_hand:
header += (
f'# gripper_tool: {self.gripper_tool}\n'
f'# T_4x4 = cam→基座(AX=XB 的手是 TCP,不折到法兰)\n'
f'# 旧结果备份在 results/backup/\n'
)
elif self.gripper_tool:
header += (
f'# gripper_tool: {self.gripper_tool}\n'
f'# T_4x4 = cam→法兰(AX=XB 在 TCP 上解,再左乘 T_flange_to_tool)\n'
f'# T_cam_to_tool 是原始求解;旧的法兰/tool1 备份在 results/backup/\n'
)
self.result_file.parent.mkdir(parents=True, exist_ok=True)
self.result_file.write_text(header + extra + yaml_txt, encoding='utf-8')
(self.output_dir / 'captures.json').write_text(
json.dumps(meta, indent=2), encoding='utf-8'
)
self.get_logger().info('======== 手眼标定结果 ========')
for line in (header + yaml_txt).strip().splitlines():
self.get_logger().info(line)
self.get_logger().info(f'已保存 -> {self.result_file}')
if self.cleanup_images:
if self.images_dir.exists():
shutil.rmtree(self.images_dir, ignore_errors=True)
self.get_logger().info('标定图像已删除')
else:
self.get_logger().info(f'图像保留在 {self.images_dir}')
return 0
def _load_poses(self) -> List[List[float]]:
flat = list(self.get_parameter('waypoint_positions').value)
n_j = len(self.joint_names)
n_cfg = int(self.get_parameter('num_poses').value)
if n_cfg < 1:
return []
need = n_cfg * n_j
if len(flat) < need:
self.get_logger().warn(
f'waypoint_positions 长度 {len(flat)} < num_poses*{n_j}={need};'
'用零补齐'
)
flat = flat + [0.0] * (need - len(flat))
poses: List[List[float]] = []
for i in range(n_cfg):
q = [float(v) for v in flat[i * n_j : (i + 1) * n_j]]
if is_pose_filled(q):
poses.append(q)
else:
self.get_logger().warn(f'位姿槽 {i} 为空(全零)—— 已跳过')
return poses
def _resolve_intrinsics(self) -> bool:
# 1) CameraInfo
deadline = time.monotonic() + 3.0
while rclpy.ok() and self._latest_info is None and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.1)
if self._latest_info is not None:
self._intrinsics = _camera_info_to_intrinsics(self._latest_info)
K = self._intrinsics.K
self.get_logger().info(
f'内参来自 CameraInfo({self._info_topic}):'
f'fx={K[0, 0]:.2f} fy={K[1, 1]:.2f} cx={K[0, 2]:.2f} cy={K[1, 2]:.2f}'
)
return True
# 2) RealSense EEPROM
if is_realsense_source(self.camera_source):
try:
intr = query_realsense_color_intrinsics(
self.camera_source,
serial=self.camera_serial,
width=self.rs_width,
height=self.rs_height,
)
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'RealSense 内参查询失败:{exc}')
else:
self._intrinsics = CameraIntrinsics(K=intr.as_K(), dist=intr.as_dist())
self.get_logger().info(
f'内参来自 RealSense {intr.model.upper()} '
f'serial={intr.serial} {intr.width}x{intr.height}:'
f'fx={intr.fx:.2f} fy={intr.fy:.2f} '
f'cx={intr.cx:.2f} cy={intr.cy:.2f}'
)
if (intr.width, intr.height) != (self.rs_width, self.rs_height):
self.get_logger().warn(
f'请求 {self.rs_width}x{self.rs_height},'
f'实际得到 {intr.width}x{intr.height}(最接近的 profile)'
)
return True
# 3) Manual YAML
fx = float(self.get_parameter('camera_fx').value)
fy = float(self.get_parameter('camera_fy').value)
cx = float(self.get_parameter('camera_cx').value)
cy = float(self.get_parameter('camera_cy').value)
if fx <= 0 or fy <= 0:
self.get_logger().error(
'没有内参。请设置 camera_source:=d405|d435,'
'或提供 camera_info_topic,或填写 camera_fx/fy/cx/cy。'
)
return False
dist = np.array(
list(self.get_parameter('camera_dist').value), dtype=float
).reshape(-1, 1)
K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=float)
self._intrinsics = CameraIntrinsics(K=K, dist=dist)
self.get_logger().info(
f'内参来自 YAML:fx={fx:.2f} fy={fy:.2f} cx={cx:.2f} cy={cy:.2f}'
)
return True
def _log_progress(self, i: int, total: int, suffix: str = '') -> None:
"""带进度条的日志。
自动标定是无人值守的长流程(十几个点位 × 移动+稳定+拍照),
单看 `[3/12]` 很难有"还要多久"的直觉,加一条可视化进度条。
"""
frac = (i + 1) / max(1, total)
width = 24
filled = int(round(frac * width))
bar = '█' * filled + '░' * (width - filled)
self.get_logger().info(
f'[{i + 1}/{total}] [{bar}] {frac * 100:5.1f}% {suffix}'
)
def _wait_settled(self) -> bool:
"""等机械臂真的停下来,再进入拍照。
判据是关节速度 |qd|_max 连续 ``settle_stable_sec`` 低于 ``settle_qd_max``。
为什么不用固定 sleep:`settle_sec` 是拍脑袋的时长,臂负载/姿态不同,
刹车距离差别很大 —— 等太短会在臂还在动时拍照(棋盘虚 → 角点抖动),
等太长则整轮标定被无谓拉长。直接看速度更准。
超时不算致命:记一条警告后继续,让后面的棋盘稳定性检查去把关。
"""
if self.settle_qd_max <= 0.0:
return True
deadline = time.monotonic() + self.settle_timeout
stable_since: Optional[float] = None
while rclpy.ok() and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.05)
js = self._latest_js
if js is None or not list(js.velocity):
continue
try:
vmax = float(np.max(np.abs(np.asarray(js.velocity, dtype=float))))
except Exception: # noqa: BLE001
continue
now = time.monotonic()
if vmax <= self.settle_qd_max:
if stable_since is None:
stable_since = now
elif now - stable_since >= self.settle_stable_sec:
return True
else:
stable_since = None # 又动了,重新计时
self.get_logger().warn(
f'wait_settled 超时 ({self.settle_timeout:.1f}s),臂可能仍在微动;'
'交给棋盘稳定性检查兜底'
)
return False
def _pin_hold(self) -> None:
"""静止后再保持几拍,抑制到位后的微小回弹/漂移。
语义取自 site.yaml 的注释"零速 pin 后再拍照":不是重新规划轨迹,
而是纯粹等一段时间让机械臂自己稳下来(控制器在到位后仍会做少量补偿)。
"""
total = self.pin_holds * self.pin_hold_sec
end = time.monotonic() + total
while rclpy.ok() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.05)
def _wait_stable(
self,
) -> Tuple[bool, float, Optional[np.ndarray], Optional[np.ndarray]]:
self.stabilizer.reset()
deadline = time.monotonic() + self.stable_timeout
last_std = float('inf')
last_corners = None
last_pattern = None
last_bgr = None
last_log = 0.0
while rclpy.ok() and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.05)
if self._latest_image is None:
continue
try:
bgr = _image_to_bgr(self._latest_image)
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'图像转换失败:{exc}')
return False, last_std, None, None, None
detected = detect_chessboard(bgr, self.board)
corners = detected[0] if detected is not None else None
pattern = detected[1] if detected is not None else None
ok, std = self.stabilizer.push(corners)
last_std = std
if corners is not None:
last_corners = corners
last_pattern = pattern
last_bgr = bgr
now = time.monotonic()
if now - last_log > 1.0:
seen = '有' if corners is not None else '无'
self.get_logger().info(
f' 棋盘={seen} 角点标准差='
f'{std if std < 1e8 else float("nan"):.3f}px'
)
last_log = now
if ok:
return True, std, last_corners, last_pattern, last_bgr
return False, last_std, last_corners, last_pattern, last_bgr
def _verify_joints(self, target: List[float], tol: float = 0.08) -> bool:
"""确认 ``joint_states`` 真的走到了目标位置。
用来识破"假的成功":有些执行接口会立刻返回成功,但关节其实没动
(或只动了一半)。这里在 2 秒内轮询关节角,逐轴比对容差 ``tol``(弧度),
只有全部落在容差内才算真到位。
"""
deadline = time.monotonic() + 2.0
while rclpy.ok() and time.monotonic() < deadline:
rclpy.spin_once(self, timeout_sec=0.05)
if self._latest_js is None:
continue
name_to_q = dict(zip(list(self._latest_js.name), list(self._latest_js.position)))
actual = []
for n in self.joint_names:
if n not in name_to_q:
break
actual.append(float(name_to_q[n]))
if len(actual) != len(target):
continue
err = float(np.sqrt(np.mean([(a - b) ** 2 for a, b in zip(actual, target)])))
self.get_logger().info(
f' 关节相对目标的 RMSE:{err:.4f} rad(tol={tol})'
)
return err <= tol
self.get_logger().error('校验运动时没有 joint_states')
return False
def _move_one(self, positions: List[float], duration: float) -> int:
goal = FollowJointTrajectory.Goal()
goal.joint_names = self.joint_names
goal.num_points = 1
goal.velocity_scale = self.velocity_scale
goal.positions = list(positions)
goal.time_from_start = [float(duration)]
send = self._client.send_goal_async(goal)
if not self._wait_future(send, 10.0, '发送目标'):
return 3
handle = send.result()
if handle is None or not handle.accepted:
self.get_logger().error('目标被拒绝')
return 4
res_fut = handle.get_result_async()
if not self._wait_future(res_fut, max(60.0, duration * 3.0), '结果'):
return 5
result = res_fut.result().result
if not result.success:
self.get_logger().error(f'移动失败:{result.message}')
return 6
return 0
def _wait_future(self, fut, timeout: float, label: str) -> bool:
deadline = time.monotonic() + timeout
while rclpy.ok() and not fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error(f'等待 {label} 超时')
return False
return fut.done()
def _read_ee_pose(self) -> Optional[PoseStamped]:
if not self._get_state.wait_for_service(timeout_sec=2.0):
return None
fut = self._get_state.call_async(GetArmState.Request())
if not self._wait_future(fut, 2.0, 'get_arm_state'):
return None
res = fut.result()
if res is None or not res.success:
return None
return res.ee_pose
def _save_png(self, bgr: np.ndarray, path: Path) -> None:
import cv2
if not cv2.imwrite(str(path), bgr):
raise RuntimeError(f'cv2.imwrite 失败:{path}')
def main(args=None) -> None:
rclpy.init(args=args)
node = HandeyeCalibrate()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,668 @@
#!/usr/bin/env python3
"""手眼示教:实时预览 + Free Drive + 按 Enter 拍照/记录关节。
两种后端
------------
ros(默认,整套栈正在运行):
- 关节来自 ``/get_arm_state``
- 拖拽:示教器 Free Drive + 按住使能键
需保持:bash ~/ros2/scripts/run_real.sh
rdk(软件 Free Drive):
- 先停掉 run_real.sh(RDK 独占)
- 按 'd' 通过 RDK 启用 FloatingJoint 拖拽
- 关节来自 RDK 的 robot.states
source ~/ros2/scripts/env.sh
ros2 run arm_application handeye_teach --ros-args -p backend:=rdk
界面按键(预览窗口)
Enter / Space 拍照 + 记录关节
d 启用 Free Drive(rdk)/ 显示示教器提示(ros)
s 停止 Free Drive(rdk)/ 保持
q / Esc 结束
"""
from __future__ import annotations
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
import numpy as np
import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
from arm_application.ros_node import TolerantNode, param_as_str
HELP = """
================================================
手眼示教 TEACH:预览 -> 拖拽 -> 拍照+位姿
================================================
窗口按键:
Enter / Space = 拍照 + 保存关节
d = 开启 Free Drive
s = 关闭 Free Drive / 保持
q / Esc = 结束
提示:让棋盘清晰,并占到画面的约 30-70%
建议采 8-12 个角度有变化的位姿
================================================
"""
def _setup_matplotlib_font():
"""避免中文乱码:优先选用存在的中日韩(CJK)字体。"""
import matplotlib
matplotlib.use('QtAgg')
from matplotlib import font_manager, rcParams
candidates = [
'Noto Sans CJK SC',
'Noto Sans CJK JP',
'Noto Serif CJK SC',
'AR PL UMing CN',
'AR PL UKai CN',
'WenQuanYi Micro Hei',
'Source Han Sans CN',
'DejaVu Sans',
]
available = {f.name for f in font_manager.fontManager.ttflist}
chosen = None
for name in candidates:
if name in available:
chosen = name
break
if chosen is None:
# 按文件路径再试一次
for path in (
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-DemiLight.ttc',
'/usr/share/fonts/truetype/arphic/uming.ttc',
):
if os.path.isfile(path):
font_manager.fontManager.addfont(path)
prop = font_manager.FontProperties(fname=path)
chosen = prop.get_name()
break
if chosen:
rcParams['font.family'] = chosen
rcParams['axes.unicode_minus'] = False
return chosen
class RdkBridge:
"""通过 rdk_py310 子进程(Python 3.10 ABI)与 Flexiv RDK 通信。"""
def __init__(self, robot_sn: str, logger=None) -> None:
import json
import subprocess
self._json = json
self._logger = logger
self.robot_sn = robot_sn
self._last_q: Optional[List[float]] = None
self._io = threading.Lock()
py = os.environ.get('RDK_PYTHON', '/home/cc/rdk_py310/bin/python')
bridge = Path.home() / 'ros2' / 'scripts' / 'rdk_drag_bridge.py'
if not bridge.is_file():
bridge = Path('/home/cc/ros2/scripts/rdk_drag_bridge.py')
if not bridge.is_file():
raise FileNotFoundError(f'找不到 rdk_drag_bridge.py:{bridge}')
# 只保留 Flexiv 的库 —— 不要继承 conda 的 LD_LIBRARY_PATH(会让 RDK 卡死)。
flexiv_lib = os.path.expanduser('~/flexiv_install/lib')
env = {
'HOME': os.environ.get('HOME', ''),
'PATH': f'{Path(py).parent}:/usr/bin:/bin',
'LD_LIBRARY_PATH': flexiv_lib,
'LANG': 'C.UTF-8',
'SITE_PC_IP': os.environ.get('SITE_PC_IP', ''),
'SITE_NET_IFACE': os.environ.get('SITE_NET_IFACE', ''),
}
# 不要继承 conda 的 PYTHONPATH。
# 重要:stderr 不能用 PIPE —— RDK 日志会填满管道并让桥接进程死锁
# (stdout 永远不返回)。改成写入日志文件。
log_path = Path('/tmp/rdk_drag_bridge.log')
self._err_file = open(log_path, 'w', encoding='utf-8')
self._proc = subprocess.Popen(
[py, str(bridge)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=self._err_file,
text=True,
bufsize=1,
env=env,
)
if logger:
logger.info(f'RDK 桥接已启动;日志 → {log_path}')
pong = self._ask({'cmd': 'ping'}, timeout=8.0)
if not pong.get('ok'):
raise RuntimeError(
f'RDK 桥接 ping 失败:{pong}。参见 {log_path}'
)
if logger:
logger.info('RDK 桥接 ping 正常,正在连接机器人...')
conn = self._ask(
{
'cmd': 'connect',
'sn': robot_sn,
'iface': os.environ.get('SITE_NET_IFACE', ''),
},
timeout=45.0,
)
if not conn.get('ok'):
raise RuntimeError(
f'RDK 连接失败:{conn.get("error")}。'
f'请先停掉 run_real.sh;切 AUTO Remote 并清除故障。参见 {log_path}'
)
if logger:
logger.info(f'RDK 桥接就绪(py={pong.get("py")})sn={robot_sn}')
q0 = conn.get('q') or []
if len(q0) >= 7:
self._last_q = [float(v) for v in q0[:7]]
def _ask(self, msg: dict, timeout: float = 10.0) -> dict:
import select
# 同一时刻只允许一条在途命令:相机/GUI 绝不能共用这个管道。
if not self._io.acquire(timeout=max(timeout, 0.2)):
return {'ok': False, 'error': '桥接忙'}
try:
assert self._proc.stdin and self._proc.stdout
self._proc.stdin.write(self._json.dumps(msg) + '\n')
self._proc.stdin.flush()
end = time.monotonic() + timeout
while time.monotonic() < end:
if self._proc.poll() is not None:
tip = '/tmp/rdk_drag_bridge.log'
return {
'ok': False,
'error': f'桥接已退出 rc={self._proc.returncode};参见 {tip}',
}
ready, _, _ = select.select([self._proc.stdout], [], [], 0.05)
if not ready:
continue
line = self._proc.stdout.readline()
if not line:
continue
line = line.strip()
if not line.startswith('{'):
continue
try:
return self._json.loads(line)
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': f'应答异常:{exc} {line!r}'}
return {'ok': False, 'error': '桥接超时'}
finally:
self._io.release()
def drag_on(self, primitive: str = 'FloatingJoint', mode: str = 'soft') -> dict:
return self._ask(
{'cmd': 'drag_on', 'primitive': primitive, 'mode': mode},
timeout=20.0,
)
def drag_off(self) -> dict:
return self._ask({'cmd': 'drag_off'}, timeout=6.0)
def joints(self) -> Optional[List[float]]:
res = self._ask({'cmd': 'joints'}, timeout=2.0)
if res.get('ok'):
q = res.get('q') or []
if len(q) >= 7:
self._last_q = [float(v) for v in q[:7]]
return self._last_q
return self._last_q
def close(self) -> None:
try:
self._ask({'cmd': 'quit'}, timeout=3.0)
except Exception:
pass
try:
self._proc.terminate()
except Exception:
pass
try:
if getattr(self, '_err_file', None) is not None:
self._err_file.close()
except Exception:
pass
class HandeyeTeach(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_teach')
self.declare_parameter('output_dir', '/home/cc/ros2/handeye/tmp')
self.declare_parameter('width', 1280)
self.declare_parameter('height', 720)
self.declare_parameter('fps', 15)
self.declare_parameter('backend', 'ros') # ros | rdk
self.declare_parameter('robot_sn', os.environ.get('SITE_ROBOT_SN', '').strip())
self.declare_parameter('drag_primitive', 'FloatingJoint') # or FloatingCartesian
self._serial = param_as_str(self, 'camera_serial', '')
self.declare_parameter('auto_exposure', True)
self.declare_parameter('ae_limit_us', 1800.0)
self.declare_parameter('emitter_enabled', False)
self.declare_parameter('exposure_us', 0.0)
self.declare_parameter('gain', 0.0)
# 目标采集点数。0 = 不限(按 q 结束)。
# 设了就只是个"提醒":采够时提示可以收工,不强制关窗 ——
# 常常采到一半发现某个角度还没覆盖,强制退出反而要重开。
self.declare_parameter('target_points', 0)
# 必须和 run_handeye.sh 的 TAUGHT 是同一个文件。只写 taught_poses.txt、
# 脚本去读 taught_poses_<模式>.txt,merge 就会「文件在、内容空」。
self.declare_parameter('poses_file', '')
self.backend = str(self.get_parameter('backend').value).strip().lower()
self.robot_sn = str(self.get_parameter('robot_sn').value)
self.drag_primitive = str(self.get_parameter('drag_primitive').value).strip()
self.output_dir = Path(str(self.get_parameter('output_dir').value)).expanduser()
self.images_dir = self.output_dir / 'images'
_pf = str(self.get_parameter('poses_file').value).strip()
self.poses_file = (
Path(_pf).expanduser() if _pf else self.output_dir / 'taught_poses.txt'
)
self.yaml_file = self.output_dir / 'taught_waypoint_positions.yaml'
self._last_capture_t = 0.0
self.images_dir.mkdir(parents=True, exist_ok=True)
self._captures: List[dict] = []
self._latest_bgr: Optional[np.ndarray] = None
self._frame_lock = threading.Lock()
self._quit = False
self._busy = False
self._cmd_pending = False
self._drag_on = False
self._robot = None
self._rdk = None
self._get_state = None
self._cam_thread: Optional[threading.Thread] = None
self._pipe = None
self._fps = 15
if self.backend == 'rdk':
self._init_rdk()
else:
self._init_ros_state()
self._start_camera()
font = _setup_matplotlib_font()
self._start_gui()
print(HELP)
if self.backend == 'rdk':
print('[backend=rdk] 按 d = 软拖拽(在 AUTO Remote 下可用,无需 Floating 许可)')
print(' run_real.sh 必须已停止。')
print(' 拖着就能按 Enter 拍照,不必先按 s')
else:
print('[backend=ros] 用示教器 Free Drive + 按住使能键来拖拽。')
print(' 或停掉 run_real.sh 后改用:-p backend:=rdk')
if font:
self.get_logger().info(f'界面字体:{font}')
self.create_timer(1.0 / max(1, self._fps), self._tick)
def _init_ros_state(self) -> None:
from arm_msgs.srv import GetArmState
self._get_state = self.create_client(GetArmState, 'get_arm_state')
if not self._get_state.wait_for_service(timeout_sec=10.0):
raise RuntimeError(
'get_arm_state 不可用。请启动 run_real.sh,'
'或改用:-p backend:=rdk(需先停掉整套栈)'
)
def _init_rdk(self) -> None:
self.get_logger().info(f'正在把 RDK 桥接连到 {self.robot_sn} ...')
self._rdk = RdkBridge(self.robot_sn, logger=self.get_logger())
self._robot = True # 标记:RDK 后端已就绪
self.get_logger().info('RDK 已连接。按 d 启用 Free Drive。')
def _start_camera(self) -> None:
import pyrealsense2 as rs
w = int(self.get_parameter('width').value)
h = int(self.get_parameter('height').value)
fps = int(self.get_parameter('fps').value)
pipe = rs.pipeline()
profile = None
last_err = None
for ww, hh, ff in [
(w, h, fps), (w, h, 15), (w, h, 10),
(640, 480, 30), (640, 480, 15),
]:
try:
cfg = rs.config()
if self._serial:
cfg.enable_device(self._serial)
cfg.enable_stream(rs.stream.color, ww, hh, rs.format.bgr8, ff)
profile = pipe.start(cfg)
self._fps = ff
self.get_logger().info(f'相机 {ww}x{hh}@{ff}Hz')
break
except Exception as exc: # noqa: BLE001
last_err = exc
try:
pipe.stop()
except Exception: # noqa: BLE001
pass
pipe = rs.pipeline()
if profile is None:
raise RuntimeError(
f'无法打开 D405 —— 请先停掉 realsense_color_pub。({last_err})'
)
self._pipe = pipe
try:
from arm_application.handeye_realsense import apply_realsense_exposure
apply_realsense_exposure(
profile.get_device(),
auto_exposure=bool(self.get_parameter('auto_exposure').value),
ae_limit_us=float(self.get_parameter('ae_limit_us').value),
emitter_enabled=bool(self.get_parameter('emitter_enabled').value),
exposure_us=(
float(self.get_parameter('exposure_us').value) or None
),
gain=(float(self.get_parameter('gain').value) or None),
log=self.get_logger().info,
)
except Exception as exc: # noqa: BLE001
self.get_logger().warn(f'曝光设置应用失败:{exc}')
self._cam_thread = threading.Thread(target=self._cam_loop, name='d405', daemon=True)
self._cam_thread.start()
def _cam_loop(self) -> None:
while not self._quit:
try:
frames = self._pipe.wait_for_frames(timeout_ms=400)
color = frames.get_color_frame()
except Exception: # noqa: BLE001
time.sleep(0.02)
continue
if not color:
continue
bgr = np.asanyarray(color.get_data()).copy()
with self._frame_lock:
self._latest_bgr = bgr
def _start_gui(self) -> None:
import matplotlib.pyplot as plt
self._plt = plt
self._fig, self._ax = plt.subplots(1, 1, figsize=(11, 7))
self._fig.canvas.manager.set_window_title(
'handeye_teach | Enter=拍照 d=拖拽 s=停止 q=退出'
)
self._im = None
self._status = self._ax.text(
0.02,
0.98,
self._status_text(),
transform=self._ax.transAxes,
color='lime',
fontsize=13,
va='top',
fontweight='bold',
bbox=dict(boxstyle='round', facecolor='black', alpha=0.6),
)
self._ax.axis('off')
self._fig.canvas.mpl_connect('key_press_event', self._on_key)
plt.ion()
plt.show(block=False)
def _status_text(self) -> str:
drag = '拖拽开启' if self._drag_on else '拖拽关闭'
if self.backend == 'rdk':
tip = 'd=软拖拽 s=保持'
else:
tip = '示教器 FreeDrive+使能'
return (
f'已采集:{len(self._captures)} [{drag}] '
f'{tip} | Enter=拍照 q=退出'
)
def _on_key(self, event) -> None:
key = (event.key or '').lower()
if key in ('q', 'escape'):
self._quit = True
self.get_logger().info('收到退出请求')
return
if self._cmd_pending:
return
if key in ('enter', ' ', 'space', 'd', 's'):
self._cmd_pending = True
threading.Thread(target=self._run_key, args=(key,), daemon=True).start()
def _run_key(self, key: str) -> None:
try:
if key in ('enter', ' ', 'space'):
self._capture_once()
elif key == 'd':
self._enable_drag()
elif key == 's':
self._disable_drag()
except Exception as exc: # noqa: BLE001
self.get_logger().error(f'按键 {key}:{exc}')
finally:
self._cmd_pending = False
def _enable_drag(self) -> None:
if self.backend != 'rdk':
self._drag_on = True
self.get_logger().warn(
'ROS 后端:请用示教器的 Free Drive + 按住使能键。'
'想用软件拖拽:先停掉 run_real.sh,然后执行 '
'ros2 run arm_application handeye_teach --ros-args -p backend:=rdk'
)
self._status.set_text(self._status_text())
return
self.get_logger().info('正在启动软拖拽(预览保持实时刷新)...')
# soft = AUTO Remote 模式下的关节阻抗拖拽(不需要 Floating 许可)
res = self._rdk.drag_on(self.drag_primitive, mode='soft')
if res.get('ok'):
self._drag_on = True
self.get_logger().info(
f'拖拽已开启 mode={res.get("mode")} —— 推着机械臂走,Enter=拍照(s=保持,可选)'
)
if res.get('hint'):
self.get_logger().info(str(res.get('hint')))
else:
self._drag_on = False
self.get_logger().error(f'拖拽失败:{res.get("error")}')
# 状态文字由 GUI 线程里的 _tick 刷新
def _disable_drag(self) -> None:
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
res = self._rdk.drag_off()
if not res.get('ok'):
self.get_logger().warn(f'停止拖拽:{res.get("error")}')
self._drag_on = False
self.get_logger().info('Free Drive 已关闭 / 保持')
def _tick(self) -> None:
if self._quit:
return
with self._frame_lock:
bgr = None if self._latest_bgr is None else self._latest_bgr.copy()
if bgr is None:
try:
self._fig.canvas.flush_events()
except Exception: # noqa: BLE001
pass
return
rgb = bgr[:, :, ::-1]
if self._im is None:
self._im = self._ax.imshow(rgb)
else:
self._im.set_data(rgb)
try:
self._status.set_text(self._status_text())
self._fig.canvas.draw_idle()
self._fig.canvas.flush_events()
except Exception: # noqa: BLE001
pass
def _read_joints(self) -> Optional[List[float]]:
if self.backend == 'rdk' and getattr(self, '_rdk', None) is not None:
q = self._rdk.joints()
if q is None:
self.get_logger().error('读取 RDK 关节失败')
return q
from arm_msgs.srv import GetArmState
fut = self._get_state.call_async(GetArmState.Request())
end = time.monotonic() + 2.0
while rclpy.ok() and not fut.done() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.02)
res = fut.result() if fut.done() else None
if res is None or not res.success:
return None
q = list(res.joint_positions)
return [float(v) for v in q[:7]] if len(q) >= 7 else None
def _capture_once(self) -> None:
if self._busy:
return
self._busy = True
try:
with self._frame_lock:
bgr = None if self._latest_bgr is None else self._latest_bgr.copy()
if bgr is None:
self.get_logger().warn('还没有相机帧')
return
q = self._read_joints()
if q is None:
self.get_logger().error('读取关节失败')
return
now = time.monotonic()
if now - self._last_capture_t < 0.8:
self.get_logger().warn('按太快,忽略(防 Enter 连发)')
return
if self._captures:
last = self._captures[-1]['joints']
delta = max(abs(a - b) for a, b in zip(q, last))
if delta < 0.015:
self.get_logger().warn(
f'关节几乎没动(Δ={delta:.4f} rad),忽略这次拍照。'
'先拖到另一个姿态再按 Enter。'
)
return
self._last_capture_t = now
idx = len(self._captures)
stamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
img_path = self.images_dir / f'teach_{idx:02d}_{stamp}.png'
try:
import cv2
cv2.imwrite(str(img_path), bgr)
except Exception: # noqa: BLE001
from PIL import Image as PILImage
PILImage.fromarray(bgr[:, :, ::-1]).save(str(img_path))
line = ', '.join(f'{v:.6f}' for v in q) + ','
self._captures.append(
{'index': idx, 'joints': q, 'image': str(img_path), 'line': line}
)
with self.poses_file.open('a', encoding='utf-8') as f:
f.write(f'# pose {idx} {stamp}\n')
f.write(line + '\n')
f.write(f'# image {img_path}\n')
print(f'\n# pose {idx} {img_path.name}')
print(line)
n = idx + 1
try:
target = int(self.get_parameter('target_points').value)
except Exception: # noqa: BLE001
target = 0
if target > 0:
if n < target:
self.get_logger().info(
f'[{n}/{target}] 已保存照片+关节 —— 还差 {target - n} 个点'
)
elif n == target:
self.get_logger().info(
f'[{n}/{target}] 已采够 {target} 个点。'
' 建议按 q 结束,然后跑 merge + calibrate;'
' 想多采几个点也可以继续按 Enter。'
)
else:
self.get_logger().info(
f'[{n}/{target}] 已保存照片+关节 —— 已超出目标 '
f'{n - target} 个点(会用全部点求解)'
)
else:
self.get_logger().info(f'[{n}] 已保存照片+关节')
self._write_yaml_snippet()
finally:
self._busy = False
def _write_yaml_snippet(self) -> None:
lines = [
'# 粘贴到 handeye_calibrate.yaml -> waypoint_positions',
f'# num_poses: {len(self._captures)}',
'waypoint_positions: [',
]
for c in self._captures:
lines.append(f' # pose {c["index"]}')
lines.append(f' {c["line"]}')
lines.append(']')
self.yaml_file.write_text('\n'.join(lines) + '\n', encoding='utf-8')
def finish(self) -> None:
self._quit = True
if self._cam_thread is not None:
self._cam_thread.join(timeout=1.5)
if self._drag_on:
self._disable_drag()
if getattr(self, '_rdk', None) is not None:
try:
self._rdk.close()
except Exception:
pass
n = len(self._captures)
print('\n========== 示教完成 ==========')
print(f'位姿: {n}')
print(f'关节: {self.poses_file}')
print(f'yaml: {self.yaml_file}')
print(f'图像: {self.images_dir}')
if n >= 1:
print('\n--- 粘贴到 handeye_calibrate.yaml ---\n')
print(self.yaml_file.read_text(encoding='utf-8'))
print('同时设置 num_poses:', n)
if n < 5:
self.get_logger().warn(f'只有 {n} 个位姿;至少需要 5 个(建议 8-12 个)。')
def main(args=None) -> None:
rclpy.init(args=args)
node = None
try:
node = HandeyeTeach()
while rclpy.ok() and not node._quit:
rclpy.spin_once(node, timeout_sec=0.05)
except SystemExit:
pass
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.finish()
try:
node._pipe.stop()
except Exception: # noqa: BLE001
pass
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
@@ -0,0 +1,525 @@
#!/usr/bin/env python3
"""手眼标定路点执行器。
逐个移动到每个关节路点,等到位后停留一段时间,
让相机(或操作者)采集一张标定图像。
capture_mode:
auto — 停留 dwell_sec 后进入下一个位姿
keypress — 在终端按 Enter 继续
preview — OpenCV 实时相机 + Enter/Space 确认(示教时推荐)
处于 ``preview`` 模式时,可在预览窗口打开期间用示教器的 **Free Drive**
拖拽微调(ros2_control 保持 AUTO Remote;拖拽经由示教器,而非 RDK)。
"""
from __future__ import annotations
import csv
import array
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
import numpy as np
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
from std_msgs.msg import String
from arm_application.handeye_realsense import (
is_realsense_source,
resolve_realsense_topics,
)
from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_msgs.srv import GetArmState
from arm_application.ros_node import TolerantNode
try:
import cv2
except Exception: # noqa: BLE001
cv2 = None # type: ignore
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
DEFAULT_TIMES = [4.0] * 12
DEFAULT_POSITIONS = [
0.00, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
0.35, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
-0.35, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
0.00, -0.90, 0.00, 1.70, 0.00, 0.85, 0.00,
0.00, -0.50, 0.00, 1.40, 0.00, 0.55, 0.00,
0.20, -0.70, 0.15, 1.57, 0.40, 0.70, 0.30,
-0.20, -0.70, -0.15, 1.57, -0.40, 0.70, -0.30,
0.25, -0.80, 0.10, 1.65, 0.25, 0.90, -0.40,
-0.25, -0.80, -0.10, 1.65, -0.25, 0.90, 0.40,
0.15, -0.60, 0.20, 1.50, -0.35, 0.60, 0.50,
-0.15, -0.60, -0.20, 1.50, 0.35, 0.60, -0.50,
0.00, -0.70, 0.00, 1.57, 0.00, 0.70, 0.00,
]
PREVIEW_HELP_GUI = """
=== 位姿预览 / 拖拽调整(OpenCV 窗口)===
1. 示教器:Free Drive + 按住使能键拖拽微调
2. 预览窗口:确认棋盘在画面内且清晰
3. 键盘:Enter / Space = 下一个位姿;q / Esc = 退出
"""
PREVIEW_HELP_HEADLESS = """
=== 位姿预览 / 拖拽调整(无 OpenCV 窗口)===
1. 另开终端查看画面:
ros2 run rqt_image_view rqt_image_view /handeye/preview_image
2. 示教器:Free Drive + 按住使能键拖拽微调
3. 本终端:Enter = 确认下一个位姿;输入 q 回车 = 退出
"""
def _cv_gui_available() -> bool:
if cv2 is None:
return False
try:
cv2.namedWindow('_handeye_gui_probe', cv2.WINDOW_NORMAL)
cv2.destroyWindow('_handeye_gui_probe')
return True
except Exception: # noqa: BLE001
return False
def _image_to_bgr(msg: Image) -> np.ndarray:
h, w = int(msg.height), int(msg.width)
enc = (msg.encoding or '').lower()
data = np.frombuffer(msg.data, dtype=np.uint8)
if enc in ('bgr8', 'rgb8'):
img = data.reshape(h, w, 3)
return img[:, :, ::-1].copy() if enc == 'rgb8' else img
if enc in ('mono8', '8uc1'):
gray = data.reshape(h, w)
return np.stack([gray, gray, gray], axis=-1)
if enc in ('bgra8', 'rgba8'):
img = data.reshape(h, w, 4)
bgr = img[:, :, :3]
return bgr[:, :, ::-1].copy() if enc == 'rgba8' else bgr
raise RuntimeError(f'不支持的图像编码:{msg.encoding!r}')
class HandeyeWaypoints(TolerantNode):
def __init__(self) -> None:
super().__init__('handeye_waypoints')
self.declare_parameter('joint_prefix', joint_prefix_default())
self.declare_parameter('joint_names', DEFAULT_JOINTS)
self.declare_parameter('velocity_scale', 0.12)
self.declare_parameter('action_name', 'follow_joint_trajectory')
self.declare_parameter('waypoint_times', DEFAULT_TIMES)
self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS)
self.declare_parameter('dwell_sec', 2.5)
# auto | keypress | preview
self.declare_parameter('capture_mode', 'auto')
self.declare_parameter('start_index', 0)
self.declare_parameter('log_csv', '')
self.declare_parameter('capture_topic', 'handeye/capture')
self.declare_parameter('camera_source', 'd405')
self.declare_parameter('camera_namespace', '')
self.declare_parameter('image_topic', '')
self.declare_parameter('preview_window', True)
self.declare_parameter('append_poses_file', '/tmp/handeye_taught_poses.txt')
prefix = str(self.get_parameter('joint_prefix').value or '')
base = list(
self.get_parameter('joint_names').get_parameter_value().string_array_value
or DEFAULT_JOINTS
)
self.joint_names = [f'{prefix}{n}' for n in base]
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
self.action_name = str(self.get_parameter('action_name').value)
self.dwell_sec = float(self.get_parameter('dwell_sec').value)
self.capture_mode = str(self.get_parameter('capture_mode').value).strip().lower()
self.start_index = max(0, int(self.get_parameter('start_index').value))
self.log_csv = str(self.get_parameter('log_csv').value or '').strip()
topic = str(self.get_parameter('capture_topic').value)
self.preview_window = bool(self.get_parameter('preview_window').value)
self.append_poses_file = str(
self.get_parameter('append_poses_file').value or ''
).strip()
cam_src = str(self.get_parameter('camera_source').value).strip().lower()
img_topic = str(self.get_parameter('image_topic').value or '').strip()
ns = str(self.get_parameter('camera_namespace').value or '').strip()
if is_realsense_source(cam_src) and not img_topic:
img_topic, _ = resolve_realsense_topics(
cam_src, camera_namespace=ns, image_topic='', camera_info_topic=''
)
self.image_topic = img_topic
self._client = ActionClient(self, FollowJointTrajectory, self.action_name)
self._get_state = self.create_client(GetArmState, 'get_arm_state')
self._capture_pub = self.create_publisher(String, topic, 10)
self._preview_pub = self.create_publisher(Image, 'handeye/preview_image', 10)
self._latest_bgr: Optional[np.ndarray] = None
self._cv_gui = _cv_gui_available() and self.preview_window
if self.capture_mode == 'preview' and not self._cv_gui:
self.get_logger().warn(
'OpenCV 没有 GUI(无头环境)。请在 /handeye/preview_image 上预览,'
'在本终端按 Enter 确认。'
)
if self.image_topic and self.capture_mode == 'preview':
self.create_subscription(
Image, self.image_topic, self._on_image, qos_profile_sensor_data
)
self.get_logger().info(f'预览图像:{self.image_topic}')
def _on_image(self, msg: Image) -> None:
try:
self._latest_bgr = _image_to_bgr(msg)
except Exception: # noqa: BLE001
pass
def run(self) -> int:
if self.capture_mode == 'preview':
if cv2 is None:
self.get_logger().error('preview 模式需要 OpenCV(cv2)')
return 1
if not self.image_topic:
self.get_logger().error('preview 模式需要 image_topic 或 camera_source')
return 1
times = list(self.get_parameter('waypoint_times').value)
positions = list(self.get_parameter('waypoint_positions').value)
n_j = len(self.joint_names)
n_pts = len(times)
if n_pts < 1:
self.get_logger().error('waypoint_times 为空')
return 1
if len(positions) != n_pts * n_j:
self.get_logger().error(
f'waypoint_positions 长度 {len(positions)} != {n_pts}*{n_j}'
)
return 1
if self.start_index >= n_pts:
self.get_logger().error(
f'start_index {self.start_index} >= 路点总数 {n_pts}'
)
return 1
if not self._client.wait_for_server(timeout_sec=15.0):
self.get_logger().error(
f'Action [{self.action_name}] 不可用。'
'请先启动整套栈:bash scripts/run_real.sh'
)
return 2
csv_file = None
csv_writer: Optional[csv.writer] = None
if self.log_csv:
path = Path(self.log_csv).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
csv_file = path.open('w', newline='', encoding='utf-8')
csv_writer = csv.writer(csv_file)
csv_writer.writerow(
['index', 'utc_iso', 'move_duration_s']
+ [f'target_{n}' for n in self.joint_names]
+ [f'measured_{n}' for n in self.joint_names]
)
self.get_logger().info(f'正在记录采集数据 → {path}')
if self.capture_mode == 'preview':
print(PREVIEW_HELP_GUI if self._cv_gui else PREVIEW_HELP_HEADLESS)
self.get_logger().info(
f'手眼标定:{n_pts} 个位姿,start={self.start_index},'
f'v_scale={self.velocity_scale},dwell={self.dwell_sec}s,'
f'mode={self.capture_mode}'
)
self.get_logger().warn(
'清理工作空间;让棋盘保持在视野内。Ctrl+C 可中止。'
)
win = 'handeye_preview'
try:
for i in range(self.start_index, n_pts):
q = positions[i * n_j : (i + 1) * n_j]
move_t = max(1.0, float(times[i]))
self.get_logger().info(
f'[{i + 1}/{n_pts}] 移动中(约 {move_t:.1f}s)...'
)
code = self._move_one(q, move_t)
if code != 0:
return code
measured = self._read_joints()
self._announce_capture(i, n_pts, q, measured)
if csv_writer is not None and csv_file is not None:
csv_writer.writerow(
[i, datetime.now(timezone.utc).isoformat(), move_t]
+ list(q)
+ list(measured if measured else [float('nan')] * n_j)
)
csv_file.flush()
if not self._wait_capture(i, n_pts):
self.get_logger().info('用户已中止')
return 0
# 在 preview 模式下确认后,保存示教关节
if self.capture_mode == 'preview':
taught = self._read_joints()
if taught:
self._save_taught_pose(i, taught)
finally:
if csv_file is not None:
csv_file.close()
if cv2 is not None:
try:
cv2.destroyWindow(win)
except Exception: # noqa: BLE001
pass
self.get_logger().info('手眼标定路点序列执行完毕')
if self.append_poses_file and self.capture_mode == 'preview':
self.get_logger().info(
f'示教位姿已保存 → {self.append_poses_file}'
)
return 0
def _save_taught_pose(self, index: int, joints: List[float]) -> None:
line = ', '.join(f'{v:.6f}' for v in joints[:7]) + ','
print(f'# pose {index}')
print(line)
if not self.append_poses_file:
return
path = Path(self.append_poses_file).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open('a', encoding='utf-8') as f:
f.write(f'# pose {index}\n')
f.write(line + '\n')
self.get_logger().info(f'已保存位姿 {index} → {path}')
def _move_one(self, positions: List[float], duration: float) -> int:
goal = FollowJointTrajectory.Goal()
goal.joint_names = self.joint_names
goal.num_points = 1
goal.velocity_scale = self.velocity_scale
goal.positions = list(positions)
goal.time_from_start = [float(duration)]
send = self._client.send_goal_async(goal)
if not self._wait_future(send, 10.0, '发送目标'):
return 3
handle = send.result()
if handle is None or not handle.accepted:
self.get_logger().error('目标被拒绝')
return 4
res_fut = handle.get_result_async()
if not self._wait_future(res_fut, max(60.0, duration * 3.0), '结果'):
return 5
result = res_fut.result().result
if not result.success:
self.get_logger().error(f'移动失败:{result.message}')
return 6
return 0
def _wait_future(self, fut, timeout: float, label: str) -> bool:
deadline = time.monotonic() + timeout
while rclpy.ok() and not fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error(f'等待 {label} 超时')
return False
return fut.done()
def _read_joints(self) -> Optional[List[float]]:
if not self._get_state.wait_for_service(timeout_sec=0.5):
return None
fut = self._get_state.call_async(GetArmState.Request())
if not self._wait_future(fut, 2.0, 'get_arm_state'):
return None
res = fut.result()
if res is None or not res.success:
return None
return list(res.joint_positions)
def _announce_capture(
self,
index: int,
n_pts: int,
target: List[float],
measured: Optional[List[float]],
) -> None:
msg = String()
msg.data = f'capture:{index}:{n_pts}'
self._capture_pub.publish(msg)
q_str = ', '.join(f'{v:.4f}' for v in (measured or target))
if self.capture_mode == 'preview':
self.get_logger().info(
f'[{index + 1}/{n_pts}] 已到位 —— 用 Free Drive 调整,然后按 Enter/Space'
)
else:
self.get_logger().info(
f'[{index + 1}/{n_pts}] 已到位 —— 现在采集 q=[{q_str}]'
)
def _wait_capture(self, index: int, n_pts: int) -> bool:
if self.capture_mode == 'preview':
return self._wait_preview(index, n_pts)
if self.capture_mode in ('key', 'keypress', 'manual'):
self.get_logger().info(
'采集完成后按 Enter(q+Enter 退出)...'
)
for _ in range(5):
rclpy.spin_once(self, timeout_sec=0.05)
line = sys.stdin.readline()
if not line or line.strip().lower() in ('q', 'quit', 'exit'):
return False
return True
end = time.monotonic() + max(0.0, self.dwell_sec)
while rclpy.ok() and time.monotonic() < end:
rclpy.spin_once(self, timeout_sec=0.05)
return rclpy.ok()
def _annotate_preview(
self, bgr: np.ndarray, index: int, n_pts: int
) -> np.ndarray:
vis = bgr.copy()
if cv2 is None:
return vis
cv2.putText(
vis,
f'位姿 {index + 1}/{n_pts}',
(12, 28),
cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(0, 255, 0),
2,
)
cv2.putText(
vis,
'Free Drive 拖拽 | Enter=下一个 q=退出',
(12, 56),
cv2.FONT_HERSHEY_SIMPLEX,
0.6,
(0, 255, 255),
2,
)
return vis
def _publish_preview(self, bgr: np.ndarray) -> None:
msg = Image()
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = 'camera'
msg.height, msg.width = bgr.shape[0], bgr.shape[1]
msg.encoding = 'bgr8'
msg.is_bigendian = 0
msg.step = msg.width * 3
msg.data = array.array('B', bgr.tobytes()) # 快速路径;用 bytes 会多花约 40 ms
self._preview_pub.publish(msg)
def _wait_preview(self, index: int, n_pts: int) -> bool:
if self._cv_gui:
return self._wait_preview_opencv(index, n_pts)
return self._wait_preview_terminal(index, n_pts)
def _wait_preview_terminal(self, index: int, n_pts: int) -> bool:
confirmed = threading.Event()
quit_req = threading.Event()
def _stdin_reader() -> None:
try:
line = sys.stdin.readline()
except Exception: # noqa: BLE001
quit_req.set()
return
if not line or line.strip().lower() in ('q', 'quit', 'exit'):
quit_req.set()
else:
confirmed.set()
self.get_logger().info(
f'[{index + 1}/{n_pts}] 用 Free Drive 调整,然后按 Enter '
'(q+Enter 退出)'
)
reader = threading.Thread(target=_stdin_reader, daemon=True)
reader.start()
no_frame_warn = False
while rclpy.ok():
rclpy.spin_once(self, timeout_sec=0.05)
if confirmed.is_set():
return True
if quit_req.is_set():
return False
if not reader.is_alive() and not confirmed.is_set() and not quit_req.is_set():
return False
bgr = self._latest_bgr
if bgr is None:
if not no_frame_warn:
self.get_logger().warn(
f'正在等待 {self.image_topic} ...',
throttle_duration_sec=3.0,
)
no_frame_warn = True
continue
vis = self._annotate_preview(bgr, index, n_pts)
self._publish_preview(vis)
return False
def _wait_preview_opencv(self, index: int, n_pts: int) -> bool:
assert cv2 is not None
win = 'handeye_preview'
cv2.namedWindow(win, cv2.WINDOW_NORMAL)
no_frame_warn = False
while rclpy.ok():
rclpy.spin_once(self, timeout_sec=0.02)
bgr = self._latest_bgr
if bgr is None:
if not no_frame_warn:
self.get_logger().warn(
f'正在等待 {self.image_topic} ...',
throttle_duration_sec=3.0,
)
no_frame_warn = True
key = cv2.waitKey(30) & 0xFF
else:
vis = self._annotate_preview(bgr, index, n_pts)
self._publish_preview(vis)
cv2.imshow(win, vis)
key = cv2.waitKey(30) & 0xFF
if key in (13, 10, 32):
return True
if key in (27, ord('q'), ord('Q')):
return False
return False
def main(args=None) -> None:
rclpy.init(args=args)
node = HandeyeWaypoints()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""``arm_msgs/action/MoveToPose``(笛卡尔目标)的示例客户端。
等你有了 MoveIt 或自己的 IK 求解 action server 后,把它接上去即可。
"""
from __future__ import annotations
import sys
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from geometry_msgs.msg import PoseStamped
from arm_msgs.action import MoveToPose
from arm_application.ros_node import TolerantNode
class MoveToPoseClient(TolerantNode):
def __init__(self) -> None:
super().__init__('move_to_pose_client')
self._client = ActionClient(self, MoveToPose, 'move_to_pose')
def send(self, x: float, y: float, z: float) -> None:
if not self._client.wait_for_server(timeout_sec=5.0):
self.get_logger().error(
'move_to_pose action 不可用。'
'请启动一个实现了它的笛卡尔规划器(例如 MoveIt)。'
)
return
goal = MoveToPose.Goal()
goal.target_pose = PoseStamped()
goal.target_pose.header.frame_id = 'base_link'
goal.target_pose.header.stamp = self.get_clock().now().to_msg()
goal.target_pose.pose.position.x = x
goal.target_pose.pose.position.y = y
goal.target_pose.pose.position.z = z
# 单位姿态(不旋转)
goal.target_pose.pose.orientation.w = 1.0
goal.velocity_scale = 0.3
goal.acceleration_scale = 0.3
goal.avoid_collisions = True
self.get_logger().info(f'发送笛卡尔目标:({x:.3f}, {y:.3f}, {z:.3f})')
future = self._client.send_goal_async(goal)
rclpy.spin_until_future_complete(self, future)
handle = future.result()
if handle is None or not handle.accepted:
self.get_logger().error('目标被拒绝')
return
result_future = handle.get_result_async()
rclpy.spin_until_future_complete(self, result_future)
result = result_future.result().result
self.get_logger().info(f'success={result.success} msg={result.message}')
def main(args=None) -> None:
rclpy.init(args=args)
node = MoveToPoseClient()
# 默认演示位姿;可通过命令行覆盖:x y z
vals = [0.35, 0.0, 0.35]
if len(sys.argv) >= 4:
vals = [float(sys.argv[1]), float(sys.argv[2]), float(sys.argv[3])]
node.send(*vals)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
@@ -45,6 +45,11 @@ from arm_application.path_profile import ( # 伺服和测试从本模块再导
# 上一跳实际少走这么多,就认定冻结 J 在当前姿态走不动,不许按同一剩差再刷。
D435_MIN_PROGRESS_FRAC = 0.40
# SwitchMode 返回不等于已经进 primitive。连试仍失败必须让一阶段立刻中止,
# 不许空等 150s 装成"还在粗定位"(2026-10-16 17:27)。
D435_MOVEL_RETRY = 3
D435_MOVEL_RETRY_S = 0.3
D435_MOVEL_GAVE_UP = "厂家 MoveL 连试 3 次没走成"
def best_det_conf(dets: Optional[Iterable]) -> Optional[float]:
@@ -154,16 +159,27 @@ def d405_commands_arm(
) -> bool:
"""D405 这一帧的点能不能下发给左臂。
推理一直开着。正在用 D435 那一个点时,进程内 MoveL 结束并且停稳之前不下发。
停稳用 ``d435_handback_sec`` 和 ``lock_still_move_m``。赶路时 XY 已经进 15 mm
也不算到位。不许在 MoveL 期间下发 D405(两套控制互抢会晃)。
``move_only``:只测 D435 位移,D405 点一律不下发(到位之后也不交)。
推理从启动就开着。没有高置信度目标、或 D435 厂家 MoveL 还没结束、
法兰还没连续停稳时,这帧**不下发**(输出截断,不许拿去控臂)。
厂家移动结束并且法兰停稳(``settled``,时长是 ``d435_handback_sec``)
之后才放行,伺服用它追工件框的矩形中心。框心对准停稳后才进精拍下臂。
``move_only``:只测 D435 位移,D405 点一律不下发。
"""
if move_only:
return False
if not using_d435:
return True
if using_d435:
return bool(arrived) and bool(settled)
return True
def retract_goal_z(z_now: float, z_target: float) -> float:
"""收臂目标 Z:已经高于目标就钉在当前高度,不许为了追 5 mm 往下补。
2026-10-16:贴面 MoveL 之后 JTC 还握着悬停关节,收臂 hop1 计划 +5 mm
实际抬了 18–25 mm;hop2 把「抬过头」当成误差 ``Δz=−13 mm`` 往回压,
看起来就是进孔再弹出。抬过了 = 成功。
"""
return max(float(z_now), float(z_target))
def oneshot_needs_verticalize(
@@ -625,6 +641,24 @@ def d435_movel_from_tcp(tcp_xyz, flange_xy, k0_xy):
return out
def movel_tcp_z_only(tcp_xyz, delta_z_m: float):
"""厂家 MoveL 目标:当前 TCP 的 XY 原样,只加 ``delta_z_m``。
一段直线。WORLD 绝对坐标不能另算,只能在厂家 TCP 上加这一段。
``delta_z_m`` 为正是上升,为负是下降。接近 0 或非有限数返回 None。
"""
try:
t = np.asarray(tcp_xyz, dtype=float).reshape(3)
dz = float(delta_z_m)
except (TypeError, ValueError):
return None
if t.size != 3 or not np.all(np.isfinite(t)) or not np.isfinite(dz):
return None
if abs(dz) < 1e-6:
return None
return np.array([float(t[0]), float(t[1]), float(t[2]) + dz], dtype=float)
def once_ff_movel_from_tcp(tcp_xyz, dxy, descent_m: float):
"""厂家 MoveL 目标 = 当前 Studio TCP + (ΔXY, −下降) [m]。
@@ -672,10 +706,60 @@ def hole_pair_span_ok(span_m: float) -> bool:
def flange_z_for_gap_m(tip_len_m: float, surface_z_m: float, gap_m: float) -> float:
"""规尖离面 ``gap_m`` 时的法兰 Z [m]。与轻降几何同一条式子。"""
"""规尖离面 ``gap_m`` 时的法兰 Z [m]。与轻降几何同一条式子。
``gap_m`` 为正是停在面上;为负是规尖进入孔口(上表面以下)。
"""
return float(tip_len_m) + float(surface_z_m) + float(gap_m)
# 工件板厚 12mm。进孔超过这个值会朝下表面穿,直接拒绝,不改成更浅再走。
HOLE_ENTRY_MAX_M = 0.008
def insert_target_gap_m(final_gap_m: float, hole_entry_m: float) -> float:
"""插孔停在哪 [m]。``hole_entry_m`` > 0 时规尖在工件上表面以下这么深;否则停在面上 ``final_gap_m``。"""
entry = float(hole_entry_m)
if entry > 1e-9:
return -entry
return float(final_gap_m)
def hole_entry_refusal(
entry_m: float,
tool_len_m: float,
surface_z_m: float,
z_min_m: float,
z_max_m: float,
) -> str:
"""进孔深度能不能用。空串表示可以(含 0 = 不进孔)。非空就是拒绝原因。"""
entry = float(entry_m)
if not np.isfinite(entry):
return "进孔深度不是有限数,拒绝开跑"
if entry < -1e-9:
return f"进孔深度 {entry * 1000:.1f}mm < 0,拒绝开跑"
if entry <= 1e-9:
return ""
if entry > HOLE_ENTRY_MAX_M + 1e-9:
return (
f"进孔深度 {entry * 1000:.1f}mm 超过 {HOLE_ENTRY_MAX_M * 1000:.0f}mm"
"(板厚 12mm,不许穿出下表面),拒绝开跑"
)
tool = float(tool_len_m)
surf = float(surface_z_m)
if tool <= 0.0 or surf <= 0.0:
return "写了进孔深度,但工具长度或工件面未知,无法确认尖端进了多深,拒绝开跑"
flange = flange_z_for_gap_m(tool, surf, -entry)
z_lo = float(z_min_m)
z_hi = float(z_max_m)
if flange < z_lo - 1e-6 or flange > z_hi + 1e-6:
return (
f"进孔法兰 Z {flange:.3f}m(面上再进 {entry * 1000:.1f}mm)"
f"超出工作盒 Z [{z_lo:.3f}, {z_hi:.3f}],拒绝开跑"
)
return ""
def final_gap_refusal(
gap_m: float,
tool_len_m: float,
@@ -105,7 +105,7 @@ class PerceptionNode(TolerantNode):
self.declare_parameter("image_topic", "")
self.declare_parameter("camera_info_topic", "")
self.declare_parameter("depth_topic", "/d405/aligned_depth_to_color/image_raw")
self.declare_parameter("weights", "models/yolo/gongjian0929.onnx")
self.declare_parameter("weights", "/home/cc/ros2/models/yolo/gongjian0929.onnx")
self.declare_parameter("conf", 0.10)
self.declare_parameter("iou", 0.45)
self.declare_parameter("imgsz", 640)
@@ -336,9 +336,7 @@ class PerceptionNode(TolerantNode):
r_pos=float(self.get_parameter("kf_r_pos").value),
)
from arm_application.site_io import resolve_repo_path
weights_raw = resolve_repo_path(str(self.get_parameter("weights").value))
weights_raw = str(self.get_parameter("weights").value)
weights = str(resolve_weights(weights_raw, self.backend_name))
yolo_cfg = {
"weights": weights,
@@ -1141,8 +1139,8 @@ class PerceptionNode(TolerantNode):
filt[2] = z_use
self._last_filt = filt.copy()
if handoff:
# 画面里没有 conf≥门槛的目标:绿框可以留,三维点不许发 —— 伺服会拿去追。
if handoff or self._d435_commanding:
# 没有高置信度,或 D435 厂家移动还没交接:推理留着,控制点不许发。
return
self._publish_point(filt, stamp)
self.get_logger().info(
@@ -1427,15 +1425,11 @@ class PerceptionNode(TolerantNode):
self._z_locked = bool(msg.data)
def _on_d435_commanding(self, msg: Bool) -> None:
"""伺服还在执行 D435 那一个点时,不再提交下一张。
"""伺服还在执行 D435 那一个点时,不再提交下一张,D405 控制点也不发。
这一段结束(到位并停稳)后清掉「已拍过」,漏检重新计 0.5 s。
这一轮已经拍过并走完。停稳后打开 D405 框心,不因交接瞬间漏检再 MoveL。
"""
commanding = bool(msg.data)
if self._d435_commanding and not commanding:
self._oneshot_shot = False
self._low_since = None
self._d435_commanding = commanding
self._d435_commanding = bool(msg.data)
def _publish_overhead_k0(self, p_base: np.ndarray, is_k0: bool) -> None:
pt = PointStamped()
@@ -1,27 +1,39 @@
#!/usr/bin/env python3
"""跟踪锁定 → 孔定位。搜孔轻压已取消,本节点不再下压搜孔。
"""三阶段串联:视觉跟踪锁定 → 孔定位 → 搜孔并轻轻按压(默认不拧入)。
流畅性(相对 bash 杀进程再起节点):
- 相机 / 触觉 / 手眼 TF 全程只起一次
- 一阶段锁定后 SIGTERM 跟踪节点,立刻 pin 当前关节,不回零、不抬臂
- 二阶段已垂直 / 已在悬停高则跳过对应动作;对准完就停,不再下压搜孔
- 旋入不在这个节点里
- 二阶段已垂直 / 已在悬停高则跳过对应动作;对准完立刻下压搜孔
- 找到孔位后力闭环轻压保持;拧入是后续阶段(--depth 才插入)
"""
from __future__ import annotations
import json
import math
import os
import subprocess
import time
from typing import Optional
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
import rclpy
from geometry_msgs.msg import PointStamped
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from std_msgs.msg import Bool, String
from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy
from std_msgs.msg import Bool, Float32MultiArray, String
from trajectory_msgs.msg import JointTrajectory
from arm_application.gauge_gripper import states_frame_online
from arm_application.gauge_kin import (
a7_limit_target,
match_limits,
parse_joint_limits,
)
from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP, movel_tcp_z_only
from arm_application.site_io import load_site_dict, ros2_root
from arm_application.stage2_locate_node import _pt
from arm_application.stage3_search_node import Stage3Search
@@ -37,6 +49,99 @@ from arm_application.stage3_search_node import Stage3Search
TRACK_CLEARANCE_M = 0.05
#: 悬停时规尖离面已经不少于这个值 [m],一阶段就锁在悬停高,对孔完成后再下降。
TRACK_LOCK_GAP_M = 0.040
#: ``fill_nrt_limits`` 的关节速度上限 [rad/s]。再快的指令会被钳住,时间到了关节还没到。
NRT_VMAX_RAD_S = 0.90
#: 两点、两端速度为 0 的三次样条,峰速是平均速度的 1.5 倍。
CUBIC_PEAK_OVER_AVG = 1.5
#: 空回按这个角速度请求。真正排程用 :func:`a7_move_duration_s`,峰速不超过上面的上限。
A7_MAX_SPEED_DEG_S = math.degrees(NRT_VMAX_RAD_S)
def a7_move_duration_s(delta_rad: float, speed_deg_s: float) -> float:
"""A7 这一段该排多长 [s]。
平均速度按请求来。峰速若会超过 ``NRT_VMAX_RAD_S``,就把平均速度降到上限的
1/1.5,让样条峰速刚好顶到上限。2026-10-19 空回按 52°/s 排 6.6s,峰速被钳住,
到位时还差 35°。
"""
dist = abs(float(delta_rad))
v_req = math.radians(max(float(speed_deg_s), 1.0))
v_avg = min(v_req, NRT_VMAX_RAD_S / CUBIC_PEAK_OVER_AVG)
if v_avg <= 1e-6:
return 0.8
return max(0.8, dist / v_avg)
# 合爪位置命令是 0。和 ``pos`` 的到位带同一宽度。
CLOSE_POS_TOL = 40.0
#: 首轮咬合结束之后,第三步整段重复的次数。
#:
#: 一轮 = 张开到 ``unwind_open_permille``、微抬 ``unwind_lift_mm``、逆时针到限位、
#: 按本轮实测上抬量降回、合爪、顺时针到限位。抬和降都在轮内,不许拿到循环外面
#: 只做一次。固定 4,不进 site.yaml:这是工序次数,不是现场标定值。
UNWIND_ROUNDS = 4
def unwind_round_label(index: int, total: int = UNWIND_ROUNDS) -> str:
"""第几轮的日志前缀。``index`` 从 1 计到 ``total``。
越界直接抛错。打出「第 0/4 轮」或「第 5/4 轮」会让人以为流程多走或少走了。
"""
n = int(total)
i = int(index)
if n != UNWIND_ROUNDS or not 1 <= i <= n:
raise ValueError(
f"空回轮次只能是 1–{UNWIND_ROUNDS}(固定 {UNWIND_ROUNDS} 轮),"
f"收到 index={index} total={total}"
)
return f"空回第{i}/{n}轮"
def close_ready(grip: int, pos: float, tol: float = CLOSE_POS_TOL) -> bool:
"""合爪之后能不能接着顺时针转 A7。
大寰 ``0x0201``:力先到是 2(夹住),位置先到命令值是 1(到位)。
这几次合到 0 时规还在两指间,位置先到,固件写 1,只认 2 就永远不转。
手指已经在合拢端(``pos`` 不超过 ``tol``)就继续。还停在张开位则不转。
"""
g = int(grip)
if g == 2:
return True
return g == 1 and float(pos) <= float(tol)
def gripper_wait_timeout_reason(
action: str,
timeout_s: float,
last: Optional[Tuple[int, float]],
*,
saw_any: bool,
saw_offline: bool,
) -> str:
"""等夹爪状态超时后,到底该报哪一句。
2026-10-20 三连实测都卡在这里:读 ``0x0201`` 一连 264 次无应答,节点
不再发状态,流水线把「总线读不到」显示成「夹爪没到位」,而臂停在原地
没做微抬。三种情况必须分开说,否则查的方向完全相反:
* ``last`` 有值 → 真收到过有效帧,就是没到位;
* 只有离线帧 → 总线读不回来,不是没张开;
* 一帧都没有 → 节点没在发(崩了 / 没起 / 话题名不对)。
"""
if last is not None:
return (
f"夹爪 {action} 后 {timeout_s:.0f}s 未到位"
f"(grip={last[0]} pos={last[1]:.0f}),A7 不再转"
)
if saw_offline:
return (
f"夹爪 {action} 后 {timeout_s:.0f}s 读不到状态(/gripper/states "
"只有离线帧,Modbus 无应答),不是夹爪没张开。A7 不再转"
)
return (
f"夹爪 {action} 后 {timeout_s:.0f}s 一帧 /gripper/states 都没收到"
f"(节点没在发,saw_any={saw_any}),A7 不再转"
)
def init_z_refusal(
@@ -69,6 +174,19 @@ def init_z_refusal(
return ""
def hold_at_insert_z(final, *, skip3: bool) -> bool:
"""贴面已经按 ``final_gap_mm`` 停下时,不再进三阶段。
三阶段开局会抬到悬高上方测触觉,再落回 ``hole_hover_z_m``。
没有接触传感器。到位高度就是这次的贴面间隙,不另设一个 Z。
"""
if skip3:
return True
if not isinstance(final, dict):
return False
return bool(final.get("insert_held"))
def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float:
"""一阶段锁高。
@@ -84,6 +202,20 @@ def track_lock_z(hover_z: float, tool_len_m: float, surface_z_m: float) -> float
return hover + TRACK_CLEARANCE_M
def stage1_log_fatal(log_path: str) -> str:
"""一阶段日志里已经写明失败时,不许再空等锁定。空串 = 还没到这一步。"""
try:
text = Path(log_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
if D435_MOVEL_GAVE_UP in text:
return (
f"D435 {D435_MOVEL_GAVE_UP}(RDK 控制模式没切到 primitive),"
f"一阶段中止。不是视野/标定。看 {log_path}"
)
return ""
def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) -> str:
"""一阶段超时了,到底是"感知没给点"还是"给了点但没收敛"。
@@ -98,6 +230,9 @@ def stage1_timeout_reason(points_seen: int, timeout_s: float, log_path: str) ->
以前两种情况共用一句"没锁定(视野里没工件?)",于是"perception_node
因为缺依赖早退了"被读成"算法不收敛",白查半天。
"""
fatal = stage1_log_fatal(log_path)
if fatal:
return fatal
if points_seen <= 0:
return (
f"一阶段 {timeout_s:.0f}s 没锁定:**一个视觉目标点都没收到**"
@@ -122,16 +257,34 @@ class AssemblyPipeline(Stage3Search):
# 在图上必须是另一个节点名(否则 ros2 node list 里两个同名的节点,
# 出问题时连"是哪条流程在发指令"都分不清)。
super().__init__(node_name=node_name)
self.declare_parameter("run_a7_after_insert", True)
self.declare_parameter("skip_stage1", False)
self.declare_parameter("skip_stage2", False)
self.declare_parameter("skip_stage3", True) # 搜孔轻压已取消,写成 false 也不再下压
self.declare_parameter("skip_stage3", False) # 默认搜孔+轻压;拧入靠 --depth
self.declare_parameter("d435_move_only", False)
self.declare_parameter("init_z_m", -1.0) # >0:启动后先到这个法兰高度
self.declare_parameter("stage1_timeout_sec", 150.0)
self.declare_parameter("completion_linger_sec", 15.0)
self.completion_linger = float(self.get_parameter("completion_linger_sec").value)
if not 0. <= self.completion_linger <= 60.:
raise ValueError("completion_linger_sec must be in 0..60")
self.linger3 = 1.2 # 串联结束短保持;单独跑 stage3 仍用 site linger_sec
self._s1: Optional[subprocess.Popen] = None
self._locked = False
self._robot_description: Optional[str] = None
# robot_state_publisher 用 TRANSIENT_LOCAL 只发一次。默认 VOLATILE
# 订阅在它之后才起来,3s 内一条都收不到,A7 会因“没有限位”拒绝旋转。
self.create_subscription(
String,
"/robot_description",
self._on_pipeline_robot_description,
QoSProfile(
depth=1,
durability=DurabilityPolicy.TRANSIENT_LOCAL,
reliability=ReliabilityPolicy.RELIABLE,
),
)
self.create_subscription(Bool, "/visual_servo/locked", self._on_lock, 10)
# 诊断用:一阶段超时时靠它区分"感知没给点"和"给了点没收敛"。
# 只计数,不参与控制 —— 它绝不能影响流程本身的判断。
@@ -175,6 +328,376 @@ class AssemblyPipeline(Stage3Search):
self._state_pub.publish(msg)
self._pipe_pub.publish(msg)
def _on_pipeline_robot_description(self, msg: String) -> None:
self._robot_description = str(msg.data)
def _a7_limits(self, timeout_s: float = 3.0) -> Optional[Tuple[float, float]]:
"""A7 软件限位。来自 ``/robot_description`` 的 joint7,与厂家 ``q_min/q_max`` 同一对。"""
t0 = time.monotonic()
while self._robot_description is None and time.monotonic() - t0 < timeout_s:
self._spin(0.05)
if not self._robot_description:
self.get_logger().error("拿不到 /robot_description,A7 没有限位,拒绝旋转")
return None
got = match_limits(
parse_joint_limits(self._robot_description), [self.joint_names[-1]]
)
if got is None:
self.get_logger().error(
f"/robot_description 里没有 {self.joint_names[-1]} 的限位,拒绝旋转"
)
return got
def _send_a7_only(self, a7_rad: float, speed_deg_s: float, tag: str) -> bool:
"""只下发 A7。前六轴用这一拍的当前角,厂家 ``SendJointPosition`` 收到的也是这一组。"""
q, Tee = self._wait_state(5.0)
if q is None or Tee is None or int(q.size) != 7:
self.get_logger().error(f"{tag}: 拿不到 7 轴关节,不下发")
return False
z0 = float(Tee[2, 3])
q_hold = q.copy()
q_hold[6] = float(a7_rad)
delta = float(q_hold[6] - q[6])
dur = a7_move_duration_s(delta, speed_deg_s)
avg_deg_s = abs(math.degrees(delta)) / dur if dur > 1e-6 else 0.0
zeros = np.zeros(q.size)
traj = JointTrajectory()
traj.header.stamp.sec = 0
traj.header.stamp.nanosec = 0
traj.joint_names = list(self.joint_names)
traj.points = [_pt(q, zeros, 0.2), _pt(q_hold, zeros, 0.2 + dur)]
self.get_logger().info(
f"{tag}: A7 {math.degrees(float(q[6])):+.1f}° → "
f"{math.degrees(float(q_hold[6])):+.1f}°,"
f"请求 {speed_deg_s:.0f}°/s,样条平均 {avg_deg_s:.0f}°/s、{dur:.1f}s"
f"(峰速不超过 {NRT_VMAX_RAD_S:.2f} rad/s),前六轴不动,法兰 Z={z0:.4f}m"
)
if not self._send_action(traj, wait_s=dur + 8.0):
return False
q2, Tee2 = self._wait_state(5.0)
if q2 is None or Tee2 is None:
self.get_logger().error(f"{tag}: 走完读不到关节")
return False
others = float(np.max(np.abs(q2[:6] - q[:6])))
dz = abs(float(Tee2[2, 3]) - z0)
a7_err = abs(float(q2[6] - q_hold[6]))
self.get_logger().info(
f"{tag}: 前六轴最大偏差 {others:.4f} rad,"
f"法兰 ΔZ={dz * 1000:.1f}mm,A7 残差 {math.degrees(a7_err):.1f}°"
)
if others > 0.02:
self.get_logger().error(f"{tag}: 前六轴动了 {others:.3f} rad,中止")
return False
if dz > 0.003:
self.get_logger().error(f"{tag}: 法兰 Z 变了 {dz * 1000:.1f}mm,中止")
return False
if a7_err > 0.05:
self.get_logger().error(
f"{tag}: A7 还差 {math.degrees(a7_err):.1f}°,没到限位内侧"
)
return False
self._pin(q2)
return True
def _shift_flange_z(self, delta_m: float, tag: str) -> Optional[float]:
"""厂家 MoveL 一段,只改 TCP 的 Z。返回实测 ΔZ [m];失败返回 None。
不走关节小跳。姿态用当前 TCP 欧拉角,XY 用当前厂家 TCP,不加别的位移。
"""
self._spin(0.05)
tcp0 = self._fresh_tcp_world()
q, Tee = self._wait_state(2.0)
if tcp0 is None or q is None or Tee is None:
self.get_logger().error(f"{tag}: 没有厂家 TCP,拒绝用关节小跳凑 Z")
return None
target = movel_tcp_z_only(tcp0, delta_m)
if target is None:
self.get_logger().error(f"{tag}: MoveL 的 Z 目标算不出")
return None
fx0, fy0, fz0 = float(Tee[0, 3]), float(Tee[1, 3]), float(Tee[2, 3])
self.get_logger().info(
f"{tag}: 厂家 MoveL 一段 TCP "
f"({float(tcp0[0]):.4f},{float(tcp0[1]):.4f},{float(tcp0[2]):.4f})"
f" → ({target[0]:.4f},{target[1]:.4f},{target[2]:.4f}) "
f"只改 Z {float(delta_m) * 1000:+.2f}mm,vel=10mm/s,"
f"过渡区 ZFine,到位等级 1"
)
# 厂家默认 Z50(50mm)和等级 3 会在还差约 0.5mm 时就报完成。
ok = self._vendor_movel_tcp(target, 0.010, zone="ZFine", toler=1)
q2, Tee2 = self._wait_state(0.5)
self._spin(0.05)
tcp1 = self._fresh_tcp_world()
if not ok or q2 is None or Tee2 is None or tcp1 is None:
self.get_logger().error(f"{tag}: 厂家 MoveL 没完成,中止")
return None
tcp_xy = math.hypot(float(tcp1[0]) - float(tcp0[0]), float(tcp1[1]) - float(tcp0[1]))
dz = float(tcp1[2]) - float(tcp0[2])
fl_xy = math.hypot(float(Tee2[0, 3]) - fx0, float(Tee2[1, 3]) - fy0)
fl_dz = float(Tee2[2, 3]) - fz0
self.get_logger().info(
f"{tag}: 厂家TCP ΔXY={tcp_xy * 1000:.2f}mm ΔZ={dz * 1000:+.2f}mm;"
f"法兰 ΔXY={fl_xy * 1000:.2f}mm ΔZ={fl_dz * 1000:+.2f}mm"
)
if tcp_xy > 0.001 or fl_xy > 0.001:
self.get_logger().error(
f"{tag}: XY 动了 TCP {tcp_xy * 1000:.2f}mm / 法兰 {fl_xy * 1000:.2f}mm,中止"
)
return None
self._pin(q2)
return dz
def _arm_short_id(self) -> str:
"""当前臂号。与 ``export_site_env.arm_short_id`` 一样,取 SN 末两位。"""
short = os.environ.get("SITE_ROBOT_SHORT_ID", "").strip()
if short:
return short
sn = str(
self.get_parameter("robot_sn").value
or os.environ.get("SITE_ROBOT_SN")
or ""
).strip()
tail = sn.rsplit("-", 1)[-1]
if tail.isdigit() and len(tail) >= 2:
return tail[-2:]
return ""
def _gripper_cli(self, action: str, value: str = "") -> bool:
"""左平行爪。``close`` 合拢;``pos`` 走到 0–1000 的指定开度,不全开。
脚本返回只说明命令已交给 ``/gripper/ctrl``。没在状态里看到目标开度或夹住,
就不许接着转 A7。
"""
short = self._arm_short_id()
if not short:
self.get_logger().error("不知道当前臂号,拒绝开合夹爪")
return False
if action == "close":
extra: list[str] = []
elif action == "pos":
try:
pos = int(value)
except (TypeError, ValueError):
pos = -1
if pos < 0 or pos > 1000:
self.get_logger().error(f"夹爪位置只接受 0–1000,收到 {value!r}")
return False
extra = [str(pos)]
else:
self.get_logger().error(f"夹爪命令只接受 pos/close,收到 {action}")
return False
script = ros2_root() / "scripts" / "run_gripper.sh"
cmd = ["bash", str(script), "--arm", short, "--cli", action, *extra]
# 第一次状态没跟上就再发一次。2026-10-20 合爪命令已发布,节点被串口
# flush 堵住 120s 才执行,8s 时手指还停在张开位,这一轮就停了。
for attempt in (1, 2):
self.get_logger().info("夹爪:" + " ".join(cmd))
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=30, check=False
)
except subprocess.TimeoutExpired:
self.get_logger().error(f"夹爪 {action} 30s 没有返回,A7 不再转")
return False
text = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip()
if text:
tail = " | ".join(text.splitlines()[-4:])
self.get_logger().info(f"夹爪脚本:{tail}")
if proc.returncode != 0:
self.get_logger().error(
f"夹爪 {action} 失败 exit={proc.returncode},A7 不再转"
)
return False
if self._wait_gripper(action, value, final=(attempt == 2)):
return True
if attempt == 1:
self.get_logger().warn(f"夹爪 {action} 状态没跟上,再发一次命令")
return False
def _wait_gripper(
self, action: str, value: str = "", timeout_s: float = 8.0, *, final: bool = True
) -> bool:
"""``/gripper/states``:``pos`` 要停在目标附近;合爪见 :func:`close_ready`。
离线帧(第 6 位为 0,前 5 个是上一次的值)不算数:读不到总线时
必须报"读不到状态",不能报成"夹爪没到位"。
"""
box: dict = {"data": None}
def _on_state(msg: Float32MultiArray) -> None:
box["data"] = list(msg.data)
sub = self.create_subscription(Float32MultiArray, "/gripper/states", _on_state, 10)
t0 = time.monotonic()
last = None
saw_any = False
saw_offline = False
try:
while time.monotonic() - t0 < timeout_s:
self._spin(0.05)
data = box["data"]
if not data or len(data) < 3:
continue
saw_any = True
if not states_frame_online(data):
saw_offline = True
continue
grip = int(data[1])
pos = float(data[2])
last = (grip, pos)
if action == "pos" and grip != 0 and abs(pos - float(value)) <= 40.0:
self.get_logger().info(
f"夹爪已到 pos={pos:.0f}(目标 {value},不全开)"
)
return True
if action == "close" and close_ready(grip, pos):
if grip == 2:
self.get_logger().info(f"夹爪已夹住 pos={pos:.0f}")
else:
self.get_logger().info(
f"夹爪已合拢 pos={pos:.0f}(固件报到位)。继续顺时针。"
)
return True
finally:
self.destroy_subscription(sub)
reason = gripper_wait_timeout_reason(
action, timeout_s, last, saw_any=saw_any, saw_offline=saw_offline
)
if final:
self.get_logger().error(reason)
else:
self.get_logger().warn(reason)
return False
def _a7_to_limit(
self, limits, direction: int, margin_deg: float, speed: float, tag: str
) -> bool:
"""只把 A7 转到这一侧限位内侧。前六轴和法兰 Z 的检查在 ``_send_a7_only``。"""
q, _Tee = self._wait_state(5.0)
if q is None:
self.get_logger().error(f"{tag}: 没有当前关节")
return False
target = a7_limit_target(q, limits, direction, float(margin_deg))
if target is None:
self.get_logger().error(f"{tag}: 算不出 A7 限位目标")
return False
return self._send_a7_only(float(target[6]), speed, tag)
def _run_a7_seat(self) -> bool:
"""贴面后咬螺纹。只转 A7,法兰 XY 不动。
前两步是首轮:逆时针到限位,再顺时针到限位。顺时针到位即首轮咬合结束。
第三步整段固定重复 :data:`UNWIND_ROUNDS` 轮。每一轮自己抬、自己记住、
自己降回;某一轮失败就停,后面的轮次不再做。
"""
limits = self._a7_limits()
if limits is None:
return False
ga = load_site_dict().get("gauge_assembly") or {}
try:
margin = float(ga["extreme_margin_deg"])
ccw = int(ga["extreme_direction"])
lift_mm = float(ga["unwind_lift_mm"])
open_permille = int(ga["unwind_open_permille"])
seat_cw_deg_s = float(ga["extreme_speed_deg_s"])
except (KeyError, TypeError, ValueError) as exc:
self.get_logger().error(
f"site.yaml gauge_assembly 缺 A7 旋转参数({exc}),拒绝旋转"
)
return False
if ccw not in (-1, 1):
self.get_logger().error(
f"extreme_direction 只能是 +1/-1,收到 {ccw},拒绝旋转"
)
return False
if not (0.0 < lift_mm <= 5.0):
self.get_logger().error(
f"unwind_lift_mm 只接受 0–5mm 的微抬,收到 {lift_mm},拒绝旋转"
)
return False
if not (0 < open_permille <= 1000):
self.get_logger().error(
f"unwind_open_permille 只接受 1–1000,收到 {open_permille},拒绝旋转"
)
return False
if not (0.0 < seat_cw_deg_s <= 90.0):
self.get_logger().error(
f"extreme_speed_deg_s 只接受 0–90°/s,收到 {seat_cw_deg_s},拒绝旋转"
)
return False
# extreme_direction 是逆时针的符号。第一步出孔走接口上限,第二步顺时针
# 进孔用 extreme_speed_deg_s(现场 10°/s)。空回轮次里的正反转仍是上限。
if not self._a7_to_limit(
limits, ccw, margin, A7_MAX_SPEED_DEG_S, "A7 逆时针至极限(出孔)"
):
return False
if not self._a7_to_limit(
limits, -ccw, margin, seat_cw_deg_s, "A7 顺时针至极限(进孔)"
):
return False
self._state("首轮咬合结束")
self.get_logger().info("首轮咬合结束:A7 已顺时针转到限位。")
self.get_logger().info(
f"第三步空回固定重复 {UNWIND_ROUNDS} 轮。每一轮:张开到 {open_permille}、"
f"微抬 {lift_mm:.1f}mm、逆时针到限位、按本轮记住的上抬量降回、合爪、顺时针到限位。"
)
# 抬和降必须在轮内:每轮的下降量用这一轮实测的上抬,不能用上一轮的数。
for rnd in range(1, UNWIND_ROUNDS + 1):
label = unwind_round_label(rnd)
self._state(label)
self.get_logger().info(f"{label} 开始")
if not self._gripper_cli("pos", str(open_permille)):
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
dz_up = self._shift_flange_z(lift_mm / 1000.0, f"{label} 空回前微抬")
if dz_up is None:
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
if dz_up < (lift_mm / 1000.0) * 0.6:
self.get_logger().error(
f"{label} 空回前微抬只做到 {dz_up * 1000:.2f}mm,"
f"不到指令 {lift_mm:.1f}mm 的六成,不空转"
)
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
self.get_logger().info(
f"{label} 记住上抬 {dz_up * 1000:.2f}mm。本轮空回后按这一段下降,XY 不动。"
)
if not self._a7_to_limit(
limits, ccw, margin, A7_MAX_SPEED_DEG_S,
f"{label} A7 逆时针至极限(松爪回转)",
):
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
dz_down = self._shift_flange_z(-dz_up, f"{label} 空回后降回")
if dz_down is None:
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
if abs(dz_down + dz_up) > 0.0005:
self.get_logger().error(
f"{label} 下降 {dz_down * 1000:.2f}mm 和记住的上抬 "
f"{dz_up * 1000:.2f}mm 不一致,不合爪"
)
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
if not self._gripper_cli("close"):
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
if not self._a7_to_limit(
limits, -ccw, margin, A7_MAX_SPEED_DEG_S,
f"{label} A7 顺时针至极限(夹紧再进)",
):
self.get_logger().error(f"{label} 没走完,后面的轮次不再做")
return False
self.get_logger().info(
f"{label} 完成:张开到 {open_permille}、上抬 {dz_up * 1000:.2f}mm、"
f"下降 {dz_down * 1000:.2f}mm、合爪、顺时针到限位。"
f"进度 {rnd}/{UNWIND_ROUNDS}。"
)
self.get_logger().info(
f"空回 {UNWIND_ROUNDS}/{UNWIND_ROUNDS} 轮全部完成。"
)
return True
def _on_lock(self, msg: Bool) -> None:
if bool(msg.data):
self._locked = True
@@ -276,7 +799,7 @@ class AssemblyPipeline(Stage3Search):
f"启动一阶段跟踪(锁高 {lock_z:.3f}m,锁定后自动交二阶段)"
)
self._s1 = subprocess.Popen(
cmd, stdout=logf, stderr=subprocess.STDOUT, start_new_session=False,
cmd, stdout=logf, stderr=subprocess.STDOUT, start_new_session=True,
)
timeout = float(self.get_parameter("stage1_timeout_sec").value)
self._point_msgs = 0
@@ -285,6 +808,11 @@ class AssemblyPipeline(Stage3Search):
self._spin(0.1)
if self._locked:
break
fatal = stage1_log_fatal(log_path)
if fatal:
self._stop_stage1()
self.get_logger().error(fatal)
return 1
if self._s1.poll() is not None:
self.get_logger().error(f"一阶段进程提前退出,看 {log_path}")
return 1
@@ -310,9 +838,7 @@ class AssemblyPipeline(Stage3Search):
def run(self) -> int:
skip1 = bool(self.get_parameter("skip_stage1").value)
skip2 = bool(self.get_parameter("skip_stage2").value)
if not bool(self.get_parameter("skip_stage3").value):
self.get_logger().info("搜孔轻压已取消,忽略要求重新打开它的参数")
skip3 = True
skip3 = bool(self.get_parameter("skip_stage3").value)
if self.d435_move_only:
skip2 = True
skip3 = True
@@ -342,28 +868,64 @@ class AssemblyPipeline(Stage3Search):
if code != 0:
self.get_logger().error(f"二阶段失败 exit={code},停在当前位姿")
return code
stay = hold_at_insert_z(final, skip3=skip3)
if stay and final.get("insert_held"):
self.get_logger().info(
f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm — "
f"插孔停在法兰 Z={final.get('flange_z_m')}m,"
f"规尖离面 {final.get('tip_gap_mm')}mm"
f"(目标 {final.get('commanded_gap_mm')}mm,"
f"来自 {final.get('insert_z_source')})。"
"没有接触传感器,不另设 Z,不抬回悬高。"
)
else:
self.get_logger().info(
f"二阶段 OK 残差 {final.get('residual_mm', -1)}mm"
" — 搜孔已取消,停在这里。旋入请另跑。"
+ (
" — 跳过搜孔,停在观测高度(看规尖是否对中;进程退出后臂保持)"
if skip3
else " — 立刻下压搜孔并轻压(不拧入)"
)
)
else:
self._state("skip_stage2")
stay = hold_at_insert_z(final, skip3=skip3)
if skip3:
if stay:
self._state("skip_stage3")
if self.d435_move_only:
self.get_logger().info(
"D435 位移测试结束 —— 臂停在当前高度,不交 D405、不对孔;"
"15s 后本进程退出,臂保持不动"
f"结果发布 {self.completion_linger:g}s 后退出,臂保持不动"
)
elif isinstance(final, dict) and final.get("insert_held") and bool(self.get_parameter("run_a7_after_insert").value):
if q is not None:
self._pin(q)
self.get_logger().info(
"插孔已停在贴面高度,不进搜孔、不抬回悬高。"
"接着只转 A7:先逆时针到限位(接口最快),再顺时针到限位"
"(首轮咬合,用 extreme_speed_deg_s)。"
f"然后下面这一整段固定重复 {UNWIND_ROUNDS} 轮:"
"平行爪只张开到指定开度、微抬 Z、最快逆时针回限位、"
"按本轮记住的上抬量降回、合爪、再最快顺时针到限位。"
"某一轮没走完就停,后面的轮次不再做。"
)
if not self._run_a7_seat():
self.get_logger().error(
"A7 旋转没走完,臂停在当前关节。不抬 Z。"
)
failed = String()
failed.data = json.dumps({"ok": False, "stage": "pipeline", "exit": 2, "error": "A7 旋转未完成"}, ensure_ascii=False)
self._pipe_result.publish(failed)
return 2
else:
self.get_logger().info(
"搜孔已取消 —— 臂停在当前高度。"
"15s 后本进程退出,臂保持不动"
f"结果发布 {self.completion_linger:g}s 后退出,臂保持不动"
)
if final:
self._publish_result(final)
self._linger_hold(final if final else {"ok": True}, seconds=15.0)
self._linger_hold(final if final else {"ok": True}, seconds=self.completion_linger)
payload = {"ok": True, "stage": "pipeline", "exit": 0, "skipped": "stage3"}
try:
payload["stage2"] = json.loads(
@@ -397,9 +959,9 @@ class AssemblyPipeline(Stage3Search):
self._stop_stage1()
def main(args=None) -> None:
def main(args=None, node_factory=AssemblyPipeline) -> None:
rclpy.init(args=args)
node = AssemblyPipeline()
node = node_factory()
code = 2
try:
code = node.run()
@@ -328,8 +328,16 @@ class PoseDetector:
self.max_det = int(max_det)
if path.suffix.lower() in {".engine", ".trt"}:
try:
self._runner = _TrtRunner(path, device)
self.backend = "tensorrt"
except Exception as exc:
onnx = path.with_suffix(".onnx")
if not onnx.is_file():
raise
logger.warning("TensorRT 加载失败(%s),改用 %s", exc, onnx)
self._runner = _OnnxRunner(onnx)
self.backend = "onnx"
else:
self._runner = _OnnxRunner(path)
self.backend = "onnx"
@@ -55,3 +55,33 @@ def stop_process_tree(proc, interrupt_timeout=4., terminate_timeout=2., kill_tim
living=[pid for pid,started in owned.items() if _alive(pid,started)]
if living:raise RuntimeError(f'受管子进程未退出:{living}')
proc.wait(timeout=1)
def stop_owned_children(parent, interrupt_timeout=4., terminate_timeout=2., kill_timeout=1.):
"""For a subreaper: stop/reap descendants, including adopted launch orphans."""
if parent != os.getpid():
raise ValueError('only the supervising process may reap its own children')
owned = {}
for sig, timeout in ((signal.SIGINT, interrupt_timeout), (signal.SIGTERM, terminate_timeout),
(signal.SIGKILL, kill_timeout)):
deadline = time.monotonic() + timeout
sent = set()
while True:
found = _descendants(parent)
found.pop(parent, None)
owned.update(found)
for pid, started in owned.items():
if (pid, started) not in sent and _alive(pid, started):
try: os.kill(pid, sig)
except ProcessLookupError: pass
sent.add((pid, started))
# Reap only our children, never wait for or signal unrelated processes.
while True:
try:
pid, _ = os.waitpid(-1, os.WNOHANG)
if pid == 0: break
except ChildProcessError: break
if not any(_alive(pid, started) for pid, started in owned.items()): return
if time.monotonic() >= deadline: break
time.sleep(.04)
raise RuntimeError('受管子进程清理未完成,拒绝报告任务结束')
@@ -29,7 +29,7 @@ import rclpy
from rcl_interfaces.msg import ParameterDescriptor
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CameraInfo, Image
from sensor_msgs.msg import CameraInfo, Image, CompressedImage
from std_msgs.msg import Float32MultiArray
from arm_application.ros_node import TolerantNode, param_as_str
@@ -49,6 +49,30 @@ USB_RESEAT_HINT = (
)
def usb2_needs_reset(usb_desc: str) -> bool:
"""USB2 枚举后 pipeline 能打开但不出帧;复位常能重新协商 SuperSpeed。"""
return str(usb_desc or '').startswith('2')
def stream_open_passes(want_depth: bool, want_infra: bool, usb2: bool):
"""开流顺序:所有分辨率先试带深度,全部失败才退回纯彩色。
旧逻辑在同一分辨率里 depth 失败就立刻 color-only。USB3 的 1280x720
深度偶尔 busy 一次,就会把已经能用的 640x480 深度丢掉,跟踪整段
150s 报「框心无深度」。
"""
depth_modes = []
if want_depth and want_infra and not usb2:
depth_modes.append((True, 'both'))
if want_depth and want_infra:
depth_modes.append((True, 'right'))
if want_depth:
depth_modes.append((True, 'none'))
if depth_modes:
yield 'depth', tuple(depth_modes)
yield 'color', ((False, 'none'),)
def _fill_camera_info(
msg: CameraInfo, width: int, height: int, fx, fy, cx, cy, frame_id: str, dist
) -> None:
@@ -88,6 +112,7 @@ class RealsenseColorPub(TolerantNode):
self.declare_parameter('height', 720)
self.declare_parameter('fps', 15)
self.declare_parameter('show_preview', False)
self.declare_parameter('preview_jpeg_quality', 95)
self.declare_parameter('overlay_detection', True)
self.declare_parameter('overlay_hold_sec', 1.5)
self.declare_parameter('enable_depth', True)
@@ -106,6 +131,7 @@ class RealsenseColorPub(TolerantNode):
self._width = int(self.get_parameter('width').value)
self._height = int(self.get_parameter('height').value)
self._fps = int(self.get_parameter('fps').value)
self._requested_mode = (self._width, self._height, self._fps)
self._show_preview = bool(self.get_parameter('show_preview').value)
self._overlay_detection = bool(self.get_parameter('overlay_detection').value)
self._overlay_hold_sec = float(self.get_parameter('overlay_hold_sec').value)
@@ -136,6 +162,11 @@ class RealsenseColorPub(TolerantNode):
self._img_pub = self.create_publisher(
Image, f'{base}/image_raw', qos
)
from rclpy.qos import QoSProfile, ReliabilityPolicy
self._compressed_pub = self.create_publisher(
CompressedImage, f'{base}/preview/compressed',
QoSProfile(depth=1,reliability=ReliabilityPolicy.BEST_EFFORT))
self._jpeg_quality = min(100, max(70, int(self.get_parameter('preview_jpeg_quality').value)))
self._info_pub = self.create_publisher(
CameraInfo, f'{base}/camera_info', qos
)
@@ -181,6 +212,7 @@ class RealsenseColorPub(TolerantNode):
self._pending = None
self._last_frame_mono = 0.0
self._usb_desc = ''
self._usb_reset_attempted = False
self._last_ok = None
self._site_yaml = _site_yaml_path()
self._site_mtime = 0.0
@@ -233,6 +265,28 @@ class RealsenseColorPub(TolerantNode):
return d
return devices[0]
def _recover_superspeed(self, device, rs):
"""USB2 卡住时复位一次。设备其实在 USB3 口上时,常能重新协商到 5000M。"""
serial = self._serial or str(device.get_info(rs.camera_info.serial_number))
usb = getattr(self, '_usb_desc', '?')
self.get_logger().warn(
f'{self._name} USB={usb} 无 SuperSpeed,复位一次以重新协商。'
+ USB_RESEAT_HINT
)
device.hardware_reset()
deadline = time.monotonic() + 8.0
while time.monotonic() < deadline:
time.sleep(0.4)
for candidate in rs.context().devices:
try:
if candidate.get_info(rs.camera_info.serial_number) == serial:
recovered = str(candidate.get_info(rs.camera_info.usb_type_descriptor) or '')
self.get_logger().info(f'{self._name} 复位后 USB={recovered or "?"}')
return candidate
except Exception:
continue
raise RuntimeError(f'{self._name} 复位后未重新枚举,请拔插 USB')
def _reload_site_preview(self, stream_w: int = 0, stream_h: int = 0) -> None:
"""不重启就能读到新的 overlay_hold_sec(宽高变了则给出警告)。"""
path = self._site_yaml
@@ -290,18 +344,28 @@ class RealsenseColorPub(TolerantNode):
h, w = bgr.shape[0], bgr.shape[1]
stamp = self.get_clock().now().to_msg()
# Keep full raw images for perception. The UI consumes its own lightweight
# preview channel, so raw multi-megabyte UDP bursts no longer feed the UI.
if self._img_pub.get_subscription_count():
img = Image()
img.header.stamp = stamp
img.header.frame_id = self._frame_id
img.height = h
img.width = w
img.height, img.width = h, w
img.encoding = 'bgr8'
img.is_bigendian = 0
img.step = w * 3
# array.array('B', ...) 会走 setter 的快路径 —— 直接传 bytes 会触发
# 逐元素的 debug 检查,在这里每帧要多花约 40 ms。
img.data = array.array('B', np.ascontiguousarray(bgr).tobytes())
self._img_pub.publish(img)
if self._compressed_pub.get_subscription_count():
import cv2
ok, encoded = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY,self._jpeg_quality])
if ok:
preview = CompressedImage()
preview.header.stamp = stamp
preview.header.frame_id = self._frame_id
preview.format = 'bgr8; jpeg compressed bgr8'
preview.data = array.array('B', encoded.tobytes())
self._compressed_pub.publish(preview)
info = CameraInfo()
info.header.stamp = stamp
@@ -369,6 +433,14 @@ class RealsenseColorPub(TolerantNode):
except Exception: # noqa: BLE001
usb_desc = '?'
self._usb_desc = usb_desc
if usb2_needs_reset(usb_desc) and not self._usb_reset_attempted:
self._usb_reset_attempted = True
found = self._recover_superspeed(found, rs)
try:
usb_desc = str(found.get_info(rs.camera_info.usb_type_descriptor))
except Exception:
usb_desc = '?'
self._usb_desc = usb_desc
usb2 = usb_desc.startswith('2')
cam_name = ''
try:
@@ -379,6 +451,8 @@ class RealsenseColorPub(TolerantNode):
f'{cam_name} serial={self._serial or "?"} USB={usb_desc} '
f'requested {self._width}x{self._height}@{self._fps}Hz'
)
# A temporary USB2 fallback must not overwrite the requested USB3 mode.
self._width, self._height, self._fps = self._requested_mode
if usb2:
# 只在 480Mbps 口上才降。实测(2026-09-28)640x480@15 彩色+深度
# 可以稳定跑满 15fps;但 1280x720@30 在 USB2 上物理不可能
@@ -392,11 +466,9 @@ class RealsenseColorPub(TolerantNode):
want = (self._width, self._height, self._fps)
last = self._last_ok
if reconnect:
candidates = []
if last:
candidates = [want]
if last and last != want and not usb2:
candidates.append(last)
if want not in candidates:
candidates.append(want)
if (640, 480, 15) not in candidates:
candidates.append((640, 480, 15))
else:
@@ -408,7 +480,15 @@ class RealsenseColorPub(TolerantNode):
]
if usb2:
candidates = [(640, 480, 15), (640, 480, 5), (424, 240, 15)]
# 丢弃固件里的垃圾模式(848@90 之类),它们会 ioctl 失败并卡死 USB。
color_modes, depth_modes = set(), set()
for sensor in found.query_sensors():
for stream in sensor.get_stream_profiles():
if not stream.is_video_stream_profile(): continue
video = stream.as_video_stream_profile()
mode = (video.width(), video.height(), stream.fps())
if stream.stream_type() == rs.stream.color: color_modes.add(mode)
if stream.stream_type() == rs.stream.depth: depth_modes.add(mode)
# Verify against this exact serial's enumerated profiles, including D435 1080p.
seen = set()
trimmed = []
for item in candidates:
@@ -417,7 +497,7 @@ class RealsenseColorPub(TolerantNode):
w, h, fps = item
if fps not in (5, 15, 30):
continue
if w * h > 1280 * 720:
if (w, h, fps) not in color_modes:
continue
seen.add(item)
trimmed.append(item)
@@ -436,33 +516,32 @@ class RealsenseColorPub(TolerantNode):
# USB3:先尝试真正的 IR-L+IR-R(共享立体自动曝光)。彩色+IR-L 在 D405 上
# 经常失败 —— 那就退回 IR-R + 把彩色当左目(观感不同,且不共享自动曝光)。
def _modes():
if want_depth and want_infra and not usb2:
yield True, 'both'
if want_depth and want_infra:
yield True, 'right'
if want_depth:
yield True, 'none'
yield False, 'none'
for pass_name, modes in stream_open_passes(want_depth, want_infra, usb2):
if pass_name == 'color' and want_depth:
self.get_logger().warn(
'所有分辨率的彩色+深度都失败,才退回纯彩色;跟踪会没有深度'
)
for w, h, fps in candidates:
for with_depth, infra_mode in _modes():
for with_depth, infra_mode in modes:
attempts = 3 if with_depth else 1
for attempt in range(attempts):
try:
trial = rs.config()
if self._serial:
trial.enable_device(self._serial)
trial.enable_stream(rs.stream.color, w, h, rs.format.bgr8, fps)
dw, dh, dfps = w, h, fps
if with_depth:
trial.enable_stream(
rs.stream.depth, w, h, rs.format.z16, fps
)
from arm_application.camera_profiles import depth_profile
dw, dh, dfps = depth_profile((w,h,fps), depth_modes)
trial.enable_stream(rs.stream.depth, dw, dh, rs.format.z16, dfps)
if infra_mode in ('both', 'left'):
trial.enable_stream(
rs.stream.infrared, 1, w, h, rs.format.y8, fps
rs.stream.infrared, 1, dw, dh, rs.format.y8, dfps
)
if infra_mode in ('both', 'right'):
trial.enable_stream(
rs.stream.infrared, 2, w, h, rs.format.y8, fps
rs.stream.infrared, 2, dw, dh, rs.format.y8, dfps
)
# 回调里只留最新一帧。wait_for_frames 会等彩色、深度、红外齐套,
# 场景一动红外对不齐,彩色帧就在这里被丢掉。
@@ -470,6 +549,8 @@ class RealsenseColorPub(TolerantNode):
have_depth = with_depth
have_infra = infra_mode != 'none'
self._infra_mode = infra_mode
if with_depth:
self.get_logger().info(f'native streams color={w}x{h}@{fps}, depth={dw}x{dh}@{dfps}')
if (w, h, fps) != want:
self.get_logger().warn(
f'请求 {want[0]}x{want[1]}@{want[2]}Hz '
@@ -498,7 +579,8 @@ class RealsenseColorPub(TolerantNode):
except Exception as exc: # noqa: BLE001
last_err = exc
kind = f'color+depth+IR-{infra_mode}' if with_depth else 'color'
self.get_logger().warn(f'{w}x{h}@{fps}Hz {kind} 失败:{exc}')
extra = f'(第 {attempt+1}/{attempts} 次)' if attempts > 1 else ''
self.get_logger().warn(f'{w}x{h}@{fps}Hz {kind} 失败{extra}:{exc}')
profile = None
have_depth = False
have_infra = False
@@ -510,6 +592,10 @@ class RealsenseColorPub(TolerantNode):
pipe = rs.pipeline()
if profile is not None:
break
if profile is not None:
break
if profile is not None:
break
if profile is None:
raise RuntimeError(
f'{self._name} 没有可用的彩色配置。最后的错误:{last_err}'
@@ -520,8 +606,9 @@ class RealsenseColorPub(TolerantNode):
try:
sensor = profile.get_device().first_color_sensor()
if sensor.supports(rs.option.frames_queue_size):
# 队列只留最新一张,避免越积越慢,动起来画面还停在旧帧。
sensor.set_option(rs.option.frames_queue_size, 1)
# SDK buffer pool must accommodate frames retained by alignment.
# The application mailbox still keeps only the latest frame.
sensor.set_option(rs.option.frames_queue_size, 4)
except Exception: # noqa: BLE001
pass
self._have_depth = have_depth
@@ -744,6 +831,8 @@ class RealsenseColorPub(TolerantNode):
def _publish_loop(self) -> None:
got = False
while self._running and rclpy.ok():
# Release SDK buffers before waiting; idle references must not starve capture.
item = frames = aligned = depth = color = ir_l = ir_r = depth_u16 = None
if self._pipe is None:
if not self._reconnect():
time.sleep(2.0)
@@ -960,6 +1049,10 @@ def main(args=None) -> None:
except KeyboardInterrupt:
pass
finally:
# Supervisors may send SIGINT through both parent and child. Do not let
# a second interrupt skip DDS cleanup and leave ghost publishers behind.
import signal
signal.signal(signal.SIGINT, signal.SIG_IGN)
node._running = False
if node._pipe is not None:
try:
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""通过 ``/follow_joint_trajectory`` 发送多点关节轨迹(真机 + mock 都适用)。"""
from __future__ import annotations
import time
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from arm_application.site_io import joint_prefix_default
from arm_msgs.action import FollowJointTrajectory
from arm_application.ros_node import TolerantNode
DEFAULT_JOINTS = [
'joint1', 'joint2', 'joint3', 'joint4', 'joint5', 'joint6', 'joint7',
]
NUM_JOINTS = 7
# 扁平化存放的位置(3 个点 x 7 个关节)+ 时间 —— ROS 参数不支持列表套字典
DEFAULT_TIMES = [3.0, 6.0, 9.0]
DEFAULT_POSITIONS = [
0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0,
0.35, -0.698, 0.35, 1.57, 0.0, 0.698, 0.0,
0.0, -0.698, 0.0, 1.57, 0.0, 0.698, 0.0,
]
class RunJointTrajectory(TolerantNode):
def __init__(self) -> None:
super().__init__('run_joint_trajectory')
self.declare_parameter('joint_prefix', joint_prefix_default())
self.declare_parameter('joint_names', DEFAULT_JOINTS)
self.declare_parameter('velocity_scale', 0.15)
self.declare_parameter('action_name', 'follow_joint_trajectory')
self.declare_parameter('waypoint_times', DEFAULT_TIMES)
self.declare_parameter('waypoint_positions', DEFAULT_POSITIONS)
# 1 = 只跑一次(默认);0 = 循环直到 Ctrl+C;N = 重复 N 次
self.declare_parameter('loop_count', 1)
self.declare_parameter('pause_between_loops', 0.5)
prefix = str(self.get_parameter('joint_prefix').value or '')
base = list(
self.get_parameter('joint_names').get_parameter_value().string_array_value
or DEFAULT_JOINTS
)
self.joint_names = [f'{prefix}{n}' for n in base]
self.velocity_scale = float(self.get_parameter('velocity_scale').value)
action = str(self.get_parameter('action_name').value)
self._action_name = action
self._client = ActionClient(self, FollowJointTrajectory, action)
def run(self) -> int:
if not self._client.wait_for_server(timeout_sec=15.0):
self.get_logger().error(
f'Action [{self._action_name}] 不可用。'
'请先启动真机栈:bash scripts/run_real.sh'
)
return 1
times = list(self.get_parameter('waypoint_times').value)
positions = list(self.get_parameter('waypoint_positions').value)
n_j = len(self.joint_names)
n_pts = len(times)
if n_pts < 1:
self.get_logger().error('waypoint_times 为空')
return 1
if len(positions) != n_pts * n_j:
self.get_logger().error(
f'waypoint_positions 长度 {len(positions)} != '
f'{n_pts} 个点 x {n_j} 个关节 = {n_pts * n_j}'
)
return 1
goal = FollowJointTrajectory.Goal()
goal.joint_names = self.joint_names
goal.num_points = n_pts
goal.velocity_scale = self.velocity_scale
goal.positions = positions
goal.time_from_start = [float(t) for t in times]
loop_count = int(self.get_parameter('loop_count').value)
pause = float(self.get_parameter('pause_between_loops').value)
duration = goal.time_from_start[-1]
if loop_count == 0:
self.get_logger().info(
f'循环模式:{goal.num_points} 个点,约 {duration:.1f}s/圈 '
f'(Ctrl+C 停止)'
)
elif loop_count > 1:
self.get_logger().info(
f'循环模式:{loop_count} 圈,{goal.num_points} 个点,'
f'约 {duration:.1f}s/圈'
)
else:
self.get_logger().info(
f'发送 {goal.num_points} 个点,v_scale={goal.velocity_scale},'
f'时长={duration:.1f}s'
)
iteration = 0
while rclpy.ok():
iteration += 1
if loop_count > 1:
self.get_logger().info(f'第 {iteration}/{loop_count} 圈')
code = self._send_once(goal, duration)
if code != 0:
return code
if loop_count == 1:
self.get_logger().info('完成:OK')
return 0
if loop_count > 1 and iteration >= loop_count:
self.get_logger().info(f'完成:已跑完 {loop_count} 圈')
return 0
if pause > 0:
time.sleep(pause)
def _send_once(self, goal: FollowJointTrajectory.Goal, duration: float) -> int:
send = self._client.send_goal_async(goal)
deadline = time.monotonic() + 10.0
while rclpy.ok() and not send.done():
rclpy.spin_once(self, timeout_sec=0.05)
if time.monotonic() > deadline:
self.get_logger().error('发送目标超时')
return 2
handle = send.result()
if handle is None or not handle.accepted:
self.get_logger().error('目标被拒绝')
return 3
res_fut = handle.get_result_async()
deadline = time.monotonic() + max(120.0, duration * 3.0)
next_log = time.monotonic() + 10.0
while rclpy.ok() and not res_fut.done():
rclpy.spin_once(self, timeout_sec=0.05)
now = time.monotonic()
if now >= next_log:
left = max(0.0, deadline - now)
self.get_logger().info(
f'正在执行轨迹(计划约 {duration:.0f}s),'
f'等待机器人……(剩余超时 {left:.0f}s)'
)
next_log = now + 10.0
if now > deadline:
self.get_logger().error('等待结果超时')
return 4
result = res_fut.result().result
if not result.success:
self.get_logger().error(f'失败:{result.message}')
return 5
self.get_logger().info(f'本圈结束:{result.message or "OK"}')
return 0
def main(args=None) -> None:
rclpy.init(args=args)
node = RunJointTrajectory()
code = 1
try:
code = node.run()
except KeyboardInterrupt:
code = 0
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
raise SystemExit(code)
if __name__ == '__main__':
main()
@@ -50,6 +50,9 @@ from arm_application.geom_ik import (
)
from arm_application.handeye_lib import cam_point_to_base, cam_to_base_T
from arm_application.overhead_oneshot import (
D435_MOVEL_GAVE_UP,
D435_MOVEL_RETRY,
D435_MOVEL_RETRY_S,
D435_NEAR_CRUISE_MPS,
D435_SETTLE_S,
D435_VENDOR_MOVEL_VEL_MPS,
@@ -126,7 +129,7 @@ class ServoNode(TolerantNode):
self.declare_parameter("point_topic", "visual_servo/filtered_point")
# 默认值只作兜底:真机运行一定由 launch 传入 site.yaml 的 paths.handeye_file。
# 指向 /tmp 是历史遗留(标定临时产物),会让人误以为"标定结果在那里"。
self.declare_parameter("handeye_file", "handeye/results/89_eye_in_hand.yaml")
self.declare_parameter("handeye_file", "/home/cc/ros2/handeye/results/89_eye_in_hand.yaml")
# eye_in_hand = cam→法兰;eye_to_hand = cam→基座。写错会把固定相机当腕部用。
self.declare_parameter("handeye_mode", "eye_in_hand")
self.declare_parameter("use_static_handeye_tf", False)
@@ -224,8 +227,7 @@ class ServoNode(TolerantNode):
self.declare_parameter("lock_hold_sec", 2.0)
# D405 跟住后法兰静止多久进二阶段。哨兵 -1 = 没从 site 接入。
# lock_still_sec 仍须从 site 接上(缺了就报错)。
# 交回 D405:进程内 MoveL 到位后,再叠 d435_handback_sec(法兰 XY 停稳)。
# 旧杀栈冷启动曾把这 3s 垫在控制器重启后面;不杀栈后只留机械停稳。
# 厂家 MoveL 结束后,法兰连续停稳 d435_handback_sec 才打开 D405 框心。
self.declare_parameter("lock_still_sec", -1.0)
self.declare_parameter("d435_handback_sec", -1.0)
self.declare_parameter("wrist_align_sec", -1.0)
@@ -329,7 +331,7 @@ class ServoNode(TolerantNode):
and self.wrist_handoff_conf >= 0.0
and self.overhead_hold_s >= 0.0
)
# 框心对准进对孔用 wrist_align_sec。D435 交回只用 d435_handback_sec,两段不要合成一个数。
# 框心对准进精拍用 wrist_align_sec。D435 交接只用 d435_handback_sec,两段不要合成一个数。
self._still = StillLock(
still_sec=max(0.0, self.wrist_align_sec),
still_move_m=max(0.0, self.lock_still_move_m),
@@ -342,7 +344,8 @@ class ServoNode(TolerantNode):
self._d435_vendor_done = False
self._d435_vendor_busy = False
self._d435_vendor_failed = False
self._d435_joint_fallback = False
self._d435_vendor_tries = 0
self._d435_retry_after = 0.0
self._d435_settled = False
self._d435_exec_until = 0.0
self._d435_pinned = False
@@ -375,10 +378,9 @@ class ServoNode(TolerantNode):
self.ws_min = np.array([float(v) for v in ws_min[:3]], dtype=float)
self.ws_max = np.array([float(v) for v in ws_max[:3]], dtype=float)
from arm_application.site_io import resolve_repo_path
handeye_path = resolve_repo_path(str(self.get_parameter("handeye_file").value))
self.T_cam_to_gripper = load_handeye_T(handeye_path)
self.T_cam_to_gripper = load_handeye_T(
str(self.get_parameter("handeye_file").value)
)
self.handeye_mode = str(
self.get_parameter("handeye_mode").value or "eye_in_hand"
).strip().lower()
@@ -401,7 +403,7 @@ class ServoNode(TolerantNode):
t_he = np.asarray(self.T_cam_to_gripper[:3, 3], dtype=float)
axis_z = float(np.clip(R_he[2, 2], -1.0, 1.0))
self._cam_tilt_deg = float(np.degrees(np.arccos(axis_z)))
he_path = handeye_path
he_path = str(self.get_parameter("handeye_file").value)
if self.handeye_mode == "eye_to_hand":
self.get_logger().warn(
f"HANDEYE yaml={he_path} mode=eye_to_hand "
@@ -710,7 +712,8 @@ class ServoNode(TolerantNode):
self._d435_vendor_done = False
self._d435_vendor_busy = False
self._d435_vendor_failed = False
self._d435_joint_fallback = False
self._d435_vendor_tries = 0
self._d435_retry_after = 0.0
self._overhead_t = now_m
self._last_seen_time = now_m
self._stamp_sec = now_sec
@@ -734,7 +737,8 @@ class ServoNode(TolerantNode):
self._d435_vendor_done = False
self._d435_vendor_busy = False
self._d435_vendor_failed = False
self._d435_joint_fallback = False
self._d435_vendor_tries = 0
self._d435_retry_after = 0.0
self._publish_d435_commanding(True)
self.get_logger().info(
f"D435 一次性设定 feat_base=({p_base[0]:.3f},{p_base[1]:.3f},{p_base[2]:.3f}) "
@@ -762,9 +766,9 @@ class ServoNode(TolerantNode):
settled=settled,
move_only=self.d435_move_only,
):
# 推理服务不关。这一帧的点不下发给臂,目标仍是 D435 那一个点。
# 推理不关。没到「法兰停稳」之前,这帧不能进运动控制。
self.get_logger().info(
"D435 位置还没到并且停稳,D405 这帧不下发",
"D405 输出已截断:D435 厂家移动还没结束或法兰还没停稳",
throttle_duration_sec=1.0,
)
return
@@ -1106,12 +1110,6 @@ class ServoNode(TolerantNode):
elif self.ee_z_m > 0.0:
ex = ey = 0.0
r_xy_raw = 0.0
# 还没到锁高、XY 未解锁时,误差被清成 0 只是为了先别动。
# 不能据此把死区标志打成真,否则下一拍会把“还没对准”当成已对准。
if self.ee_z_m > 0.0 and not self._xy_armed:
xy_dead = True
self._xy_in_dead = False
else:
xy_dead = self._xy_deadzone(r_xy_raw)
clip_xy = max(0.03, float(self.e_xy_clip))
r_xy = r_xy_raw
@@ -1416,9 +1414,12 @@ class ServoNode(TolerantNode):
return geometric_jacobian(link_R, link_p, p_ee, RIZON_LINK_AXES)
def _d435_oneshot_holds_the_arm(self) -> bool:
"""D435 到点平移:action 短跳,等结果再重测。D405 主跟踪仍走原来的伺服。"""
if self.d435_move_only and self._hybrid and self._track_source == "overhead" and self._d435_settled:
# 位移测完了:守着,不许掉进 D405 的 _execute_twist。hold 只在到位那一拍发一次。
"""D435 粗定位只走厂家 MoveL。停稳后先钉住,等 D405 框心接上再放开。
这段不许再用流式伺服去追 D435 那个点。D405 点被 ``_on_point`` 接上后
跟踪源变成腕部,这里返回 False,框心才开始带臂。
"""
if self._hybrid and self._track_source == "overhead" and self._d435_settled:
return True
if not (self._hybrid and self._track_source == "overhead" and not self._d435_settled):
return False
@@ -1528,7 +1529,7 @@ class ServoNode(TolerantNode):
"""停或重新打开关节轨迹控制器。关节状态广播留着,硬件接口不下线。
用节点里的客户端,不走 ``ros2 service call``:那条命令自己就要一两秒,
交回 D405 的预算会被它吃掉。
停稳锁定的预算会被它吃掉。
"""
from controller_manager_msgs.srv import SwitchController
@@ -1663,28 +1664,37 @@ class ServoNode(TolerantNode):
return bool(ok)
def _d435_oneshot_holds_locked(self) -> bool:
# 优先厂家 MoveL。机器人拒绝下发时改走关节轨迹,否则会停在原地,
# 腕部又看不见,跟踪就再也不动。
if self._d435_vendor_busy:
# 混合流程这一段只走厂家 MoveL。下面留下的关节折线不再执行:
# 那条路径会在真机上一段一段启停。
if self._d435_vendor_failed or self._d435_vendor_busy:
return True
if self._d435_joint_fallback:
return self._d435_oneshot_holds_locked_joint()
if not self._d435_vendor_done:
# busy 先于调用置位:另一条控制回调不能再开一次 MoveL,也不能提前交回 D405。
if time.monotonic() < float(self._d435_retry_after):
return True
if int(self._d435_vendor_tries) >= int(D435_MOVEL_RETRY):
self._d435_vendor_failed = True
self.get_logger().error(
f"{D435_MOVEL_GAVE_UP} —— 一阶段中止,D405 也不带臂"
)
return True
# busy 先于调用置位:另一条控制回调不能再开一次 MoveL,也不能提前锁定。
self._d435_vendor_busy = True
ok = self._d435_vendor_movel()
self._d435_vendor_busy = False
if not ok:
self._d435_vendor_failed = True
self._d435_vendor_done = True
self._d435_joint_fallback = True
self._arrive.reset()
self._d435_vendor_tries = int(self._d435_vendor_tries) + 1
self._d435_retry_after = time.monotonic() + float(D435_MOVEL_RETRY_S)
self.get_logger().error(
"厂家 MoveL 没走成 —— 改用关节轨迹走到顶视点,高度保持锁高"
f"厂家 MoveL 第{self._d435_vendor_tries}次没走成,"
f"{D435_MOVEL_RETRY_S:.1f}s 后再试"
)
return self._d435_oneshot_holds_locked_joint()
else:
self._d435_vendor_done = True
self._arrive.reset()
# MoveL 切回关节后 JTC 还握着出发前的悬停。不钉住的话
# 硬件 3 s 窗口一过,臂会按悬停关节走回去(比交回 D405 的 10 cm 更远)。
self._send_hold()
self._d435_pinned = True
return True
return True
@@ -1743,7 +1753,7 @@ class ServoNode(TolerantNode):
):
self._d435_pin_and_stop_hops(
f"D435 上一跳几乎没走近,剩差还是 {dist*1000:.0f}mm "
f"—— 钉住不再补刀,等停稳后交回 D405"
f"—— 钉住不再补刀,等停稳后锁定进二阶段"
)
return True
if not at:
@@ -1850,7 +1860,7 @@ class ServoNode(TolerantNode):
return True
def _tick_d435_arrive(self) -> None:
"""走到 D435 那个点,并且法兰按一阶段的静止判据停稳,才允许下发 D405。"""
"""厂家 MoveL 已结束:看法兰还在不在动。连续停稳后才打开 D405 框心。"""
if not self._hybrid:
return
if (
@@ -1872,11 +1882,15 @@ class ServoNode(TolerantNode):
return
xy = np.asarray(T[:2, 3], dtype=float)
dist = float(np.hypot(float(xy[0]) - goal[0], float(xy[1]) - goal[1]))
arrived = dist <= float(self.lock_still_move_m) or self._d435_give_up
settled = self._arrive.update(
xy, time.monotonic(), tracking_ok=arrived
)
# 到位看法兰是否还在动,不看离视觉点还差几毫米。
settled = self._arrive.update(xy, time.monotonic(), tracking_ok=True)
if not settled:
self.get_logger().info(
f"D435 厂家 MoveL 已结束,法兰还在动或未满 "
f"{self.d435_handback_sec:.1f}s(离点 {dist*1000:.0f}mm),"
"D405 输出仍截断",
throttle_duration_sec=1.0,
)
return
with self._lock:
self._d435_settled = True
@@ -1899,10 +1913,12 @@ class ServoNode(TolerantNode):
self._lock_pub_t = now_m
self._lock_pub.publish(Bool(data=True))
return
# 法兰已停稳:打开 D405。不在这里锁定;框心对准停稳后才精拍下臂。
self._publish_d435_commanding(False)
self.get_logger().info(
f"已到 D435 位置(还差 {dist*1000:.0f}mm)并停稳 "
f"{self.d435_handback_sec:.1f}s —— 改回下发 D405 框心"
f"法兰已连续停稳 {self.d435_handback_sec:.1f}s"
f"(离 D435 点 {dist*1000:.0f}mm)"
" —— 打开 D405 输出,追工件框矩形中心;对准后才精拍下臂"
)
def _tick_still_lock(self) -> None:
@@ -1922,15 +1938,12 @@ class ServoNode(TolerantNode):
lost = self._target_lost_flag
source = self._track_source
conf = self._wrist_conf
# 没进死区不算对准。只看“有目标且臂没动”会在刚看见工件、
# 还差一百多毫米时就把一阶段锁死。
tracking_ok = (
source == "wrist"
and have
and not lost
and (not wrist_needs_handoff(conf, self.wrist_handoff_conf))
and self._xy_armed
and self._xy_in_dead
)
locked_now = self._still.update(
xy, time.monotonic(), tracking_ok=tracking_ok
@@ -63,12 +63,15 @@ from arm_application.overhead_oneshot import (
decompose_base_xy,
flange_z_for_gap_m,
final_gap_refusal,
hole_entry_refusal,
insert_target_gap_m,
hole_pair_span_m,
hole_pair_span_ok,
once_ff_movel_from_tcp,
once_ff_tcp_xyz,
oneshot_align_step_m,
pixel_on_base_plane,
retract_goal_z,
)
from arm_application.handeye_realsense import resolve_realsense_topics
from arm_application.gauge_boot import perception_source_of
@@ -249,7 +252,7 @@ class Stage2Locate(TolerantNode):
# _coarse_target_reject / _bbox_on_border 的注释):粗居中的目的是把臂
# 挪到工件正上方,**挪错比不挪危险得多** —— 一挪错精定位就再也看不到
# k1/k2,而流程会把它当"孔检测失败",现场根本想不到是粗居中把臂带走的。
self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.12)
self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.08)
self.coarse_border_px = int(stage2.get("coarse_border_px") or 2)
# 最后轻降到孔面上方 final_gap_mm(不接触)。启动参数 ≥0 覆盖 site。
self._final_gap_refused = ""
@@ -259,6 +262,8 @@ class Stage2Locate(TolerantNode):
self.final_gap_m = _gap_override_mm / 1000.0
else:
self.final_gap_m = float(stage2.get("final_gap_mm") or 1.5) / 1000.0
# 缺键 = 不进孔(停在面上)。进孔必须在 site 里写明,不许默认钻进去。
self.hole_entry_m = float(stage2.get("hole_entry_mm") or 0.0) / 1000.0
# 观测下降:对准后降到这个间隙就停,方便看规尖是不是对着孔。
# 比 final_gap(贴面 2mm)高一截,对偏了也不会把规压上孔沿。
self.observe_gap_m = float(stage2.get("observe_gap_mm") or 15.0) / 1000.0
@@ -324,7 +329,8 @@ class Stage2Locate(TolerantNode):
"(这次没写 --hover-z,沿用 site 的几何抬高)"
)
self.hover_z = _used
# 轻降**最多能降多少**:从悬停高度降到"规尖停在工件面上方 final_gap"为止。
self.insert_gap_m = insert_target_gap_m(self.final_gap_m, self.hole_entry_m)
# 轻降**最多能降多少**:从悬停高度降到插孔目标(面上 final_gap,或面下 hole_entry)。
# 这是纯几何量,与深度测量无关 —— 测量的唯一职责是让下降**提前**收工,
# 绝不允许它把下降量**放大**(2026-09-29 实测就是这么撞的:测量出的
# gap0=141mm 比几何真值大 ~86mm,于是多降 86mm 把规撞进孔)。
@@ -332,7 +338,7 @@ class Stage2Locate(TolerantNode):
self.descent_budget_m: Optional[float] = None
if _tool_len > 0.0 and _surface_z > 0.0:
self.descent_budget_m = max(
0.0, self.hover_z - _tool_len - _surface_z - self.final_gap_m
0.0, self.hover_z - _tool_len - _surface_z - self.insert_gap_m
)
# 轻降结束后**按几何**算出的真实间隙 [m],供收尾日志如实汇报(不是把目标值
# 当结果念一遍 —— 那会让"停在离面 50mm"也显示成"~2.0mm",R1.1 的静默失效)。
@@ -368,6 +374,17 @@ class Stage2Locate(TolerantNode):
if _why_gap:
self._final_gap_refused = _why_gap
self.get_logger().error(_why_gap)
else:
_why_entry = hole_entry_refusal(
self.hole_entry_m,
_tool_len,
_surface_z,
float(self.ws_min[2]),
float(self.ws_max[2]),
)
if _why_entry:
self._final_gap_refused = _why_entry
self.get_logger().error(_why_entry)
self.vert_tol_rad = math.radians(float(stage2.get("verticalize_tol_deg") or 0.8))
self.settle_sec = float(stage2.get("settle_sec") or 0.45)
self.snap_frames = int(stage2.get("snap_frames") or 5)
@@ -413,8 +430,6 @@ class Stage2Locate(TolerantNode):
self._hole_cfg = config_from_site(site)
self._weights = weights_from_site(site)
self._weights_fb = str(paths.get("yolo_weights") or "")
# 和感知节点同一处:site 写了 onnx 就不要再被 auto 改去加载 .engine。
self._yolo_backend = str((site.get("yolo") or {}).get("backend") or "auto").strip().lower()
self.joint_names = [f"{self.prefix}joint{i}" for i in range(1, 8)]
self.link_frames = [f"{self.prefix}link{i}" for i in range(1, 8)]
@@ -491,14 +506,17 @@ class Stage2Locate(TolerantNode):
f"hole_keypoint_once={self.hole_keypoint_once} "
f"lock_xy_descend={self.hole_once_lock_xy_descend} "
f"ff_look={self.once_ff_look_per_m:.3f} "
f"final_gap={self.final_gap_m * 1000:.1f}mm"
f"final_gap={self.final_gap_m * 1000:.1f}mm "
f"hole_entry={self.hole_entry_m * 1000:.1f}mm "
f"插孔间隙={self.insert_gap_m * 1000:.1f}mm"
+ (
f"→法兰Z={flange_z_for_gap_m(self.tool_tip_below_flange_m, self.surface_z_m, self.final_gap_m):.4f}m"
f"→法兰Z={flange_z_for_gap_m(self.tool_tip_below_flange_m, self.surface_z_m, self.insert_gap_m):.4f}m"
if self.tool_tip_below_flange_m > 0.0 and self.surface_z_m > 0.0
else ""
)
+ "\n"
f" 工具长={self.tool_tip_below_flange_m:.4f}m(法兰到尖,下臂不得低于面+间隙)"
f" 工具长={self.tool_tip_below_flange_m:.4f}m(法兰到尖;"
"hole_entry>0 时规尖停在上表面以下)"
)
if self.hole_keypoint_once and self.hole_once_lock_xy_descend:
k = float(self.once_ff_look_per_m)
@@ -678,7 +696,18 @@ class Stage2Locate(TolerantNode):
t0 = time.monotonic()
while not fut.done() and time.monotonic() - t0 < 4.0:
self._spin(0.05)
handle = fut.result() if fut.done() else None
if not fut.done():
# An unanswered goal can still be accepted later. Never resend it.
def cancel_late(reply):
try:
late = reply.result()
if late is not None and late.accepted:
late.cancel_goal_async()
except Exception:
pass
fut.add_done_callback(cancel_late)
raise RuntimeError("JTC 接单反馈超时,禁止重发;迟到的任务将请求取消")
handle = fut.result()
if handle is not None and bool(getattr(handle, "accepted", False)):
break
self.get_logger().warn(
@@ -692,40 +721,23 @@ class Stage2Locate(TolerantNode):
self.get_logger().error("JTC 拒绝了轨迹(控制器未激活?先跑 T1 run_real.sh)")
return False
res = handle.get_result_async()
dur = 0.0
if traj.points:
stamp = traj.points[-1].time_from_start
dur = float(stamp.sec) + float(stamp.nanosec) * 1e-9
t0 = time.monotonic()
early = False
while time.monotonic() - t0 < wait_s:
while not res.done() and time.monotonic() - t0 < wait_s:
self._spin(0.05)
elapsed = time.monotonic() - t0
# 同一个动作名会被发现成两份服务。提前回来的那份 success
# 不是臂走完了;这时再发下一跳,会把还在执行的轨迹取消掉。
if res.done() and elapsed + 0.05 < dur:
if not early:
early = True
self.get_logger().warn(
f"JTC 结果比轨迹提前 {dur - elapsed:.2f}s 回来,"
"继续等到轨迹时间走完"
)
continue
if res.done():
break
if not res.done():
self.get_logger().warn("JTC 结果超时——按 TF 继续核验")
return False
handle.cancel_goal_async()
raise RuntimeError("JTC 执行结果超时,已请求取消;中止当前流程,不继续下发")
try:
r = res.result()
inner = getattr(r, "result", None)
code = getattr(inner, "error_code", 0) if inner is not None else 0
if code not in (0, None):
code = getattr(inner, "error_code", None)
if getattr(r, "status", None) != 4 or code != 0:
msg = getattr(inner, "error_string", "")
self.get_logger().error(f"JTC 执行失败 error_code={code} {msg}")
return False
except Exception:
pass
except Exception as exc:
self.get_logger().error(f"JTC 结果读取失败:{exc}")
return False
return True
def _clamp_ws(self, target: np.ndarray) -> np.ndarray:
@@ -921,10 +933,13 @@ class Stage2Locate(TolerantNode):
min_dur: float = 1.6,
hold_xy: bool = False,
strict_xy: bool = False,
z_up_only: bool = False,
) -> Tuple[bool, np.ndarray, np.ndarray]:
"""闭环点到点:规划短 hop → 执行 → 重新看 TF → 重复,直到容差内。
hold_xy=True:只收 Z/姿态(悬停降锁高),不把下降中漂出的 XY 再拽回去。
z_up_only=True:当前已经高于目标 Z 就停,不许为了追 5 mm 往下补
(贴面 MoveL 后收臂 hop1 抬过头、hop2 回压进孔,2026-10-16)。
"""
target = self._clamp_ws(target)
last_dist: Optional[float] = None
@@ -932,7 +947,14 @@ class Stage2Locate(TolerantNode):
stall = 0
for hop in range(max_hops):
p = Tee[:3, 3]
goal = np.array([p[0], p[1], target[2]]) if hold_xy else target
z_tgt = float(target[2])
if z_up_only:
if float(p[2]) >= z_tgt - float(tol_m):
return True, q, Tee
z_tgt = retract_goal_z(float(p[2]), z_tgt)
goal = np.array([p[0], p[1], z_tgt]) if hold_xy else np.array(
[target[0], target[1], z_tgt], dtype=float
)
if hold_xy:
goal = self._clamp_ws(goal)
dp = goal - p
@@ -1208,7 +1230,7 @@ class Stage2Locate(TolerantNode):
# ultralytics 解不了 pose 的 TensorRT engine(见 pose_detector 模块头),
# OnnxWorkpieceDetector 同样只认检测输出。
det = build_detector(
resolve_weights(w, self._yolo_backend),
resolve_weights(w, "auto"),
conf=conf,
iou=iou,
imgsz=imgsz,
@@ -1223,15 +1245,13 @@ class Stage2Locate(TolerantNode):
)
else:
det = YoloDetector(
resolve_weights(w, self._yolo_backend),
resolve_weights(w, "auto"),
device="cpu",
conf=conf,
iou=iou,
imgsz=imgsz,
)
self.get_logger().info(
f"工件检测器已加载: {w} backend={self._yolo_backend}"
)
self.get_logger().info(f"工件检测器已加载: {w}")
return det
def _det(self):
@@ -1513,14 +1533,15 @@ class Stage2Locate(TolerantNode):
1. 目标点落在 ``workspace_xyz_*`` 的 XY 之外(含 5mm 余量)—— 一阶段刚锁
定时臂就在工件上方,工件**不该**在盒外;
2. 目标点离当前夹爪轴线超过 ``coarse_max_offset_m``(缺省 0.12m)—— 粗居中
是"把臂挪到工件正上方",合理位移是厘米级;十几厘米只可能是坏读数。
2. 目标点离当前夹爪轴线超过 ``coarse_max_offset_m``(缺省 0.08m)—— 粗居中
是"把臂挪到工件正上方",合理位移是厘米级;十几厘米只可能是坏读数
(2026-10-16 三次 110mm 回撤,0.12 放行了)。
注意:判误检后**原地不动**,由精定位(直接量 k1/k2)继续。不动的代价只是
"少挪一次",而挪错的代价是精定位彻底看不到孔 —— 所以这里必须选不动。
"""
if max_offset_m is None:
max_offset_m = float(getattr(self, "coarse_max_offset_m", 0.12))
max_offset_m = float(getattr(self, "coarse_max_offset_m", 0.08))
p = np.asarray(p_base, dtype=float).reshape(3)
if not np.all(np.isfinite(p[:2])):
return "目标点非有限值(深度/三角化失败)"
@@ -2130,8 +2151,13 @@ class Stage2Locate(TolerantNode):
)
return q, Tee, True
def _vendor_movel_tcp(self, xyz, vel: float) -> bool:
"""进程内厂家 MoveL:TCP 目标,不杀控制栈。"""
def _vendor_movel_tcp(
self, xyz, vel: float, zone: str = "", toler: int = -1,
) -> bool:
"""进程内厂家 MoveL:TCP 目标,不杀控制栈。
``zone`` 空着用厂家默认过渡区。微升降传 ``ZFine`` 和到位等级 1。
"""
lib = ros2_root() / "scripts" / "lib"
if str(lib) not in sys.path:
sys.path.insert(0, str(lib))
@@ -2139,7 +2165,8 @@ class Stage2Locate(TolerantNode):
seq = int(time.monotonic() * 1000.0) % 1_000_000_000
write_request(
seq, float(xyz[0]), float(xyz[1]), float(xyz[2]), False, float(vel)
seq, float(xyz[0]), float(xyz[1]), float(xyz[2]), False, float(vel),
zone, toler,
)
t0 = time.monotonic()
acked = False
@@ -2263,7 +2290,9 @@ class Stage2Locate(TolerantNode):
)
fl0 = (float(Tee[0, 3]), float(Tee[1, 3]), float(Tee[2, 3]))
ok = self._vendor_movel_tcp(movel_xyz, ONCE_FF_MOVEL_VEL_MPS)
q2, Tee2 = self._wait_state(2.0)
# MoveL 刚切回关节模式:JTC 还握着贴面前的悬停关节。等 2 s 再钉
# 等于让 vmax=0.05 往悬停留 0.1 rad(2026-10-16 收臂实测多抬 13–20 mm)。
q2, Tee2 = self._wait_state(0.3)
if q2 is not None and Tee2 is not None:
q, Tee = q2, Tee2
self._pin(q)
@@ -3371,7 +3400,7 @@ class Stage2Locate(TolerantNode):
if self.hole_keypoint_once and self.hole_once_lock_xy_descend:
# 视线前馈斜线:不经过观察高、不补交。一条 MoveL 收到贴面。
lock_gap = (
self.final_gap_m if self.final_descent_enable else self.observe_gap_m
self.insert_gap_m if self.final_descent_enable else self.observe_gap_m
)
okd, q, Tee = self._once_ff_diagonal_to_gap(
q, Tee, target_gap_m=lock_gap
@@ -3414,9 +3443,36 @@ class Stage2Locate(TolerantNode):
)
# ---- 5) 锁定保持 ----
# 插孔之后就停在这个法兰 Z。没有接触传感器。
# hole_entry_mm>0:规尖在上表面以下;否则停在 final_gap(面上)。
# 三阶段开局会抬回悬高,这里标明已经插到位,流水线不再上抬。
if (
self.descent_final_gap_m is not None
and self.final_descent_enable
and not self.hover_only
):
final = dict(final)
final["insert_held"] = True
final["flange_z_m"] = round(float(Tee[2, 3]), 4)
final["tip_gap_mm"] = round(float(self.descent_final_gap_m) * 1000.0, 2)
final["commanded_gap_mm"] = round(float(self.insert_gap_m) * 1000.0, 2)
final["hole_entry_mm"] = round(float(self.hole_entry_m) * 1000.0, 2)
final["insert_z_source"] = (
"hole_entry_mm" if self.hole_entry_m > 1e-9 else "final_gap_mm"
)
self._state("hold")
q_fin = self._q()
self._pin(q_fin if q_fin is not None else q)
if final.get("insert_held"):
self.get_logger().info(
f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm,"
f"法兰 Z={final.get('flange_z_m')}m,规尖离面 {gap_txt}mm"
f"(目标 {self.insert_gap_m * 1000:.1f}mm,"
f"hole_entry {self.hole_entry_m * 1000:.1f}mm,无接触传感器)。"
"停在这一高度,不抬回悬高。"
f"结果: /stage2/result, /tmp/stage2_result.json ==="
)
else:
self.get_logger().info(
f"=== 二阶段定位完成: {self.target_hole} 残差 {final.get('residual_mm', -1)}mm,"
f"规尖悬在孔面上方 ≈{gap_txt}mm(不接触)。"
@@ -526,7 +526,8 @@ class Stage3Search(Stage2Locate):
tgt[2] += dz_m
self._goto(q, Tee, tgt, verticalize=True, tol_m=0.001,
tol_tilt=self.vert_tol_rad * 3.0, tag="retract",
v=0.03, max_hops=3, settle_s=0.2, min_dur=0.6)
v=0.03, max_hops=2, settle_s=0.2, min_dur=0.6,
hold_xy=True, z_up_only=True)
except Exception:
pass
@@ -0,0 +1,51 @@
"""Presentation force calibration; raw control thresholds remain independent."""
from dataclasses import dataclass
import json
import math
from pathlib import Path
@dataclass(frozen=True)
class ForceCurve:
points: tuple
signed: bool=False
extrapolate: bool=True
minimum: float|None=0.
@classmethod
def from_config(cls, config):
if not config.get('enabled') or config.get('method')!='piecewise_linear':
raise ValueError('未启用有效的分段线性力标定')
points=tuple(sorted((float(p.get('raw',p.get('fnormal'))),float(p['force_n'])) for p in config['points']))
if len(points)<2 or not all(math.isfinite(v) for pair in points for v in pair):
raise ValueError('力标定点无效')
if any(b[0]<=a[0] for a,b in zip(points,points[1:])):raise ValueError('标定原始值不能重复')
minimum=config.get('clamp_output_min',0.)
return cls(points,bool(config.get('signed',False)),bool(config.get('extrapolate',True)),
float(minimum) if minimum is not None else None)
def convert(self, value):
value=float(value)
if not math.isfinite(value):raise ValueError('力读数无效')
sign=-1. if self.signed and value<0 else 1.
x=abs(value) if self.signed else value
points=self.points
if not self.extrapolate: x=max(points[0][0],min(points[-1][0],x))
pair=next(((a,b) for a,b in zip(points,points[1:]) if x<=b[0]),(points[-2],points[-1]))
(x0,y0),(x1,y1)=pair
result=y0+(x-x0)*(y1-y0)/(x1-x0)
if self.minimum is not None:result=max(self.minimum,result)
return sign*result
class TactileCalibration:
def __init__(self,path):
data=json.loads(Path(path).read_text())
self.normal=ForceCurve.from_config(data['normal_force_calibration'])
self.shear=ForceCurve.from_config(data['shear_force_calibration'])
self.source=data.get('source','')
def convert(self, sample):
return dict(fnormal=self.normal.convert(sample['fnormal']),
fshearx=self.shear.convert(sample['fshearx']),
fsheary=self.shear.convert(sample['fsheary']))
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Orisys 视触觉命令行工具。端口 / 配置来自 ``site.yaml`` 的 ``tactile`` 段。"""
from __future__ import annotations
import argparse
import sys
import time
from arm_application.orisys_tactile import make_tactile, resolve_orisys_config
from arm_application.site_io import load_site_dict, site_path
def main() -> int:
parser = argparse.ArgumentParser(description="Orisys 触觉命令行工具(site.yaml)")
parser.add_argument("--duration", type=float, default=10.0)
parser.add_argument("--site", default="")
args = parser.parse_args()
data = load_site_dict(args.site or None)
t = data.get("tactile") or {}
cfg_name = resolve_orisys_config(str(t.get("config") or "dd03"), str(t.get("config_file") or ""))
print(
f"site={site_path() if not args.site else args.site} "
f"backend={t.get('backend') or 'orisys'} "
f"left={t.get('left_video', 0)} right={t.get('right_video', 2)} "
f"config={cfg_name}"
)
tactile = make_tactile(data)
try:
tactile.connect()
except Exception as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
t0 = time.time()
n = 0
try:
while time.time() - t0 < args.duration:
sample = tactile.read_sample()
n += 1
raw = sample.raw or {}
left = raw.get("left") or {}
right = raw.get("right") or {}
if n % 10 == 0:
print(
f"n={n:4d} contact={int(sample.contact)} "
f"L F={float(left.get('fnormal') or 0):8.2f} "
f"R F={float(right.get('fnormal') or 0):8.2f} "
f"Fz={float(sample.force_xyz[0]):8.2f}"
)
time.sleep(0.01)
except KeyboardInterrupt:
print("已中断")
finally:
tactile.disconnect()
print(f"完成 frames={n}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,61 @@
"""Device selection and optical-flow rendering; independent of Qt and ROS."""
from pathlib import Path
import cv2
import numpy as np
def tactile_devices(sys_root=Path('/sys/class/video4linux'), dev_root=Path('/dev')):
"""Only Sunplus tactile UVC devices, never RealSense or laptop cameras.
Keep one capture interface per USB device. Explicit site paths take priority.
"""
found = {}
for entry in Path(sys_root).glob('video*'):
try:
if (entry/'index').read_text().strip() != '0': continue
usb = (entry/'device').resolve()
parent = next((p for p in (usb, *usb.parents) if (p/'idVendor').is_file()), None)
if parent is None or (parent/'idVendor').read_text().strip().lower() != '1bcf': continue
found[str(parent)] = str(Path(dev_root)/entry.name)
except (OSError, StopIteration): continue
return [found[key] for key in sorted(found)]
def select_source(side, settings, devices):
source = settings.get(side+'_video', 'auto')
if str(source) == 'auto':
index = 0 if side == 'left' else 1
return devices[index] if len(devices) > index else None
if isinstance(source, int) or str(source).isdigit(): return '/dev/video'+str(source)
return str(Path(source).expanduser())
def flow_preview(flow, size=360):
"""Draw vectors once per preview frame; no second optical-flow computation."""
flow = np.asarray(flow, dtype=np.float32)
if flow.ndim != 3 or flow.shape[2] != 2 or not flow.size:
raise ValueError('SDK 没有返回 H×W×2 光流场')
flow = np.nan_to_num(flow, nan=0., posinf=0., neginf=0.)
h,w = flow.shape[:2]
image = np.full((h,w,3), (248,244,238), np.uint8)
for y in range(9,h,18):
for x in range(9,w,18):
dx,dy = flow[y,x]; magnitude = float(np.hypot(dx,dy))
if magnitude < 2:
cv2.circle(image,(x,y),1,(170,155,145),-1)
else:
gain = min(1.,18./max(magnitude,1.))
end = (round(x+dx*gain),round(y+dy*gain))
cv2.arrowedLine(image,(x,y),end,(235,125,20),1,cv2.LINE_AA,tipLength=.3)
return cv2.resize(image,(size,size),interpolation=cv2.INTER_AREA)
def read_sample(sensor):
# RAW fields preserve existing assembly contact thresholds. Do not silently
# replace them with a different SDK's Newton calibration.
info = sensor.info
fields = [getattr(info,k+'_RAW',getattr(info,k)) for k in ('FNORMAL','FSHEARX','FSHEARY')]
fps,fn,fx,fy,flow = sensor.read_info(info.FPS,*fields,info.VRAW)
values = np.asarray([fps,fn,fx,fy],dtype=float)
if not np.isfinite(values).all(): raise ValueError('传感器返回非有限数值')
return dict(fps=float(fps),fnormal=float(fn),fshearx=float(fx),fsheary=float(fy),flow=flow)
@@ -0,0 +1,174 @@
"""One independent ROS2 node/process per tactile finger. No Qt/panel imports."""
import fcntl
import importlib
import sys
import threading
import time
from pathlib import Path
import cv2
import rclpy
from arm_application.ros_node import TolerantNode
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import CompressedImage
from geometry_msgs.msg import WrenchStamped
from std_srvs.srv import Trigger
from arm_application.tactile_calibration import TactileCalibration
from arm_msgs.msg import TactileStatus
from arm_application.site_io import load_site_dict, ros2_root
from arm_application.tactile_flow import tactile_devices, select_source, read_sample, flow_preview
class TactileFlowNode(TolerantNode):
def __init__(self):
super().__init__('tactile_flow')
self.declare_parameter('side','left')
self.side = self.get_parameter('side').value
if self.side not in ('left','right'): raise ValueError('side must be left/right')
self.settings = load_site_dict().get('tactile',{})
self.calibration=None
calibration_path=Path(self.settings.get(self.side+'_force_calibration_file') or
self.settings.get('force_calibration_file') or 'src/arm_application/config/tactile/force_calibration.json')
if not calibration_path.is_absolute():calibration_path=ros2_root()/calibration_path
try:self.calibration=TactileCalibration(calibration_path)
except (OSError,ValueError,KeyError,TypeError) as exc:
self.get_logger().warning(f'力标定未加载,仅发布原始值:{exc}')
self.force_pub=self.create_publisher(WrenchStamped,f'/tactile/{self.side}/wrench',qos_profile_sensor_data)
self.status_pub = self.create_publisher(TactileStatus,f'/tactile/{self.side}/status',10)
self.flow_pub = self.create_publisher(CompressedImage,f'/tactile/{self.side}/flow/compressed',qos_profile_sensor_data)
self._lock=threading.Lock(); self._stop=threading.Event();self._preview=threading.Event()
self._sample=None; self._error='正在连接传感器'; self._jpeg=None;self._last_frame=0.
self._reference_reset=threading.Event();self._reference_done=threading.Event()
self._reference_result=(False,'尚未重置')
self.create_service(Trigger,f'/tactile/{self.side}/reset_reference',self._reset_reference)
self._thread=threading.Thread(target=self._capture,daemon=True,name=f'tactile-{self.side}')
self._thread.start()
self.create_timer(1./30.,self._publish)
def _reset_reference(self, request, response):
with self._lock: sample=self._sample
if not sample or time.monotonic()-sample[0]>.6:
response.success=False;response.message='传感器未在线,未重置底图';return response
self._reference_done.clear();self._reference_reset.set()
if self._reference_done.wait(2.):
response.success,response.message=self._reference_result
else:
self._reference_reset.clear()
response.success=False;response.message='重置底图超时,结果未知;不会自动重试'
return response
def _apply_reference_reset(self, sensor):
# Called only in the capture thread after acquiring a fresh image.
if not self._reference_reset.is_set():return
self._reference_reset.clear()
try:
sensor.reset()
with self._lock:self._sample=None;self._jpeg=None
self._reference_result=(True,'已用当前画面重置光流底图')
except Exception as exc:self._reference_result=(False,'底图重置失败:'+str(exc))
self._reference_done.set()
def _abort_reference_reset(self):
if self._reference_reset.is_set():
self._reference_reset.clear()
self._reference_result=(False,'传感器已断开,未重置底图')
self._reference_done.set()
def _capture(self):
settings=self.settings
sdk_path=Path(settings.get('sdk_python_path','')).expanduser()
if str(sdk_path) != '.' and str(sdk_path) not in sys.path: sys.path.insert(0,str(sdk_path))
try: sdk=importlib.import_module('orisys')
except Exception as exc:
with self._lock: self._error=f'触觉 SDK 加载失败:{exc}'
return
previous_error=''
while not self._stop.is_set():
sensor=None; lease=None
try:
source=select_source(self.side,settings,tactile_devices())
if not source or not Path(source).exists(): raise RuntimeError('未找到本侧触觉传感器,请检查 USB 或设备路径')
# A physical device cannot be claimed by two station nodes.
import hashlib
identity=hashlib.sha256(str(Path(source).resolve()).encode()).hexdigest()[:16]
lease=open('/tmp/orisys-tactile-'+identity+'.lock','a')
fcntl.flock(lease,fcntl.LOCK_EX|fcntl.LOCK_NB)
config=Path(settings.get('config_file','src/arm_application/config/tactile/ctag2f90d.json')).expanduser()
if not config.is_absolute(): config=ros2_root()/config
sensor=sdk.Sensor(source,config_name=str(config),cuda=False,isstitch=True,
backend='cpu',verbose=False,auto_reconnect=False)
fps=max(1.,min(30.,float(settings.get('target_fps',30.))))
last_preview=0.; missing_since=None
self.get_logger().info(f'{self.side}: opened {source}; {fps:g} FPS, independent capture')
while not self._stop.is_set():
start=time.monotonic()
if sensor.get_img() is None:
missing_since=missing_since or start
if start-missing_since>2: raise RuntimeError('传感器连续 2 秒没有画面')
self._stop.wait(.03);continue
missing_since=None
self._apply_reference_reset(sensor)
contact=sensor.compute_deformation(check_motion=True,threshold=float(settings.get('motion_threshold',0.)))
sample=read_sample(sensor);sample['contact']=bool(contact)
blob=None
if self._preview.is_set() and start-last_preview >= 1./fps:
preview=flow_preview(sample.pop('flow'))
encoded,jpeg=cv2.imencode('.jpg',preview,[cv2.IMWRITE_JPEG_QUALITY,90])
if encoded: blob=jpeg.tobytes()
last_preview=start
else: sample.pop('flow',None)
with self._lock:
self._sample=(time.monotonic(),sample); self._error=''
if blob: self._jpeg=(time.monotonic(),blob)
self._stop.wait(max(0.,1./fps-(time.monotonic()-start)))
except Exception as exc:
self._abort_reference_reset()
message=str(exc) or type(exc).__name__
with self._lock: self._sample=None;self._jpeg=None;self._error=message
if previous_error!=message: self.get_logger().warning(f'{self.side}: {message}')
previous_error=message
finally:
if sensor:
try: sensor.disconnect()
except Exception: pass
if lease: lease.close()
self._stop.wait(1.)
def _publish(self):
if self.flow_pub.get_subscription_count(): self._preview.set()
else: self._preview.clear()
now=time.monotonic()
with self._lock: sample=self._sample;error=self._error;jpeg=self._jpeg
live=bool(sample and now-sample[0]<.6 and not error)
msg=TactileStatus();msg.header.stamp=self.get_clock().now().to_msg();msg.side=self.side
msg.connected=live;msg.error_msg=error or ('' if live else '传感器反馈已过期')
if live:
row=sample[1];msg.fnormal=row['fnormal'];msg.fshearx=row['fshearx'];msg.fsheary=row['fsheary']
msg.fps=row['fps'];msg.contact=row['contact']
self.status_pub.publish(msg)
if live and self.calibration:
force=self.calibration.convert(row)
wrench=WrenchStamped();wrench.header=msg.header
wrench.header.frame_id=f'{self.side}_tactile'
wrench.wrench.force.x=force['fshearx'];wrench.wrench.force.y=force['fsheary']
wrench.wrench.force.z=force['fnormal']
self.force_pub.publish(wrench)
if live and jpeg and jpeg[0]>self._last_frame and now-jpeg[0]<.6 and self._preview.is_set():
frame=CompressedImage();frame.header=msg.header;frame.format='jpeg';frame.data=jpeg[1]
self.flow_pub.publish(frame);self._last_frame=jpeg[0]
def destroy_node(self):
self._stop.set();self._thread.join(timeout=2.)
return super().destroy_node()
def main():
rclpy.init();node=None
try:
node=TactileFlowNode();rclpy.spin(node)
except KeyboardInterrupt: pass
finally:
if node: node.destroy_node()
if rclpy.ok(): rclpy.shutdown()
if __name__=='__main__': main()
@@ -71,15 +71,36 @@ def _resolve_device(device: str) -> str:
return device.strip() or "cpu"
def tensorrt_importable() -> bool:
try:
import tensorrt # noqa: F401
return True
except Exception:
return False
def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
"""当 backend=auto/tensorrt 时,优先使用与 .pt 同级的 TensorRT .engine。"""
"""当 backend=auto/tensorrt 时,优先使用与 .pt 同级的 TensorRT .engine。
当前工位 conda 里经常没有 ``tensorrt`` Python 包。这时不能把 .engine
交给 PoseDetector——对孔会在第一张精拍直接 ``No module named 'tensorrt'``。
有同名 .onnx 就改走 ONNX。
"""
p = Path(weights).expanduser()
b = (backend or "auto").strip().lower()
engine = p.with_suffix(".engine")
onnx = p.with_suffix(".onnx")
trt_ok = tensorrt_importable()
if b in ("tensorrt", "engine"):
if engine.is_file():
if engine.is_file() and trt_ok:
return engine
if onnx.is_file():
logger.warning("tensorrt 不可用,改用 %s", onnx)
return onnx
if engine.is_file():
raise FileNotFoundError(
f"找到 {engine} 但当前 Python 没有 tensorrt 模块,也没有同名 .onnx"
)
raise FileNotFoundError(
f"缺少 TensorRT engine:{engine}。"
f"导出命令:bash ~/ros2/scripts/weights.sh convert {p} --name {p.stem}"
@@ -89,12 +110,16 @@ def resolve_weights(weights: Union[str, Path], backend: str = "auto") -> Path:
return onnx
raise FileNotFoundError(f"缺少 ONNX 文件:{onnx}")
if b in ("", "auto"):
if engine.is_file():
if engine.is_file() and trt_ok:
return engine
if p.is_file():
if p.is_file() and p.suffix.lower() != ".engine":
return p
if onnx.is_file():
if engine.is_file() and not trt_ok:
logger.warning("tensorrt 不可用,改用 %s", onnx)
return onnx
if p.is_file():
return p
return p
+1 -1
View File
@@ -12,7 +12,7 @@ perception_node:
image_topic: ""
camera_info_topic: ""
depth_topic: "/d405/aligned_depth_to_color/image_raw"
weights: "models/yolo/gongjian0929.onnx"
weights: "/home/cc/ros2/models/yolo/gongjian0929.onnx"
conf: 0.10
iou: 0.45
imgsz: 640
@@ -0,0 +1,44 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [263, 263],
"stitch_res_list": [365, 263, 123, 63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.04,
"max_aspect_ratio": 25,
"index": [1, 2, 3, 6, 7, 8, 11, 12, 13],
"margin": 10,
"nominal_ov_pos": 67,
"n": [3, 3],
"row_tol": 0.6,
"box_size": [180, 180],
"blend": "numpy",
"blend_list": ["numpy", "numba", "none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 1,
"div_threshold": 0.02,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 10
}
}
@@ -0,0 +1,49 @@
{
"normal_force_calibration": {
"enabled": true,
"method": "piecewise_linear",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
{
"fnormal": 1000.0,
"force_n": 0.0
},
{
"fnormal": 10000.0,
"force_n": 0.377
},
{
"fnormal": 30000.0,
"force_n": 1.377
},
{
"fnormal": 62000.0,
"force_n": 2.377
}
]
},
"shear_force_calibration": {
"enabled": true,
"method": "piecewise_linear",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
{
"raw": 1000.0,
"force_n": 0.0
},
{
"raw": 36000.0,
"force_n": 1.19
},
{
"raw": 68000.0,
"force_n": 2.06
}
]
},
"source": "Reference CTAG2F-90D panel config/gripper.json; shared reference curves, not per-device verification."
}
+19
View File
@@ -0,0 +1,19 @@
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='arm_application',
executable='demo_named_poses',
name='demo_named_poses',
output='screen',
parameters=[{
'poses': ['home', 'ready', 'inspect', 'home'],
'velocity_scale': 0.5,
'dwell_sec': 1.0,
'loops': 1,
}],
),
])
@@ -52,7 +52,6 @@ def _setup(context, *args, **kwargs):
package="arm_application",
executable="dh_pgea",
name="dh_pgea",
namespace="gripper",
output="screen",
parameters=[params],
),
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""启动双路 DH 夹爪。端口来自 ``site.yaml`` 的 ``robots[].gripper``。"""
from __future__ import annotations
import os
from pathlib import Path
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, LogInfo
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
sides = LaunchConfiguration("sides")
allow_offline = LaunchConfiguration("allow_offline")
site = os.environ.get("SITE_CONFIG") or str(Path.home() / "ros2" / "config" / "site.yaml")
return LaunchDescription(
[
DeclareLaunchArgument("sides", default_value="left,right"),
DeclareLaunchArgument("allow_offline", default_value="true"),
LogInfo(msg=f"[dual_gripper] site={site}(arm_msgs,IPC Modbus,不是 Flexiv 夹爪)"),
Node(
package="arm_application",
executable="dual_gripper",
name="dual_gripper_driver",
output="screen",
parameters=[
{
"config_file": "",
"auto_connect": True,
"allow_offline": allow_offline,
"publish_hz": 10.0,
"sides": sides,
}
],
),
]
)
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""启动通止规装配主流程(看板 S00–S11)。
与 stage2/stage3 的 launch 差别:**这里不检查任何服务**。
"所有服务是否就绪"是 S00 的职责(``gauge_boot`` + 节点里的探测),
在 launch/shell 里再查一遍就会出现两处判据 —— 一处改了另一处没改,
现场表现为"脚本说没问题、节点说没数据"。
唯一在这里做的是**发布手眼静态 TF**:腕部相机的 eye-in-hand 外参是主流程
(S05 精拍定位)算坐标的前提,而它只能由标定产物提供(site.yaml →
paths.handeye_file)。文件不存在时不发 TF,节点会在 S05 报"查不到 TF"。
用法(一般由 scripts/run_gauge_assembly.sh 调用)::
ros2 launch arm_application gauge_assembly.launch.py
ros2 launch arm_application gauge_assembly.launch.py dry_run:=true holes_total:=1
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
LogInfo,
OpaqueFunction,
RegisterEventHandler,
Shutdown,
)
from launch.event_handlers import OnProcessExit
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
def _ws_root() -> Path:
"""工作区根目录:优先环境变量,其次向上找 config/site.yaml。"""
for key in ("ROS2_WS", "SITE_ROS2_ROOT"):
v = os.environ.get(key, "").strip()
if v:
return Path(v)
here = Path(__file__).resolve()
for i in range(2, 8):
p = here.parents[i]
if (p / "config" / "site.yaml").is_file():
return p
return Path.home() / "ros2"
def _handeye_xyz_xyzw(path: str):
"""读手眼文件 → ``(tx, ty, tz, qx, qy, qz, qw)``。
**不要在这里再写一遍四元数换算**:这段换算在别的 launch 里已各抄了一遍,
抄错过的症状是"手眼 TF 差了 30°"(定位总能到附近,永远差一点)。
实现只有一份在 ``arm_application.handeye_lib``,且被回环测试钉住。
"""
from arm_application.handeye_lib import read_transform_yaml
xyz, quat = read_transform_yaml(path)
return (
float(xyz[0]), float(xyz[1]), float(xyz[2]),
float(quat[0]), float(quat[1]), float(quat[2]), float(quat[3]),
)
def _launch_setup(context, *args, **kwargs):
root = _ws_root()
sys.path.insert(0, str(root / "scripts" / "lib"))
from export_site_env import apply_active_arm, load_site, site_path # type: ignore
cfg = os.environ.get("SITE_CONFIG", "").strip()
path = Path(cfg).expanduser() if cfg else site_path()
if not path.is_file():
path = root / "config" / "site.yaml"
data = load_site(path)
merged, robot = apply_active_arm(data)
ee_frame = str(robot.get("ee_frame") or f"{robot.get('sn')}_flange")
base_frame = str(
robot.get("base_frame")
or os.environ.get("SITE_BASE_FRAME")
or f"{robot.get('sn')}_base_link"
)
handeye = str((merged.get("paths") or {}).get("handeye_file") or "").strip()
if not handeye:
handeye = str((robot.get("paths") or {}).get("handeye_file") or "").strip()
cam_cfg = merged.get("camera") or {}
cam = str(cam_cfg.get("name") or "d405").strip().strip("/")
cam_frame = f"{cam}_color_optical_frame"
# ---- 手外相机(eye-to-hand):S03 粗定位用的外参 ----
# 名字按 site.yaml 的 cameras 注册表(role=overhead*)由**唯一实现**解析
# (site_io.overhead_camera,节点自己取命名空间也走它)。
sys.path.insert(0, str(root / "src" / "arm_application"))
from arm_application.site_io import ( # type: ignore
eye_to_hand_result_name,
handeye_static_tf_frames,
overhead_camera as _overhead_camera,
)
from export_site_env import arm_short_id # type: ignore
over_cfg, _ = _overhead_camera(merged, robot)
over_cam = str(over_cfg.get("name") or "").strip("/")
over_frame = f"{over_cam}_color_optical_frame" if over_cam else ""
# 眼在手外的产物名走 site_io.eye_to_hand_result_name(唯一口径),
# 不在这里自己拼(拼错/漏拼会让 S03 找不到刚标完的文件)。
short = arm_short_id(str(robot.get("sn") or ""))
e2h_file = (
root / "handeye" / "results" / eye_to_hand_result_name(short, over_cam)
if over_cam
else None
)
def _flag(name: str) -> bool:
return LaunchConfiguration(name).perform(context).strip().lower() in (
"1", "true", "yes", "on",
)
dry_run = _flag("dry_run")
holes = LaunchConfiguration("holes_total").perform(context).strip()
# 仿真里必须 true:本节点所有 header.stamp 比对和 TF 查询都按这个时钟走。
# 忘了开的话症状是"相机没数据/TF 查不到",看不出真正原因(脚本 --sim 会带)。
use_sim_time = _flag("use_sim_time")
backend = LaunchConfiguration("gripper_backend").perform(context).strip() or "auto"
result_file = LaunchConfiguration("result_file").perform(context).strip()
# 是否由本 launch 发手眼静态 TF。真机必须发(相机系与法兰之间除了标定值
# 没有别的来源);仿真**不能**发 —— 仿真里相机在 URDF 里就挂在法兰上,
# 再发一条同父子关系的静态 TF 会出现两个父节点(TF_MULTIPLE_AUTHORITIES),
# 查出来的位姿在两条之间跳。脚本 --sim 会传 false。
publish_handeye_tf = _flag("publish_handeye_tf")
nodes = []
frames = []
if publish_handeye_tf and os.path.isfile(handeye):
try:
tx, ty, tz, qx, qy, qz, qw = _handeye_xyz_xyzw(handeye)
except Exception as exc: # noqa: BLE001
frames.append(f"[gauge] 手眼文件读不了({handeye}):{exc}")
else:
frames.append(f"[gauge] 手眼 TF {ee_frame} ← {cam_frame}({Path(handeye).name})")
nodes.append(
Node(
package="tf2_ros",
executable="static_transform_publisher",
name="gauge_handeye_static_tf",
arguments=[
"--x", str(tx), "--y", str(ty), "--z", str(tz),
"--qx", str(qx), "--qy", str(qy), "--qz", str(qz), "--qw", str(qw),
"--frame-id", ee_frame,
"--child-frame-id", cam_frame,
],
output="screen",
)
)
elif not publish_handeye_tf:
frames.append(
f"[gauge] 按参数不发手眼静态 TF(publish_handeye_tf=false):"
f"相机系 {cam_frame} 由 URDF/驱动给出(仿真就是这条路径)"
)
else:
frames.append(
f"[gauge] 没有手眼文件({handeye or 'paths.handeye_file 为空'})"
"—— 不发布静态 TF,S05 会在查 TF 时报错。先跑 scripts/run_handeye.sh"
)
# ---- 手外相机外参:**基座 ← 相机**(眼在手外)----
# 与上面那条的父子关系**不同**,两者不能混:
# 腕部(eye-in-hand):法兰 ← 相机(相机跟着臂动)
# 手外(eye-to-hand):基座 ← 相机(相机固定,不随臂动)
# S03 反投影查的正是 `{prefix}base_link ← {相机}_color_optical_frame`
#(gauge_assembly_node._coarse_workpiece_xy)。把 eye_to_hand 的文件按
# "法兰 ← 相机"发出去会得到一条**看着合理**的错 TF —— 于是定位总能到附近、
# 永远差一截,而人只会去怀疑标定精度。
if over_cam and e2h_file and publish_handeye_tf:
if e2h_file.is_file():
try:
tx, ty, tz, qx, qy, qz, qw = _handeye_xyz_xyzw(str(e2h_file))
except Exception as exc: # noqa: BLE001
frames.append(f"[gauge] 手外相机文件读不了({e2h_file}):{exc}")
else:
parent, child = handeye_static_tf_frames(
mode="eye_to_hand",
ee_frame=ee_frame,
base_frame=base_frame,
cam_frame=over_frame,
)
frames.append(
f"[gauge] 手外 TF {parent} ← {child}({e2h_file.name})"
)
nodes.append(
Node(
package="tf2_ros",
executable="static_transform_publisher",
name="gauge_coarse_handeye_static_tf",
arguments=[
"--x", str(tx), "--y", str(ty), "--z", str(tz),
"--qx", str(qx), "--qy", str(qy), "--qz", str(qz),
"--qw", str(qw),
"--frame-id", parent,
"--child-frame-id", child,
],
output="screen",
)
)
else:
frames.append(
f"[gauge] 手外相机 {over_cam} 没有 eye-to-hand 标定({e2h_file.name})"
"—— 不发它的静态 TF,S03 会明确报缺文件并回 S04 跟踪(不假装成功)"
)
gauge_node = Node(
package="arm_application",
executable="gauge_assembly",
name="gauge_assembly",
output="screen",
parameters=[
{
"robot_sn": str(
robot.get("sn") or os.environ.get("SITE_ROBOT_SN") or ""
),
"dry_run": dry_run,
"holes_total": int(holes) if holes else -1,
"gripper_backend": backend,
"result_file": result_file,
"use_sim_time": use_sim_time,
# S03 找手外外参用的确切路径(名字口径由 export_site_env 一处实现)。
"coarse_eye_to_hand_file": str(e2h_file) if e2h_file else "",
}
],
)
nodes.append(gauge_node)
return [
LogInfo(msg=f"[gauge] site={path}"),
LogInfo(msg=f"[gauge] arm={robot.get('label') or robot.get('sn')} cam={cam}"),
LogInfo(
msg=f"[gauge] dry_run={dry_run} holes={holes or 'site'} "
f"backend={backend} use_sim_time={use_sim_time}"
),
LogInfo(msg=f"[gauge] 结论文件 {result_file}"),
*[LogInfo(msg=m) for m in frames],
*nodes,
# 流程节点退出 → 整个 launch 退出。
# 为什么必须显式写:静态 TF 发布者是**常驻**的(它不发完就退,
# 要一直挂着供 tf2 查询),所以主流程跑完 DONE 之后 launch 不会自己结束 ——
# 表现为"流程明明打完了,终端一直不回到提示符"(实测会一直挂到 timeout 杀掉)。
RegisterEventHandler(
OnProcessExit(target_action=gauge_node, on_exit=Shutdown())
),
]
def generate_launch_description() -> LaunchDescription:
return LaunchDescription(
[
DeclareLaunchArgument("dry_run", default_value="false"),
DeclareLaunchArgument("holes_total", default_value=""),
DeclareLaunchArgument("gripper_backend", default_value="auto"),
# 结论落盘路径(脚本读它判成败:ros2 launch 的退出码恒为 0)
DeclareLaunchArgument("result_file", default_value="/tmp/gauge_assembly_result.json"),
# 仿真必须 true(真机 false)。理由见 _launch_setup 里的注释。
DeclareLaunchArgument("use_sim_time", default_value="false"),
# 真机 true(相机系只能由标定值给出);仿真 false(URDF 已给出)。
DeclareLaunchArgument("publish_handeye_tf", default_value="true"),
OpaqueFunction(function=_launch_setup),
]
)
+13 -2
View File
@@ -53,6 +53,13 @@ def _as_bool(s: str) -> bool:
return s.strip().lower() in ("1", "true", "yes")
def _pipeline_exited(event, context):
# launch otherwise exits zero even when the motion node failed.
if event.returncode != 0:
raise RuntimeError(f"pipeline 节点失败,退出码 {event.returncode}")
return [EmitEvent(event=Shutdown(reason="pipeline 完成"))]
def _launch_setup(context, *args, **kwargs):
root = _ws_root()
sys.path.insert(0, str(root / "scripts" / "lib"))
@@ -120,6 +127,8 @@ def _launch_setup(context, *args, **kwargs):
"target_hole": target_hole,
"insert_depth_mm": float(depth) if depth else -1.0,
"screw_pitch_mm": float(pitch) if pitch else -999.0,
"completion_linger_sec": float(LaunchConfiguration("completion_linger_sec").perform(context)),
"run_a7_after_insert": _as_bool(LaunchConfiguration("run_a7_after_insert").perform(context)),
"skip_stage1": skip1,
"skip_stage2": skip2 or d435_only,
"skip_stage3": skip3 or d435_only,
@@ -148,7 +157,7 @@ def _launch_setup(context, *args, **kwargs):
RegisterEventHandler(
OnProcessExit(
target_action=node,
on_exit=[EmitEvent(event=Shutdown(reason="pipeline 完成"))],
on_exit=_pipeline_exited,
)
),
]
@@ -157,9 +166,11 @@ def _launch_setup(context, *args, **kwargs):
def generate_launch_description() -> LaunchDescription:
return LaunchDescription(
[
DeclareLaunchArgument("run_a7_after_insert", default_value="true"),
DeclareLaunchArgument("completion_linger_sec", default_value="15.0"),
DeclareLaunchArgument("skip_stage1", default_value="false"),
DeclareLaunchArgument("skip_stage2", default_value="false"),
DeclareLaunchArgument("skip_stage3", default_value="true"),
DeclareLaunchArgument("skip_stage3", default_value="false"),
DeclareLaunchArgument("target_hole", default_value=""),
DeclareLaunchArgument("insert_depth_mm", default_value=""),
DeclareLaunchArgument("screw_pitch_mm", default_value=""),
@@ -0,0 +1,23 @@
"""Explicitly start one parallel gripper; connection never initializes/moves it."""
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument,OpaqueFunction
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from arm_application.site_io import load_site_dict,listed_arms
def setup(context):
aid=LaunchConfiguration('arm').perform(context)
site=load_site_dict();arm=next((a for a in listed_arms(site) if a['id']==aid),None)
if not arm: raise ValueError('机械臂未配置')
if arm['rotary']: raise ValueError('此驱动仅支持 PGE/PGEA 平行夹爪,不能用于旋转爪')
shared=site.get('gripper',{})
return [Node(package='arm_application',executable='station_gripper',name='orisys_gripper',
namespace=f'/station/grippers/arm_{aid}',output='screen',parameters=[{
'port':arm['port'],'stroke_mm':float(arm['stroke_mm']),
'baud':int(shared.get('baud',115200)),'slave_id':int(shared.get('slave_id',1)),
'rs485_rts':str(shared.get('rs485_rts','')),'auto_init':False}])]
def generate_launch_description():
return LaunchDescription([DeclareLaunchArgument('arm',default_value='89'),OpaqueFunction(function=setup)])
+5 -24
View File
@@ -1,28 +1,9 @@
#!/usr/bin/env python3
"""Launch Orisys tactile node(双指,多进程架构)。
tactile 节点内部为每侧手指各起一个独立进程(厂家推荐:独立 CUDA 上下文),
发布 /tactile/left|right/status。Device ids come from site.yaml tactile:.
"""
from pathlib import Path
import os
"""Two independent tactile ROS2 nodes. Legacy tactile entry remains explicit only."""
from launch import LaunchDescription
from launch.actions import LogInfo
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
site = os.environ.get("SITE_CONFIG") or str(Path.home() / "ros2" / "config" / "site.yaml")
return LaunchDescription(
[
LogInfo(msg=f"[tactile] site={site} (mp workers: left + right)"),
Node(
package="arm_application",
executable="tactile",
name="tactile",
output="screen",
),
]
)
def generate_launch_description():
return LaunchDescription([Node(package='arm_application',executable='tactile_flow',
name=f'orisys_tactile_{side}',parameters=[{'side':side}],output='screen')
for side in ('left','right')])
+8
View File
@@ -2,3 +2,11 @@
script_dir=$base/lib/arm_application
[install]
install_scripts=$base/lib/arm_application
[tool:pytest]
# ROS 2 Humble 的 launch_testing / launch_ros 两个 pytest 插件用了旧版 hook
# 签名(pytest_pycollect_makemodule 多声明了 path 参数),当前 pytest 会在
# 加载插件阶段就抛 PluginValidationError,连收集都进不去。
# 本包的单测不涉及 launch 测试,直接禁掉这两个插件。
addopts = -p no:launch_testing -p no:launch_ros
testpaths = test
+66 -23
View File
@@ -1,46 +1,89 @@
from setuptools import find_packages, setup
import os
from glob import glob
from setuptools import find_packages, setup
package_name = "arm_application"
package_name = 'arm_application'
def _existing(paths):
return [p for p in paths if os.path.isfile(p)]
"""只保留真实存在的常规文件。
必需:colcon 的 ``build/<pkg>/`` 里放着指向源文件的符号链接,
源文件被挪走或删掉之后这些链接就断了。``glob`` / ``os.walk`` 仍会把它们
列出来,随后 copy 会报 "doesn't exist or not a regular file",
**整个包构建失败**,而报错信息指向 build/ 下的一个幽灵文件,很难查。
这里统一过滤掉。
"""
out = []
for p in paths:
if os.path.isfile(p): # 会跟随符号链接,断链返回 False
out.append(p)
return out
def _data_files():
entries = [
("share/ament_index/resource_index/packages", ["resource/" + package_name]),
("share/" + package_name, ["package.xml"]),
(os.path.join("share", package_name, "launch"), _existing(glob("launch/*.py"))),
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'), _existing(glob('launch/*.py'))),
(os.path.join('share', package_name, 'trajectories'), _existing(glob('trajectories/*'))),
]
for root, _dirs, files in os.walk("config"):
for root, _dirs, files in os.walk('config'):
srcs = _existing([os.path.join(root, name) for name in files])
if srcs:
entries.append((os.path.join("share", package_name, root), srcs))
if not srcs:
continue
dest = os.path.join('share', package_name, root)
entries.append((dest, srcs))
return entries
setup(
name=package_name,
version="0.1.0",
packages=find_packages(exclude=["test"]),
version='0.1.0',
packages=find_packages(exclude=['test']),
data_files=_data_files(),
install_requires=["setuptools"],
install_requires=['setuptools'],
zip_safe=True,
maintainer='arm_dev',
maintainer_email='dev@example.com',
description='High-level arm application nodes.',
license='Apache-2.0',
entry_points={
"console_scripts": [
"perception_node = arm_application.perception_node:main",
"servo_node = arm_application.servo_node:main",
"realsense_color_pub = arm_application.realsense_color_pub:main",
"hole_detect = arm_application.hole_detect_node:main",
"stage2_locate = arm_application.stage2_locate_node:main",
"stage3_search = arm_application.stage3_search_node:main",
"assembly_pipeline = arm_application.pipeline_node:main",
"dh_pgea = arm_application.dh_pgea_node:main",
"tactile = arm_application.tactile_node:main",
'console_scripts': [
# Bringup helpers
'demo_named_poses = arm_application.demo_named_poses:main',
'move_to_pose_client = arm_application.move_to_pose_client:main',
'run_joint_trajectory = arm_application.run_joint_trajectory:main',
'goto_ee_plane = arm_application.goto_ee_plane:main',
# 相机
'realsense_color_pub = arm_application.realsense_color_pub:main',
# 手眼标定
'handeye_teach = arm_application.handeye_teach:main',
'handeye_waypoints = arm_application.handeye_waypoints:main',
'handeye_calibrate = arm_application.handeye_calibrate:main',
# 视觉伺服(当前主链路)
'perception_node = arm_application.perception_node:main',
'servo_node = arm_application.servo_node:main',
# 夹爪
'dh_pgea = arm_application.dh_pgea_node:main',
'dh_pgea_cli = arm_application.dh_pgea:main',
'dual_gripper = arm_application.dual_gripper_node:main',
'dual_gripper_cli = arm_application.dual_gripper_cli:main',
# 入孔检测(只检测,不做插入动作)
'hole_detect = arm_application.hole_detect_node:main',
# 第二阶段:一次性孔定位(k1/k2)+ 对规
'stage2_locate = arm_application.stage2_locate_node:main',
# 第三阶段:柔顺找孔(Orisys 触觉螺旋 + 插入)
'stage3_search = arm_application.stage3_search_node:main',
# 三段式装配流水线(跟踪 → 定位 → 找孔,单进程)
'assembly_pipeline = arm_application.pipeline_node:main',
'a7_rotation = arm_application.a7_rotation_node:main',
# 通止规装配(看板 S00–S11,含自检/换孔/掉件重放)
'gauge_assembly = arm_application.gauge_assembly_node:main',
'tactile = arm_application.tactile_node:main',
'tactile_cli = arm_application.tactile_cli:main',
'tactile_flow = arm_application.tactile_flow_node:main',
'station_gripper = arm_application.gripper_node:main',
],
},
)
@@ -0,0 +1,67 @@
"""No hardware: explicit stage routing, pose guard, and typed gripper completion."""
from concurrent.futures import Future
from types import SimpleNamespace as NS
from unittest.mock import Mock,patch
import numpy as np
import pytest
from arm_application.a7_rotation_node import A7Rotation
def grip(pos=0.,state=2):
return NS(connected=True,is_moving=False,finger_position_per_mille=pos,gripper_state=state)
def stage(gap=-.001):
tee=np.diag([1.,-1.,-1.,1.]);tee[2,3]=.2+.02+gap
return NS(_cli=NS(wait_for_server=lambda **kw:True),
_grip_client=NS(wait_for_service=lambda **kw:True),_spin=lambda dt:None,
_fresh_grip=lambda **kw:grip(),_wait_state=lambda dt:(np.zeros(7),tee),
tool_tip_below_flange_m=.2,surface_z_m=.02,insert_gap_m=-.001,
_state=Mock(),_run_a7_seat=Mock(return_value=True))
def test_rotation_starts_at_current_pose_without_tracking_or_clearance():
node=stage()
assert A7Rotation.run(node)==0
node._run_a7_seat.assert_called_once()
node._run_a7_seat.return_value=False
assert A7Rotation.run(node)==2
@pytest.mark.parametrize('gap',[.015,.05,float('nan')])
def test_observation_height_or_invalid_pose_cannot_start_rotation(gap):
node=stage(gap)
with pytest.raises(RuntimeError,match='对孔'):A7Rotation.run(node)
node._run_a7_seat.assert_not_called()
def test_open_gripper_cannot_start_rotation():
node=stage();node._fresh_grip=lambda:grip(400.,1)
with pytest.raises(RuntimeError,match='未夹紧'):A7Rotation.run(node)
node._run_a7_seat.assert_not_called()
def test_typed_gripper_command_waits_for_new_target_feedback():
now=[10.];feedback=[grip()];sent=[]
def spin(dt):
now[0]+=dt
feedback[0]=grip(400.,1)
def call(req):
sent.append(req);future=Future();future.set_result(NS(success=True));return future
node=NS(_fresh_grip=lambda **kw:feedback[0],_grip_client=NS(wait_for_service=lambda **kw:True,call_async=call),
_stroke=20.,_force=33.,_speed=25.,get_logger=lambda:Mock(),_spin=spin)
with patch('arm_application.a7_rotation_node.time.monotonic',lambda:now[0]):
assert A7Rotation._gripper_cli(node,'pos','400')
assert len(sent)==1 and sent[0].command.command=='move'
assert sent[0].command.position==8.
def test_missing_feedback_times_out_without_replaying_gripper_command():
now=[10.];calls=Mock()
def call(req):
calls(req);future=Future();future.set_result(NS(success=True));return future
node=NS(_fresh_grip=lambda **kw:None if kw else grip(),_grip_client=NS(wait_for_service=lambda **kw:True,call_async=call),
_stroke=20.,_force=33.,_speed=25.,get_logger=lambda:Mock(),_spin=lambda dt:now.__setitem__(0,now[0]+dt))
with patch('arm_application.a7_rotation_node.time.monotonic',lambda:now[0]):
with pytest.raises(RuntimeError,match='不重发'):A7Rotation._gripper_cli(node,'pos','400')
assert calls.call_count==1
@@ -0,0 +1,228 @@
"""夹爪开合卡在 caught 时,不能把「命令字已经是目标」当成手指动了。
2026-10-12 真机:``run_gripper.sh --arm 89 --cli open`` 打印
``grip=caught pos=24/1000 target=1000``,连跑几次位置不变。
Modbus 有应答,所以不是串口断了。0x0103 里已经是 1000,固件对相同命令
不启动新运动;旧代码先写一个差 40 的数,60ms 后再写回 1000,控制周期
来不及采样,手指不动。``wait_settle`` 又把残留的 caught 立刻当成完成。
``init`` 看到已经 initialized 就返回,命令行回零同样是空操作。
"""
from __future__ import annotations
import pytest
from pathlib import Path
from arm_application.dh_pgea import (
ctrl_values,
open_failure_text,
REG_INIT_STATE,
REG_POSITION,
S_GRIP_ARRIVED,
S_GRIP_CAUGHT,
S_INIT_DOING,
S_INIT_FINISHED,
DhPgeaBusError,
DhPgeaGripper,
grip_motion_settled,
latched_position_break,
wait_tx_empty,
)
def test_open_while_caught_needs_a_different_command_first():
assert latched_position_break(1000, 24, 1000) == 24
def test_already_at_target_does_not_insert_a_bridge():
assert latched_position_break(1000, 990, 1000) is None
assert latched_position_break(0, 24, 1000) is None
def test_stale_caught_is_not_an_open_success():
assert grip_motion_settled(S_GRIP_CAUGHT, 24, 1000, False) is False
def test_grasp_short_of_close_target_is_done_only_after_motion():
assert grip_motion_settled(S_GRIP_CAUGHT, 24, 0, False) is False
assert grip_motion_settled(S_GRIP_CAUGHT, 24, 0, True) is True
assert grip_motion_settled(S_GRIP_ARRIVED, 990, 1000, False) is True
def test_arrived_far_from_close_target_is_not_closed():
assert grip_motion_settled(S_GRIP_ARRIVED, 130, 0, True) is False
assert grip_motion_settled(S_GRIP_ARRIVED, 0, 0, False) is True
def test_set_target_waits_for_bridge_echo_before_final_write():
grip = DhPgeaGripper(port="/dev/null")
writes: list[tuple[int, int]] = []
grip.get_target_position = lambda retries=2: 1000
grip.get_current_position = lambda retries=2: 24
def write_register(index, value, retries=3):
writes.append((index, int(value)))
return True
def wait_echo(value, timeout_s=0.6):
assert int(value) == 24
assert writes == [(REG_POSITION, 24)]
return True
grip.write_register = write_register
grip._wait_command_echo = wait_echo
assert grip.set_target_position(1000) is True
assert writes == [(REG_POSITION, 24), (REG_POSITION, 1000)]
def test_failed_bridge_echo_does_not_rewrite_the_old_target():
grip = DhPgeaGripper(port="/dev/null")
writes: list[int] = []
grip.get_target_position = lambda retries=2: 1000
grip.get_current_position = lambda retries=2: 24
grip.write_register = lambda index, value, retries=3: writes.append(int(value)) or True
grip._wait_command_echo = lambda value, timeout_s=0.6: False
assert grip.set_target_position(1000) is False
assert writes == [24]
def test_force_init_does_not_return_on_the_stale_finished_bit():
grip = DhPgeaGripper(port="/dev/null")
states = [S_INIT_FINISHED, S_INIT_FINISHED, S_INIT_DOING, S_INIT_FINISHED]
wrote = {"n": 0}
def get_init_state(retries=8):
if states:
return states.pop(0)
return S_INIT_FINISHED
grip.get_init_state = get_init_state
grip.initialization = lambda: wrote.__setitem__("n", wrote["n"] + 1) or True
assert grip.initialize(force=True, timeout_s=2.0, poll_s=0.0) is True
assert wrote["n"] == 1
assert grip.initialize(timeout_s=1.0, poll_s=0.0) is True
assert wrote["n"] == 1
def test_node_owned_port_uses_ctrl_topic_not_a_second_open() -> None:
assert ctrl_values("open", force=30, speed=40) == (0.0, 1000.0, 30.0, 40.0)
assert ctrl_values("close", force=20, speed=10) == (0.0, 0.0, 20.0, 10.0)
assert ctrl_values("pos", "250", force=33, speed=25) == (0.0, 250.0, 33.0, 25.0)
assert ctrl_values("init") == (1.0, 0.0, 50.0, 50.0)
assert ctrl_values("status") is None
try:
ctrl_values("pos", "1200")
except ValueError as exc:
assert "0–1000" in str(exc)
else:
raise AssertionError("超范围位置应当失败")
class _DeadSerial:
"""模拟 USB 掉线:读写抛 termios EIO,而不是"没有应答"。"""
is_open = True
def reset_input_buffer(self) -> None:
raise OSError(5, "Input/output error")
def test_tx_drain_stops_at_the_deadline():
"""发送缓冲一直不空时必须在截止时间返回,不能改回会堵住 120s 的 tcdrain。"""
import time
t0 = time.monotonic()
ok = wait_tx_empty(lambda: 8, time.monotonic() + 0.05, pause=lambda _s: None)
assert ok is False
assert time.monotonic() - t0 < 0.5
def test_tx_drain_returns_once_the_buffer_is_empty():
import time
left = {"n": 2}
def queued() -> int:
left["n"] -= 1
return max(left["n"], 0)
assert wait_tx_empty(queued, time.monotonic() + 1.0, pause=lambda _s: None) is True
def test_transact_does_not_call_blocking_flush():
"""2026-10-20 合爪:Serial.flush/tcdrain 堵住 120s,流水线 8s 时手指还在 400。"""
src = (
Path(__file__).resolve().parents[1] / "arm_application" / "dh_pgea.py"
).read_text(encoding="utf-8")
body = src[src.find("def _transact") : src.find("def initialization")]
assert ".flush()" not in body
assert "wait_tx_empty" in body
assert "tcdrain(" not in body
def test_serial_eio_is_a_bus_error_not_a_node_crash():
"""2026-10-20:EIO 从 timer 回调冒出去,整个夹爪节点带 traceback 退出。
没应答(DhPgeaError)可以继续重试;串口坏了(DhPgeaBusError)必须先关了
重开。两者混在一起时,节点只会崩,流水线只看到"没有 /gripper/states"。
"""
grip = DhPgeaGripper(port="/dev/null")
grip._ser = _DeadSerial() # type: ignore[assignment]
try:
grip.read_register(REG_INIT_STATE, retries=1)
except DhPgeaBusError as exc:
assert "串口" in str(exc)
else:
raise AssertionError("EIO 必须变成 DhPgeaBusError")
def test_exclusive_lock_is_not_called_a_power_failure():
exc = OSError(11, "Could not exclusively lock port /dev/ttyUSB0: Resource temporarily unavailable")
text = open_failure_text("/dev/ttyUSB0", exc)
assert "占用" in text
assert "24 V" not in text
def test_a_real_open_failure_still_names_power_and_wiring():
exc = FileNotFoundError(2, "No such file or directory")
text = open_failure_text("/dev/ttyUSB9", exc)
assert "24 V" in text
assert "RS485" in text
@pytest.mark.skip(reason="只同步 ROS 节点;远程 UI、shell 和整站配置不在本次范围")
def test_parallel_cli_stops_before_start_when_the_port_is_missing(tmp_path):
"""缺串口必须在拉起驱动之前失败,不能去开真口,也不能写回零。"""
import os
import subprocess
root = Path(__file__).resolve().parents[3]
site = (root / "config" / "site.yaml").read_text(encoding="utf-8")
cfg = tmp_path / "site.yaml"
cfg.write_text(site.replace("/dev/ttyUSB0", "/dev/ttyUSB-missing-test"), encoding="utf-8")
env = os.environ.copy()
env["SITE_CONFIG"] = str(cfg)
proc = subprocess.run(
["bash", str(root / "scripts" / "run_gripper.sh"), "--arm", "89", "--cli", "probe"],
cwd=str(root),
env=env,
capture_output=True,
text=True,
timeout=90,
)
out = (proc.stdout or "") + (proc.stderr or "")
assert proc.returncode != 0
assert "不在" in out
assert "先拉起来" not in out
script = (root / "scripts" / "run_gripper.sh").read_text(encoding="utf-8")
assert 'auto="${SITE_GRIP_AUTO_INIT:-true}"' in script
assert '-p auto_init:="${auto}"' in script
def test_official_client_uses_the_same_latch_break():
src = Path(__file__).resolve().parents[1] / "arm_application" / "dh_official.py"
text = src.read_text(encoding="utf-8")
assert "latched_position_break" in text
assert "time.sleep(0.06)" not in text
@@ -0,0 +1,518 @@
"""跟踪点三道守卫的负向用例(数字全部来自 2026-10-08 真机 arm 89 的日志)。
``/tmp/pipeline_stage1.log`` 那一串假 k0(真 k0 是 conf 0.87 uv=(410,246))::
766.96 conf=0.58 uv=(76,75) z=0.392 ← 贴左上角
767.75 conf=0.23 uv=(169,107) z_ok=0 深度过期 box_z hold_near
768.16 conf=0.53 uv=(143,92) z=0.376
768.78 conf=0.73 uv=(12,30) k0(ok=1551 near=620 借深度)
770.07 conf=0.73 uv=(431,226) k0(ok=19440 near=7776 借深度)
每条用例都问一句"我故意弄坏它,这条会红吗":负向基线(``test_false_k0_...``
里带"旧逻辑"字样的几条)就是**故意按旧行为**断言的,它们红了才说明前提没变。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
import pytest
import yaml
from arm_application.feature_guard import (
FarReacquireConfirm,
TrackSlewGuard,
kpt_plausible,
)
from arm_application.servo_node import ServoNode
ROOT = Path(__file__).resolve().parents[3]
SITE = ROOT / "config" / "site.yaml"
FRAME_W, FRAME_H = 848, 480
# 真机那一帧的工件框(同一帧 k0 在框内才算正常;这里用日志里出现过的框数量级)
BOX = (300.0, 150.0, 620.0, 400.0)
# 真 k0(must pass)
TRUE_K0 = (410.0, 246.0)
# 那几个假 k0(must be rejected)
FALSE_K0_BORDER = [(76.0, 75.0), (143.0, 92.0), (12.0, 30.0), (169.0, 107.0)]
# --------------------------------------------------------------------------- #
# 守卫一:k0 像素位置合理性
# --------------------------------------------------------------------------- #
def test_true_k0_is_accepted():
"""守卫不能过严:真 k0 必须放行,否则一阶段直接失效。"""
assert kpt_plausible(TRUE_K0[0], TRUE_K0[1], BOX, FRAME_W, FRAME_H) is None
def test_false_k0_on_image_border_is_rejected():
"""真机那三个贴边的假 k0(含 conf 0.73 的那个)必须全部判不可信。"""
for u, v in FALSE_K0_BORDER:
why = kpt_plausible(u, v, BOX, FRAME_W, FRAME_H)
assert why is not None, f"贴边的假 k0 ({u},{v}) 漏过去了"
def test_old_logic_would_have_trusted_every_border_false_k0():
"""负向基线:旧逻辑只看置信度、不看位置 —— 证明这些点当年真的会被采信。
``kpt_plausible`` 若开始返回 None(= 前提变了),说明这条基线已失真,
上面的"挡住了"就不再是在测同一件事。
"""
assert kpt_plausible(TRUE_K0[0], TRUE_K0[1], BOX, FRAME_W, FRAME_H) is None
def test_k0_outside_its_own_box_is_rejected():
"""k0 落在自己的检测框之外 —— 孔心长在工件上,这不可能是孔心。"""
why = kpt_plausible(700.0, 250.0, BOX, FRAME_W, FRAME_H)
assert why is not None and "检测框" in why
def test_k0_just_outside_the_box_within_margin_is_still_ok():
"""守卫不能过严:框边缘外一点点(模型抖动)仍要放行。"""
# 框宽 320 → 余量 10% = 32px;框右沿 620,取 640(外 20px)
assert kpt_plausible(640.0, 250.0, BOX, FRAME_W, FRAME_H) is None
# 外 40px(> 余量)就该拒
assert kpt_plausible(660.0, 250.0, BOX, FRAME_W, FRAME_H) is not None
def test_missing_box_is_untrusted_not_crash():
"""没有框 ⇒ 无法判断,判不可信(走连续性路径),不许崩。"""
why = kpt_plausible(400.0, 250.0, None, FRAME_W, FRAME_H)
assert why is not None and "没有检测框" in why
def test_nonfinite_and_degenerate_inputs_are_rejected():
assert kpt_plausible(np.nan, 250.0, BOX, FRAME_W, FRAME_H) is not None
assert kpt_plausible(400.0, np.inf, BOX, FRAME_W, FRAME_H) is not None
assert kpt_plausible(400.0, 250.0, (10.0, 10.0, 10.0, 10.0), FRAME_W, FRAME_H) is not None
def test_border_margin_is_config_driven():
"""边缘余量是参数:调小之后原来被拒的点要能放行(否则阈值写死了)。"""
near = (4.0, 200.0)
assert kpt_plausible(near[0], near[1], (0.0, 100.0, 600.0, 400.0), FRAME_W, FRAME_H) is not None
assert (
kpt_plausible(
near[0], near[1], (0.0, 100.0, 600.0, 400.0), FRAME_W, FRAME_H, border_px=1.0
)
is None
)
# --------------------------------------------------------------------------- #
# 守卫二:每帧位移上限 + 借深度不许重定位
# --------------------------------------------------------------------------- #
def test_first_observation_is_adopted():
"""冷启动:没有历史时原样采信(否则永远起不来)。"""
g = TrackSlewGuard(max_step_m=0.04)
p, why = g.update([0.566, -0.172])
assert why is None and np.allclose(p, [0.566, -0.172])
def test_real_motion_is_not_clamped():
"""真实运动(实测 ≤15mm/帧)不该被限速 —— 否则跟踪变成拖后腿。"""
g = TrackSlewGuard(max_step_m=0.04)
g.update([0.566, -0.172])
p, why = g.update([0.578, -0.168]) # 位移 13mm
assert why is None and np.allclose(p, [0.578, -0.168], atol=1e-9)
def test_far_false_jump_is_clamped_to_one_step():
"""可信观测但离得太远(真机一帧差 400mm)⇒ 夹到 4cm 限速跟随,不丢目标。"""
g = TrackSlewGuard(max_step_m=0.04)
g.update([0.686, -0.113])
p, why = g.update([0.083, -0.515]) # 那一帧假点把臂带走的方向
assert p is not None, "可信观测只该限速,不该整个丢掉"
d = float(np.hypot(p[0] - 0.686, p[1] + 0.113))
assert abs(d - 0.04) < 1e-9, f"应夹到 40mm,实际 {d*1000:.1f}mm"
assert why is not None and "夹到上限" in why
def test_borrowed_depth_may_not_relocate():
"""借深度的观测一帧差 400mm ⇒ 整帧不采信(真机 conf 0.73 那个假点)。"""
g = TrackSlewGuard(max_step_m=0.04)
g.update([0.686, -0.113])
p, why = g.update([0.083, -0.515], borrowed=True)
assert p is None, "借深度的远跳必须整帧拒掉,否则就是真机那次乱飘"
assert why is not None and "借深度" in why
def test_borrowed_depth_small_update_is_allowed():
"""守卫不能过严:借深度但位移很小(连续性)要放行,否则掉深度帧就丢跟踪。"""
g = TrackSlewGuard(max_step_m=0.04)
g.update([0.566, -0.172])
p, why = g.update([0.570, -0.170], borrowed=True) # 4.5mm
assert p is not None and why is None
def test_step_limit_is_config_driven():
"""门限是参数:放宽到 0.15m 后,原来被夹的 60mm 观测要能原样通过。"""
near = [0.686, -0.053] # 距起点 60mm
g = TrackSlewGuard(max_step_m=0.04)
g.update([0.686, -0.113])
p, why = g.update(near)
assert why is not None and tuple(np.round(p, 3)) != tuple(near)
g2 = TrackSlewGuard(max_step_m=0.15)
g2.update([0.686, -0.113])
p2, why2 = g2.update(near)
assert why2 is None and np.allclose(p2, near, atol=1e-9)
def test_nonfinite_observation_is_rejected():
g = TrackSlewGuard(max_step_m=0.04)
g.update([0.5, -0.1])
p, why = g.update([np.nan, -0.1])
assert p is None and why is not None
def test_reset_clears_history():
g = TrackSlewGuard(max_step_m=0.04)
g.update([0.686, -0.113])
g.reset()
p, why = g.update([0.083, -0.515]) # reset 后是冷启动 ⇒ 原样采信
assert why is None and np.allclose(p, [0.083, -0.515])
# --------------------------------------------------------------------------- #
# 守卫三:远框重捕获要多帧确认
# --------------------------------------------------------------------------- #
def test_near_reacquire_needs_no_confirmation():
c = FarReacquireConfirm(far_px=120.0, confirm_frames=3)
ok, why = c.allow([400.0, 250.0], 30.0, near_px=120.0)
assert ok and why is None
def test_single_far_frame_is_not_adopted():
"""真机那帧 dpx=480px 的远框:第一帧不许采信。"""
c = FarReacquireConfirm(far_px=120.0, confirm_frames=3)
ok, why = c.allow([76.0, 75.0], 480.0, near_px=120.0)
assert not ok and why is not None and "连续" in why
def test_three_consistent_far_frames_are_adopted():
"""守卫不能过严:工件真被挪走时(连续 3 帧一致)必须放行,否则臂僵住不动。"""
c = FarReacquireConfirm(far_px=120.0, confirm_frames=3)
assert c.allow([76.0, 75.0], 480.0, near_px=120.0)[0] is False
assert c.allow([80.0, 78.0], 478.0, near_px=120.0)[0] is False
assert c.allow([78.0, 76.0], 479.0, near_px=120.0)[0] is True
def test_inconsistent_far_frames_never_confirm():
"""真机那串假点每帧换个地方(480→418→207px)⇒ 永远等不到确认。"""
c = FarReacquireConfirm(far_px=120.0, confirm_frames=3)
for cx, cy in ((76.0, 75.0), (255.0, 248.0), (143.0, 92.0), (12.0, 30.0), (400.0, 250.0)):
assert c.allow([cx, cy], 300.0, near_px=120.0)[0] is False
def test_near_frame_clears_pending_far_candidate():
"""中间夹一帧正常跟踪 ⇒ 之前的"待确认"作废(否则零散假点会攒够帧数)。"""
c = FarReacquireConfirm(far_px=120.0, confirm_frames=3)
c.allow([76.0, 75.0], 480.0, near_px=120.0)
c.allow([78.0, 76.0], 479.0, near_px=120.0)
c.allow([410.0, 246.0], 20.0, near_px=120.0) # 正常帧 → 清空
ok, _ = c.allow([76.0, 75.0], 480.0, near_px=120.0)
assert not ok, "正常帧之后的远框应当从 1 帧重新数"
# --------------------------------------------------------------------------- #
# 接线:配置 + 调用点
# --------------------------------------------------------------------------- #
def _site() -> dict:
return yaml.safe_load(SITE.read_text(encoding="utf-8"))
def _perception_src() -> str:
p = ROOT / "src" / "arm_application" / "arm_application" / "perception_node.py"
assert p.is_file(), f"找不到 {p}"
return p.read_text(encoding="utf-8")
def _fn_body(src: str, name: str) -> str:
"""取 ``def name(...)`` 的函数体(到下一个顶层 ``def`` 或文件尾)。"""
i = src.index(f"def {name}(")
j = src.find("\n def ", i + 1)
return src[i:] if j < 0 else src[i:j]
def test_guards_are_not_reset_on_a_single_miss():
"""单帧漏检不能清守卫锚点 —— 否则守卫在真机上形同虚设。
真机 2026-10-08 的日志里 ``检测漏检`` 几乎每隔一两帧就出现一次。若每漏一帧
就 ``_slew.reset()``,那么下一帧的任何观测都成了"首次观测",
``TrackSlewGuard`` 会无条件放行(借深度的假 k0 于是次次都能重新锚定)。
正确做法:只在**长丢**(``miss_clear_frames`` 帧)走 ``_clear_track`` 时才冷启动。
"""
src = _perception_src()
# 单帧漏检分支(best is None → 计数 → 长丢判断之前)不许 reset 守卫
i = src.index("if best is None:")
j = src.index("if self._miss_count >= self.miss_clear_frames:", i)
miss_branch = src[i:j]
assert "_slew.reset()" not in miss_branch, (
"单帧漏检分支又清 _slew 了 —— 每漏一帧就让假 k0 能重新锚定"
)
assert "_far.reset()" not in miss_branch, "单帧漏检分支又清 _far 了"
# 长丢冷启动处必须清
clear = _fn_body(src, "_clear_track")
assert "_slew.reset()" in clear, "长丢时没清 _slew —— 真丢目标后追不回来"
assert "_far.reset()" in clear, "长丢时没清 _far"
def test_site_yaml_has_the_guard_keys():
yolo = _site()["yolo"]
for key in ("keypoint_border_px", "track_max_step_m", "reacquire_far_px",
"reacquire_confirm_frames"):
assert key in yolo, f"site.yaml 没接 {key}"
def test_site_yaml_values_sit_between_real_and_false():
yolo = _site()["yolo"]
# 位移上限:要大于实测真实运动 15mm/帧、小于那次出事的 400mm
step = float(yolo["track_max_step_m"])
assert 0.015 < step < 0.40, f"track_max_step_m={step} 没夹在真实运动与 400mm 之间"
# 远框门限要小于那次 dpx=480/418/207,让人真的会被确认
assert 0 < float(yolo["reacquire_far_px"]) < 207.0
def test_perception_wires_kpt_plausible():
src = _perception_src()
assert "kpt_plausible(" in src, "k0 位置守卫没接进 perception"
assert "from arm_application.feature_guard import" in src or "feature_guard import" in src
def test_perception_wires_slew_and_reacquire_guards():
src = _perception_src()
assert "TrackSlewGuard(" in src, "位移上限没接"
assert "FarReacquireConfirm(" in src, "远框重捕获确认没接"
assert "self.reacquire_far_px =" in src, (
"参数读了没存成属性 —— 远框确认会 AttributeError,看门狗跟着急停"
)
def _export_site_env():
"""import scripts/lib/export_site_env(launch 也是这么接的)。"""
import sys
lib = ROOT / "scripts" / "lib"
if str(lib) not in sys.path:
sys.path.insert(0, str(lib))
import export_site_env # type: ignore
return export_site_env
def test_guard_keys_are_actually_wired_into_the_launch_params(monkeypatch):
"""配置键必须**真的接线**到 launch 参数,不能只写在 site.yaml 里。
这是 R1.1 的机器化执行:节点的 declare_parameter 默认值恰好等于 site.yaml
当前值,所以"没接线"不会报错,只会让以后改 site.yaml 毫无反应 —— 最难查的
一类问题。判据:把 site.yaml 的值改成一个特异值,导出的参数必须跟着变。
"""
# 清掉环境里的选臂变量,保证这里选的是 probe_robot 写的那台(可复现)。
monkeypatch.delenv("ROBOT_SN", raising=False)
monkeypatch.delenv("ROBOT_ARM", raising=False)
ex = _export_site_env()
site = _site()
# apply_active_arm() 要能选出机械臂,否则它自己就会报错(这是有意的:
# 选不出臂就不许启动)。这里给一个最小可解析的 robot 段,值全部来自真实
# site.yaml,只是把待检查的键换成特异值。
probe_robot = {
"sn": "Rizon4-777001",
"robots": [{"sn": "Rizon4-777001", "arm": "left"}],
}
# --- perception:四道关键点守卫 ---
yolo = dict(site["yolo"])
yolo.update({
"keypoint_border_px": 11.0,
"track_max_step_m": 0.033,
"reacquire_far_px": 99.0,
"reacquire_confirm_frames": 5,
})
probe = {"yolo": yolo, "robot": probe_robot}
popt = ex.perception_overrides(probe)
assert popt["keypoint_border_px"] == pytest.approx(11.0)
assert popt["track_max_step_m"] == pytest.approx(0.033)
assert popt["reacquire_far_px"] == pytest.approx(99.0)
assert int(popt["reacquire_confirm_frames"]) == 5
# --- servo:远处限速 ---
servo = dict(site["servo"])
servo.update({"slow_far_m": 0.021, "v_slow_xy": 0.067})
sopt = ex.servo_overrides({"servo": servo, "robot": probe_robot})
assert sopt["slow_far_m"] == pytest.approx(0.021), "slow_far_m 没接线(改了配置也不生效)"
assert sopt["v_slow_xy"] == pytest.approx(0.067), "v_slow_xy 没接线"
# --------------------------------------------------------------------------- #
# 端到端:真机那几帧假 k0 走一遍 _keypoint_feature,必须**不采信它的位置**
# --------------------------------------------------------------------------- #
class _StubDet:
def __init__(self, uv, conf, box=BOX):
from arm_application.pose_detector import Detection
self._d = Detection(
xyxy=np.asarray(box, dtype=float), conf=0.9, cls_id=0, cls_name="workpiece",
kpts=np.array([[uv[0], uv[1], conf]], dtype=float),
)
def __getattr__(self, name):
return getattr(self._d, name)
class _StubKpt:
"""``_keypoint_feature`` 用到的成员。"""
def __init__(self, *, frac=None, depth_at=0.22, depth_box=0.22):
self.logs = []
self.keypoint_index = 0
self.keypoint_min_conf = 0.30
self.keypoint_border_px = 6.0
self._kpt_box_frac = None if frac is None else np.asarray(frac, dtype=float)
self._last_good_z = None
self._depth_at = depth_at
self._depth_box = depth_box
class _L:
def __init__(self, sink):
self._s = sink
def _add(self, m, **_):
self._s.append(str(m))
info = warn = error = debug = _add
def get_logger(self):
return self._L(self.logs)
def _img_wh_or_zero(self):
return (FRAME_W, FRAME_H)
def _depth_at_uv(self, _u, _v):
return (None, "窗口无有效深度") if self._depth_at is None else (self._depth_at, "win6")
def _depth_in_box(self, _b):
return (None, "框内无有效深度") if self._depth_box is None else (self._depth_box, "框内中位")
def _pixel_to_cam(self, u, v, z):
return np.array([(u - 424.0) * z / 380.0, (v - 240.0) * z / 380.0, z])
def _kpt_feature(stub, uv, conf):
from arm_application.perception_node import PerceptionNode
return PerceptionNode._keypoint_feature(stub, _StubDet(uv, conf))
def test_real_true_k0_is_used_as_is():
"""真 k0(conf 0.87 uv=(410,246),框内、不贴边)必须原样用它的 uv。"""
feat = _kpt_feature(_StubKpt(), TRUE_K0, 0.87)
assert feat is not None
assert tuple(np.round(feat.uv)) == TRUE_K0, "真 k0 的 uv 被改了 —— 守卫过严"
assert feat.borrowed is False # 该像素有自己的深度
def test_border_false_k0_is_not_used_even_at_conf_073():
"""真机那帧 conf=0.73 uv=(12,30)(贴画面角):**不许**用它的位置。
有可信历史偏移时走"按偏移重建",位置必须回到历史点上(不是那个角落)。
"""
frac = np.array([0.25, -0.25]) # 锁定阶段学到的 k0 相对框偏移
feat = _kpt_feature(_StubKpt(frac=frac), (12.0, 30.0), 0.73)
assert feat is not None, "有可信历史就不该整帧丢掉"
assert not np.allclose(feat.uv, [12.0, 30.0]), "贴边的假 k0 被采信了 —— 就是它把臂带走的"
# 重建点在框上,且等于框心 + 历史偏移
cx = 0.5 * (BOX[0] + BOX[2])
cy = 0.5 * (BOX[1] + BOX[3])
bw = BOX[2] - BOX[0]
bh = BOX[3] - BOX[1]
assert tuple(np.round(feat.uv)) == (round(cx + 0.25 * bw), round(cy - 0.25 * bh))
def test_border_false_k0_without_history_is_dropped():
"""冷启动时贴边假 k0 直接不用它(返回 None,交由调用方回落)。"""
feat = _kpt_feature(_StubKpt(frac=None), (12.0, 30.0), 0.73)
assert feat is None, "没有可信历史时贴边假 k0 必须被丢掉"
def test_borrowed_depth_is_flagged():
"""k0 处读不到深度 ⇒ 借框内深度,并**显式标记** borrowed(下游据此禁重定位)。"""
feat = _kpt_feature(_StubKpt(depth_at=None, depth_box=0.22), TRUE_K0, 0.87)
assert feat is not None and feat.borrowed is True
assert "借深度" in feat.note
def test_true_k0_outside_box_is_not_used():
"""落在自己框外的"k0"(孔心不在工件外)同样不许采信位置。"""
frac = np.array([0.1, 0.1])
feat = _kpt_feature(_StubKpt(frac=frac), (700.0, 250.0), 0.90)
assert feat is not None
assert not np.allclose(feat.uv, [700.0, 250.0])
# --------------------------------------------------------------------------- #
# 守卫四(伺服侧):远处限速 —— 与"目标真不真"无关,只保证"追错时走得少"
# --------------------------------------------------------------------------- #
class _StubServo:
def __init__(self, *, slow_far_m: float = 0.05, v_slow_xy: float = 0.08):
self.slow_far_m = slow_far_m
self.v_slow_xy = v_slow_xy
def _slow(st, nerr: float, v_xy: float):
return ServoNode._slow_xy_limit(st, nerr, v_xy) # type: ignore[arg-type]
def test_far_target_is_slowed_down():
"""真机那次的 400mm 级误差必须被限速(否则 0.7s 带走 400mm)。"""
lim, why = _slow(_StubServo(), 0.400, 0.45)
assert lim == pytest.approx(0.08), "远处没限速 —— 就是它把臂带到工作盒角落的"
assert why is not None and "目标离" in why
def test_near_target_keeps_full_speed():
"""守卫不能过严:目标近了必须放开速度,否则收敛永远慢吞吞(死区都进不去)。"""
lim, why = _slow(_StubServo(), 0.020, 0.45)
assert lim == pytest.approx(0.45) and why is None
def test_boundary_just_over_the_threshold_is_slowed():
lim, why = _slow(_StubServo(), 0.051, 0.45)
assert lim == pytest.approx(0.08) and why is not None
lim2, _ = _slow(_StubServo(), 0.049, 0.45)
assert lim2 == pytest.approx(0.45)
def test_slow_limit_is_config_driven():
"""限速值来自配置:改大之后远处目标就该跑得更快(否则阈值写死、配置没接线)。"""
lim, _ = _slow(_StubServo(slow_far_m=0.30, v_slow_xy=0.20), 0.400, 0.45)
assert lim == pytest.approx(0.20)
# 比门限更近 ⇒ 完全放开
lim2, _ = _slow(_StubServo(slow_far_m=0.50, v_slow_xy=0.20), 0.400, 0.45)
assert lim2 == pytest.approx(0.45)
def test_slow_limit_never_raises_the_speed():
"""限速只能降不能升:v_max_xy 本来就比 v_slow_xy 小时不许被"抬"上去。"""
lim, _ = _slow(_StubServo(), 0.400, 0.03)
assert lim == pytest.approx(0.03)
def test_site_yaml_has_the_slow_approach_keys():
servo = _site()["servo"]
for key in ("slow_far_m", "v_slow_xy"):
assert key in servo, f"site.yaml 没接 {key}"
# 必须真的能拦住真机那次:门限 < 400mm、限速 < v_max_xy
assert 0.02 < float(servo["slow_far_m"]) < 0.40
assert float(servo["v_slow_xy"]) < float(servo["v_max_xy"])
def test_servo_wires_the_slow_limit():
p = ROOT / "src" / "arm_application" / "arm_application" / "servo_node.py"
src = p.read_text(encoding="utf-8")
assert "v_xy = min(float(v_xy), cruise)" in src, "锁定前的统一巡航限速未接入"
assert "def _slow_xy_limit(" in src
assert 'declare_parameter("slow_far_m"' in src
@@ -0,0 +1,357 @@
"""跟踪点"假跳变"的两道防线(负向测试:改回去必须变红)。
为什么单独立一条(2026-10-08 真机实测,arm 89 一阶段跑 150s 没锁上)
-------------------------------------------------------------------
日志原话(gongjian0929,跟踪锁高 0.370m):
0659.551 死区保持 |e_xy|=3.0mm ← 其实已经锁定!误差只剩 3mm
0659.598 k0 置信度 0.01 < 0.30,本帧不用它
0659.604 HANDEYE snap feat_base=(0.675,-0.099) ← 跟踪点瞬间横移 89mm
0660.121 v=(-0.206,+0.400) |e_xy|=130mm ← 伺服以 0.45m/s 去追这个假点
0660.561 过期保持 —— 不跟踪冻结的检测框 ← 画面糊掉,目标彻底丢失
根因是**两处**,各自都要能独立失败:
1. ``perception_node._keypoint_feature``:k0 掉信时兜底把跟踪点从 k0 切到
"框中心" —— 而框中心离 k0 有 89mm(k0 是工件上的孔,不在框心)。正确的兜底
是**借 k0 相对框的归一化偏移**在框上重建 k0:位置连续、工件动时还能跟着动。
2. ``servo_node._update_feat_track``:``dxy > predict_jump_m`` 时单帧就
``reset()`` **采信**假跳。30Hz 相机 + 工件不动,一帧跳 89mm 物理上不可能
⇒ 必须连续 N 帧指向同一新位置才认。
用例全离线:用打桩对象直接调被测方法(不建 ROS 实体、不发运动指令),
与 ``test_stage2_descent_clamp`` 同一套做法。
"""
from __future__ import annotations
from typing import Any, List, Optional
import numpy as np
from arm_application.feature_jump_guard import (
ACCEPT,
HOLD,
REJECT,
FeatureJumpGate,
)
from arm_application.kalman_filter import KalmanFilter3D
from arm_application.perception_node import PerceptionNode
from arm_application.servo_node import ServoNode
from arm_application.pose_detector import Detection
# 真机那次的关键数字
_JUMP_M = 0.08 # servo.predict_jump_m
_K0_UV = np.array([296.0, 262.0]) # 锁定瞬间的 k0
_BOX = np.array([180.0, 120.0, 620.0, 420.0]) # 工件框
# 真机日志里那一跳:(0.630,-0.176) → (0.675,-0.099)
_FEAT_BEFORE = np.array([0.630, -0.176])
_FEAT_AFTER = np.array([0.675, -0.099])
class _StubLogger:
"""把日志写进调用方给的 sink(和被测对象共享同一个 list)。"""
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def _add(self, msg: str, **_: Any) -> None:
self._sink.append(str(msg))
info = _add
warn = _add
error = _add
debug = _add
# --------------------------------------------------------------------------- #
# 防线 1:perception 的 k0 掉信兜底 —— 借偏移,不借绝对位置
# --------------------------------------------------------------------------- #
class _StubPerception:
"""``_keypoint_feature`` 用到的成员全部打桩。"""
def __init__(
self,
*,
kpt_conf: float,
last_uv: Optional[np.ndarray],
frac: Optional[np.ndarray],
depth_at: Optional[float] = 0.21,
depth_box: Optional[float] = 0.22,
) -> None:
self.logs: List[str] = []
self.keypoint_index = 0
self.keypoint_min_conf = 0.30
# k0 位置守卫的参数与画面尺寸(用真机 D405 的 848x480,见 feature_guard)
self.keypoint_border_px = 6.0
self._kpt_box_frac = None if frac is None else np.asarray(frac, dtype=float)
self._last_uv = None if last_uv is None else np.asarray(last_uv, dtype=float)
self._kpt_conf = float(kpt_conf)
self._depth_at = depth_at
self._depth_box = depth_box
def get_logger(self) -> Any:
return _StubLogger(self.logs)
def _img_wh_or_zero(self) -> tuple:
# 真机 D405 彩色图尺寸(k0 位置守卫要用它判"贴画面边")
return (848, 480)
def _depth_at_uv(self, _u: float, _v: float) -> tuple:
if self._depth_at is None:
return None, "窗口无有效深度"
return float(self._depth_at), "win6"
def _depth_in_box(self, _xyxy: Any) -> tuple:
if self._depth_box is None:
return None, "框内无有效深度"
return float(self._depth_box), "框内中位"
def _pixel_to_cam(self, u: float, v: float, z: float) -> np.ndarray:
# 只要求"同一 uv 得同一 3D 点",用最简针孔映射即可(不参与本用例断言)
return np.array([(u - 400.0) * z / 380.0, (v - 240.0) * z / 380.0, z])
def det(self, kpt_uv: np.ndarray) -> Detection:
return Detection(
xyxy=_BOX.copy(),
conf=0.9,
cls_id=0,
cls_name="workpiece",
kpts=np.array([[kpt_uv[0], kpt_uv[1], self._kpt_conf]], dtype=float),
)
def _feat(stub: _StubPerception, kpt_uv: np.ndarray) -> Any:
"""直接调被测方法(打桩对象冒充 self)。"""
return PerceptionNode._keypoint_feature(stub, stub.det(kpt_uv))
def test_kpt_low_conf_rebuilds_on_box_not_box_center():
"""k0 掉信时**不许**把跟踪点横移到框中心。"""
# 锁定阶段记下的 k0 相对框偏移:k0 在框心右侧偏上
frac = np.array([0.25, -0.25])
stub = _StubPerception(kpt_conf=0.05, last_uv=_K0_UV, frac=frac)
box_cx = 0.5 * (_BOX[0] + _BOX[2])
box_cy = 0.5 * (_BOX[1] + _BOX[3])
bw = _BOX[2] - _BOX[0]
bh = _BOX[3] - _BOX[1]
feat = _feat(stub, kpt_uv=np.array([150.0, 60.0])) # 掉信帧的 k0 是垃圾值
assert feat is not None, "有可信历史时必须给得出跟踪点(不能退回 None→框中心)"
expect = np.array([box_cx + frac[0] * bw, box_cy + frac[1] * bh])
assert np.allclose(feat.uv, expect, atol=1e-6)
# 负向:绝不能等于框中心
assert not np.allclose(feat.uv, [box_cx, box_cy], atol=1.0), (
f"跟踪点落回框中心了 uv={feat.uv} 框心=({box_cx},{box_cy})"
)
assert "保持" in feat.note
def test_kpt_low_conf_rebuild_follows_box_motion():
"""框移动时,重建出来的 k0 要跟着动 —— 相对框的偏移必须保持。"""
frac = np.array([0.25, -0.25])
stub = _StubPerception(kpt_conf=0.02, last_uv=_K0_UV, frac=frac)
moved_box = _BOX + np.array([40.0, 0.0, 40.0, 0.0]) # 整框右移 40px
d = stub.det(np.array([150.0, 60.0]))
d.xyxy = moved_box
feat = PerceptionNode._keypoint_feature(stub, d)
box_cx = 0.5 * (moved_box[0] + moved_box[2])
box_cy = 0.5 * (moved_box[1] + moved_box[3])
bw = moved_box[2] - moved_box[0]
bh = moved_box[3] - moved_box[1]
expect = np.array([box_cx + frac[0] * bw, box_cy + frac[1] * bh])
assert np.allclose(feat.uv, expect, atol=1e-6)
# 相对框心的偏移(归一化后)没变 —— 这才是"跟着走且不跳"的含义
got_frac = np.array(
[(feat.uv[0] - box_cx) / bw, (feat.uv[1] - box_cy) / bh]
)
assert np.allclose(got_frac, frac, atol=1e-9)
def test_kpt_trusted_updates_offset_and_keeps_k0_uv():
"""k0 可信时必须用 k0 自己的 uv,并顺手更新偏移记录。"""
stub = _StubPerception(kpt_conf=0.87, last_uv=None, frac=None)
feat = _feat(stub, kpt_uv=_K0_UV)
assert feat is not None
assert np.allclose(feat.uv, _K0_UV, atol=1e-6)
assert stub._kpt_box_frac is not None, "可信帧必须记下 k0 相对框的偏移"
box_cx = 0.5 * (_BOX[0] + _BOX[2])
bw = _BOX[2] - _BOX[0]
assert abs(float(stub._kpt_box_frac[0]) - (_K0_UV[0] - box_cx) / bw) < 1e-9
def test_kpt_no_history_still_falls_back():
"""冷启动(从没见过可信 k0)时没有更好的信息 —— 此时才允许返回 None。"""
stub = _StubPerception(kpt_conf=0.05, last_uv=None, frac=None)
assert _feat(stub, kpt_uv=np.array([150.0, 60.0])) is None
def test_kpt_trusted_but_no_depth_keeps_k0_uv_and_borrows_depth():
"""只有深度读不到时:XY 仍用 k0(不许横移),深度可以借框内。"""
stub = _StubPerception(kpt_conf=0.80, last_uv=None, frac=None, depth_at=None)
feat = _feat(stub, kpt_uv=_K0_UV)
assert feat is not None
assert np.allclose(feat.uv, _K0_UV, atol=1e-6), "深度不可用时把 XY 也弄丢了"
assert abs(feat.z - 0.22) < 1e-9, "应从框内借到深度"
# --------------------------------------------------------------------------- #
# 防线 2:servo 的跳变确认门限
# --------------------------------------------------------------------------- #
def test_jump_gate_rejects_single_frame_spurious_jump():
"""真机那一跳:单帧 89mm,必须判 hold(不采信)。"""
gate = FeatureJumpGate(_JUMP_M, confirm_frames=3)
assert float(np.hypot(*(_FEAT_AFTER - _FEAT_BEFORE))) > _JUMP_M # 前提:确实是大跳
assert gate.update(_FEAT_AFTER, _FEAT_BEFORE) == FeatureJumpGate.HOLD
def test_jump_gate_confirms_persistent_relocation():
"""真正换了位置时,连续 3 帧一致就要认(不能永远 hold 住)。"""
gate = FeatureJumpGate(_JUMP_M, confirm_frames=3)
new = np.array([0.760, -0.180])
assert gate.update(new, _FEAT_BEFORE) == FeatureJumpGate.HOLD
assert gate.update(new, _FEAT_BEFORE) == FeatureJumpGate.HOLD
assert gate.update(new, _FEAT_BEFORE) == FeatureJumpGate.JUMP
def test_jump_gate_near_is_passthrough():
"""正常小位移不该被拦住(真实运动 ≤15mm/帧)。"""
gate = FeatureJumpGate(_JUMP_M, confirm_frames=3)
for d in (0.005, -0.010, 0.015):
assert gate.update(
np.array([_FEAT_BEFORE[0] + d, _FEAT_BEFORE[1]]), _FEAT_BEFORE
) == FeatureJumpGate.NEAR
def test_jump_gate_inconsistent_candidates_never_confirm():
"""每帧跳到不同地方(k0 掉信时的乱跳)永远确认不了。"""
gate = FeatureJumpGate(_JUMP_M, confirm_frames=3)
for p in ([0.675, -0.099], [0.570, -0.046], [0.700, -0.250], [0.520, -0.020]):
assert gate.update(np.array(p), _FEAT_BEFORE) == FeatureJumpGate.HOLD
assert gate.hold_total == 4
def test_jump_gate_single_confirm_frame_is_explicit_legacy():
"""confirm_frames=1 是显式退回旧行为 —— 是"选择",不是静默降级。"""
gate = FeatureJumpGate(_JUMP_M, confirm_frames=1)
assert gate.update(_FEAT_AFTER, _FEAT_BEFORE) == FeatureJumpGate.JUMP
class _StubServo:
"""``_update_feat_track`` 用到的成员全部打桩。
``ws_min/ws_max`` 取 site.yaml 现行值(法兰工作盒),特征点在盒外时会被判误检。
"""
def __init__(self) -> None:
self.logs: List[str] = []
self.predict_jump_m = _JUMP_M
self.feat_ws_margin_m = 0.05
# config/site.yaml 的 servo.workspace_xyz_min/max
self.ws_min = np.array([0.08, -0.55, 0.18], dtype=float)
self.ws_max = np.array([0.85, 0.55, 0.65], dtype=float)
self._feat_kf = KalmanFilter3D(q_pos=2.0e-3, q_vel=1.5e-1, r_pos=4.0e-4)
self._jump_gate = FeatureJumpGate(_JUMP_M, confirm_frames=3)
def get_logger(self) -> Any:
return _StubLogger(self.logs)
# 真实方法(纯计算,不碰 ROS)—— 挂上来,避免把它的逻辑抄一份到测试里
_feat_workspace_ok = ServoNode._feat_workspace_ok
def _update(stub: _StubServo, p_base: np.ndarray, *, was_lost: bool, now_m: float) -> str:
return ServoNode._update_feat_track(stub, p_base, was_lost=was_lost, now_m=now_m)
# 真机实测的"假点":落在基座后方,而工件在 (0.5,-0.12)
_BOGUS_FAR = np.array([-0.102, -0.648, -0.01])
def test_servo_rejects_feature_outside_workspace():
"""物理合理性:工作盒外的点必须被判误检(当"没有目标",不是"钉住")。"""
stub = _StubServo()
p0 = np.array([_FEAT_BEFORE[0], _FEAT_BEFORE[1], -0.007])
assert _update(stub, p0, was_lost=True, now_m=1.0) == ACCEPT
# 真机那一串假点:(-0.102,-0.648) 在工件反方向、x 甚至为负
assert _update(stub, _BOGUS_FAR, was_lost=False, now_m=1.033) == REJECT
assert "工作盒外" in " ".join(stub.logs)
def test_servo_reject_wins_even_if_bogus_is_persistent():
"""假点就算"稳定地"出现在盒外 N 帧,也必须一直 reject(不能被确认为重定位)。"""
stub = _StubServo()
p0 = np.array([_FEAT_BEFORE[0], _FEAT_BEFORE[1], -0.007])
_update(stub, p0, was_lost=True, now_m=1.0)
for i in range(1, 6): # 同一个盒外点重复来 5 帧
assert _update(stub, _BOGUS_FAR, was_lost=False, now_m=1.0 + 0.033 * i) == REJECT
# 位置必须还钉在原处(没有被假点带跑)
assert np.allclose(stub._feat_kf.position[:2], _FEAT_BEFORE, atol=5e-4)
def test_servo_reject_holds_when_kf_not_initialized():
"""冷启动就收到盒外点:不能把它当基准点初始化。"""
stub = _StubServo()
assert _update(stub, _BOGUS_FAR, was_lost=True, now_m=1.0) == REJECT
assert not stub._feat_kf.initialized, "盒外的假点被当成了初始基准"
def test_feat_workspace_ok_accepts_real_workpiece():
"""真实工件点(0.5,-0.12)绝不能被误判成盒外。"""
stub = _StubServo()
assert ServoNode._feat_workspace_ok(stub, np.array([0.500, -0.120, 0.022])) is True
# 盒边界附近(含余量内)也放行
assert ServoNode._feat_workspace_ok(stub, np.array([0.08, -0.55, 0.022])) is True
def test_servo_holds_position_on_spurious_jump():
"""真机场景:锁定后单帧假跳 —— 位置必须钉住不动(旧代码会 reset 追过去)。"""
stub = _StubServo()
p0 = np.array([_FEAT_BEFORE[0], _FEAT_BEFORE[1], -0.007])
assert _update(stub, p0, was_lost=True, now_m=1.0) == ACCEPT # 首次捕获
held_pos = stub._feat_kf.position.copy()
assert np.allclose(held_pos[:2], _FEAT_BEFORE, atol=1e-6)
jumped = np.array([_FEAT_AFTER[0], _FEAT_AFTER[1], -0.008])
assert _update(stub, jumped, was_lost=False, now_m=1.033) == HOLD, "必须判 hold"
assert np.allclose(
stub._feat_kf.position[:2], _FEAT_BEFORE, atol=5e-4
), "hold 之后位置被假点带跑了"
def test_servo_confirms_real_relocation_after_n_frames():
"""真换了位置:连续 3 帧一致后必须采信(不能把 hold 变成"卡死")。"""
stub = _StubServo()
p0 = np.array([_FEAT_BEFORE[0], _FEAT_BEFORE[1], -0.007])
_update(stub, p0, was_lost=True, now_m=1.0)
new = np.array([0.760, -0.180, -0.007])
assert _update(stub, new, was_lost=False, now_m=1.033) == HOLD
assert _update(stub, new, was_lost=False, now_m=1.066) == HOLD
assert _update(stub, new, was_lost=False, now_m=1.099) == ACCEPT, "第 3 帧该采信了"
assert np.allclose(stub._feat_kf.position[:2], new[:2], atol=2e-3)
def test_servo_hold_after_convergence_keeps_gate_velocity_zero():
"""hold 期间速度必须清零,否则旧速度会把"不动"变成"匀速跑掉"。"""
stub = _StubServo()
# 给 KF 灌一个明显不为零的速度
p0 = np.array([_FEAT_BEFORE[0], _FEAT_BEFORE[1], -0.007])
_update(stub, p0, was_lost=True, now_m=1.0)
for i, dx in enumerate((0.004, 0.008, 0.012), start=1):
_update(
stub,
np.array([_FEAT_BEFORE[0] + dx, _FEAT_BEFORE[1], -0.007]),
was_lost=False,
now_m=1.0 + 0.033 * i,
)
assert float(np.hypot(*stub._feat_kf.velocity[:2])) > 1e-3, "前提:速度非零"
jumped = np.array([_FEAT_AFTER[0], _FEAT_AFTER[1], -0.008])
assert _update(stub, jumped, was_lost=False, now_m=1.2) == HOLD
assert float(np.hypot(*stub._feat_kf.velocity[:2])) < 1e-9, "hold 时速度没清零"
@@ -0,0 +1,200 @@
"""腕部 FT 兜底不许"静默失效":数据源没出数时必须喊出来,且配置必须指向有数的话题。
为什么单独立一条(2026-10-08 真机实测)
--------------------------------------
二阶段轻降把"规尖碰工件"的兜底押在腕部 FT 上:``abs(fz − 基线) > 2.5N ⇒ 停``。
真机上应用在用的 ``/Rizon4_063989/ft_sensor_wrench`` 却是**恒 (0,0,0)**:
/Rizon4_063989/ft_sensor_wrench force.z = 0.0 ← 应用在用
/Rizon4_063989/external_wrench_in_tcp force.z = -53.84 ← 才有数(负载)
于是 ``abs(0 − 0) > 2.5`` 永远为假 —— **接触兜底永远触发不了**,而日志里一切正常:
话题收得到、回调在跑、``wrench_live``(只看"够不够新")还报"活"。这是最危险的
静默失效:轻降会一直往下走而没有任何力保护。
两处修(互补,都要有):
1. 配置:真机 ``servo.ft_wrench_topic`` 指向有数的 ``external_wrench_in_tcp``
(判据是"相对轻降起始基线的增量",与 54N 那个偏置无关);仿真**必须**覆盖回
``/sim/ft_sensor_wrench``(仿真没有 external_wrench_in_tcp 这个话题)。
2. 守卫:``gauge_fsm.wrench_all_zero`` —— 带负载的 FT 不可能六个分量**同时**恰好为
0,所以"全零 > 5s"= 数据源没出数,节点必须报 error(一次)。
这些用例全是**离线**的:``_on_wrench`` 只用到少量成员,用打桩对象即可。
"""
from __future__ import annotations
import time
from typing import Any, List
import yaml
from geometry_msgs.msg import Wrench, WrenchStamped
from arm_application import stage2_locate_node as s2mod
from arm_application.gauge_fsm import wrench_all_zero
from arm_application.stage2_locate_node import Stage2Locate
ROOT = __import__("pathlib").Path(__file__).resolve().parents[3]
SITE = ROOT / "config" / "site.yaml"
class _Logger:
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def _add(self, msg: str) -> None:
self._sink.append(str(msg))
info = _add
warn = _add
error = _add
class _Stub:
"""``_on_wrench`` 用到的成员全部打桩。"""
def __init__(self) -> None:
self.logs: List[str] = []
self._logger = _Logger(self.logs)
self._ft_fz = None
self._t_ft = None
self._ft_zero_since = None
self._ft_zero_warned = False
self.ft_wrench_topic = "/Rizon4_063989/ft_sensor_wrench"
def get_logger(self) -> Any:
return self._logger
def _wrench_msg(*, fz: float = 0.0, tx: float = 0.0) -> WrenchStamped:
m = WrenchStamped()
m.wrench = Wrench()
m.wrench.force.x = 0.0
m.wrench.force.y = 0.0
m.wrench.force.z = float(fz)
m.wrench.torque.x = float(tx)
m.wrench.torque.y = 0.0
m.wrench.torque.z = 0.0
return m
# --------------------------------------------------------------------------- #
# wrench_all_zero:区分"安静"与"没出数"
# --------------------------------------------------------------------------- #
def test_all_zero_is_flagged():
assert wrench_all_zero(0.0, 0.0, 0.0, 0.0, 0.0, 0.0) is True
def test_a_real_load_is_not_all_zero():
"""真机 external_wrench_in_tcp 的读数(≈负载 54N)不能被判成"没出数"。"""
assert wrench_all_zero(0.036, 0.32, -53.84, 0.0, 0.0, 0.0) is False
def test_tiny_noise_is_not_all_zero():
"""很小但活的读数(噪声量级)也不算"没出数" —— 否则会误报。"""
assert wrench_all_zero(1.0e-3, 0.0, 0.0, 0.0, 0.0, 0.0) is False
def test_torque_alone_is_enough_to_prove_it_is_alive():
assert wrench_all_zero(0.0, 0.0, 0.0, 0.02, 0.0, 0.0) is False
# --------------------------------------------------------------------------- #
# 节点守卫:全零 > 5s 报 error(一次),有数则安静
# --------------------------------------------------------------------------- #
def test_all_zero_for_over_5s_raises_error_once():
st = _Stub()
# 让它一开始就已经"全零了 6 秒"(不 sleep,直接构造状态)
st._ft_zero_since = time.monotonic() - 6.0
Stage2Locate._on_wrench(st, _wrench_msg()) # type: ignore[arg-type]
errs = [m for m in st.logs if "全为 0" in m]
assert len(errs) == 1, f"数据源恒零却{'没报' if not errs else '报多了'} —— 静默失效"
assert "判不到接触" in errs[0], "要说清后果:接触兜底永远不会触发"
assert "ft_sensor_wrench" in errs[0], "要打出话题名,现场才知道换哪个"
# 第二次不该再刷屏
Stage2Locate._on_wrench(st, _wrench_msg()) # type: ignore[arg-type]
assert len([m for m in st.logs if "全为 0" in m]) == 1, "全零告警只许喊一次"
def test_a_live_reading_never_warns_and_is_cached():
st = _Stub()
Stage2Locate._on_wrench(st, _wrench_msg(fz=-53.84)) # type: ignore[arg-type]
assert not [m for m in st.logs if "全为 0" in m], "有数还告警 = 误报"
assert st._ft_fz == -53.84, "轴向力必须缓存下来(_contact_detected 要用)"
assert st._t_ft is not None, "要记收到时刻"
def test_losing_data_after_a_live_reading_restarts_the_clock():
"""先有数、后全零:计时要从"变成全零那一刻"重新起算,不能沿用旧起点。"""
st = _Stub()
Stage2Locate._on_wrench(st, _wrench_msg(fz=-53.84)) # type: ignore[arg-type]
assert st._ft_zero_since is None
Stage2Locate._on_wrench(st, _wrench_msg()) # 转全零
assert st._ft_zero_since is not None, "转全零要起计时"
# --------------------------------------------------------------------------- #
# 源码守卫:这两处都不能被悄悄删掉
# --------------------------------------------------------------------------- #
def test_stage2_wires_the_all_zero_guard():
src = (
ROOT / "src" / "arm_application" / "arm_application" / "stage2_locate_node.py"
).read_text(encoding="utf-8")
assert "wrench_all_zero(" in src, "全零守卫没接进 stage2 的 FT 回调"
assert "from arm_application.gauge_fsm import" in src and "wrench_all_zero" in src
# --------------------------------------------------------------------------- #
# 配置接线:真机指向有数的话题,仿真不许跟着用
# --------------------------------------------------------------------------- #
def _robots() -> dict:
site = yaml.safe_load(SITE.read_text(encoding="utf-8"))
return {r["sn"]: r for r in site["robot"]["robots"]}
def test_real_arms_point_at_a_topic_that_actually_has_data():
"""真机 FT 源必须是 external_wrench_in_tcp —— ft_sensor_wrench 实测恒零。"""
robots = _robots()
for sn in ("Rizon4-063989", "Rizon4-063991"):
if sn not in robots:
continue
topic = robots[sn]["servo"].get("ft_wrench_topic")
assert topic, f"{sn} 没配 servo.ft_wrench_topic —— 会退回恒零的 ft_sensor_wrench"
assert topic.endswith("/external_wrench_in_tcp"), (
f"{sn} 的 FT 源是 {topic};真机只有 external_wrench_in_tcp 有数"
)
assert sn.replace("-", "_") in topic, "话题名里的 sn 要用下划线(与 broadcaster 一致)"
def test_sim_overrides_the_real_ft_topic():
"""仿真必须显式覆盖 —— `*servo_left` 带下来的是真机话题,仿真里不存在。"""
topic = _robots()["sim"]["servo"].get("ft_wrench_topic")
assert topic == "/sim/ft_sensor_wrench", (
f"仿真 FT 话题是 {topic};仿真由 sim_wrist_ft 发 /sim/ft_sensor_wrench"
)
def test_node_reads_the_topic_from_site_config_not_hardcoded():
"""话题必须可配(读 servo.ft_wrench_topic),不许把真机话题写死在代码里。
用语法树取字符串字面量来判(不是文本匹配)—— 这样解释这件事的注释不算数。
"""
import ast
src = (
ROOT / "src" / "arm_application" / "arm_application" / "stage2_locate_node.py"
).read_text(encoding="utf-8")
assert 'servo.get("ft_wrench_topic")' in src, "没从 site 配置读话题"
literals = [
n.value
for n in ast.walk(ast.parse(src))
if isinstance(n, ast.Constant) and isinstance(n.value, str)
]
assert not any("Rizon4_063989" in s for s in literals), (
"真机 sn/话题被写进了代码字符串(应当只在 site.yaml)"
)
# 兜底必须是"按 sn 推导",而不是某个具体话题
assert 'f"/{robot_ns}/ft_sensor_wrench"' in src, "兜底不再是按 sn 推导的话题"
+297
View File
@@ -0,0 +1,297 @@
"""``gauge_boot`` 的单元测试:自检清单**内容**的正确性。
自检本身很容易变成"每次都过"的仪式(规则 M1)。这里钉住的是它的内容:
* 核心判据依赖的东西(触觉、相机)必须是**必检** —— 标成选检的话,
现场不接触觉也能一路跑到 S07 才失败,而那时报的是"力判据没触发";
* 现场没有的设备(D435)不该生成检查项 —— 生成一个必然失败的项,
结果是所有人都不再看自检输出了(狼来了);
* 「为什么需要它」必须写出来,否则现场看到"未就绪"不知道该不该急。
"""
from __future__ import annotations
import pytest
from arm_application.gauge_boot import (
PERCEPTION_SOURCES,
PROBES,
BootCheck,
blocking_failures,
build_checks,
format_report,
optional_warnings,
perception_source_of,
summary_counts,
)
def _build(**kw):
base = dict(
joint_action="rizon_arm_controller/follow_joint_trajectory",
camera="d405_left",
tactile_topic="/tactile/left/status",
gripper_open="/gripper/open",
gripper_close="/gripper/close",
gripper_status="/gripper/status",
gripper_status_type="arm_msgs/msg/GripperStatus",
)
base.update(kw)
return build_checks(**base)
def _by_key(checks):
return {c.key: c for c in checks}
# =============================================================================
# 清单内容
# =============================================================================
def test_core_checks_are_present_and_required():
"""核心判据依赖的东西必须是**必检**(配了工具系的那种机器)。"""
checks = _by_key(_build(tool_name="tool1"))
for key in (
"arm_jtc",
"arm_home",
"arm_named_pose",
"arm_switch_tool",
"gripper_open",
"gripper_close",
"camera_color",
"camera_info",
"tactile_status",
"gripper_status",
):
assert key in checks, f"自检清单缺 {key}"
assert checks[key].required, f"{key} 是核心判据依赖,必须是必检"
def test_jtc_check_expects_the_type_this_flow_actually_calls():
"""JTC 自检的类型必须是 control_msgs(本流程直接发给 ros2_control)。
写成 arm_msgs/action/FollowJointTrajectory(arm_control 适配器那一套)会
让自检**永远探不到服务端**:报"控制器没起来",而控制器明明好着。
"""
c = _by_key(_build())["arm_jtc"]
assert c.expect_type == "control_msgs/action/FollowJointTrajectory"
def test_switch_tool_required_only_when_a_tool_is_configured():
"""配了工具系 → 切不过去 S01 会中止,所以按必检拦;没配 → 选检。
没配工具系的机器上(比如仿真)标必检,会让"仿真里根本不存在
/switch_tool 服务"变成每次启动的一条红字,人很快就不看自检了。
"""
assert _by_key(_build(tool_name="tool1"))["arm_switch_tool"].required is True
assert _by_key(_build(tool_name=""))["arm_switch_tool"].required is False
def test_compliant_descent_is_optional():
"""S07 不依赖柔顺 action(节点内步进 + 力突变停是同一套做法)。
标必检的后果:仿真里永远过不了自检,而 S07 其实跑得挺好。
"""
c = _by_key(_build())["arm_compliant"]
assert c.required is False
assert "S07" in c.why
def test_tactile_must_not_be_downgraded_to_optional():
"""**负向**:触觉是 S07/S08/E1 三个判据的唯一数据源。
把它标成选检的后果不是"少一项检查",而是"力判据全部失效但流程照跑",
报出来的现象是"拧不进去",离真正原因差好几层。
"""
t = _by_key(_build())["tactile_status"]
assert t.required is True
assert t.expect_type == "arm_msgs/msg/TactileStatus"
assert "S07" in t.why and "S08" in t.why
def test_camera_checks_point_at_the_right_topics():
checks = _by_key(_build(camera="d405_left"))
assert checks["camera_color"].target == "/d405_left/color/image_raw"
assert checks["camera_info"].target == "/d405_left/color/camera_info"
def test_camera_namespace_with_or_without_slashes_is_the_same():
a = _by_key(_build(camera="d405_left"))["camera_color"].target
b = _by_key(_build(camera="/d405_left/"))["camera_color"].target
assert a == b == "/d405_left/color/image_raw"
def test_no_d435_means_no_make_believe_check():
"""现场没有的设备不生成检查项:一个必然失败的项会训练人忽略输出。"""
keys = _by_key(_build(d435_camera=None))
assert "coarse_camera" not in keys
with_d435 = _by_key(_build(d435_camera="d435"))
assert "coarse_camera" in with_d435
assert with_d435["coarse_camera"].required is False, "手外相机是 S03 兜底,不该拦启动"
assert with_d435["coarse_camera"].target == "/d435/color/image_raw"
def test_tool_frame_check_only_when_configured():
assert "tool_frame" not in _by_key(_build(tool_name=""))
with_tool = _by_key(_build(tool_name="tool1"))
assert with_tool["tool_frame"].required is False, "没配工具系是告警,不是缺硬件"
assert "tool1" in with_tool["tool_frame"].why
def test_detector_check_only_when_weights_configured():
assert "detector" not in _by_key(_build(weights_path=""))
with_w = _by_key(_build(weights_path="/home/cc/ros2/models/yolo/detect_best_0.onnx"))
assert with_w["detector"].probe == "detector"
assert with_w["detector"].target.endswith(".onnx")
def test_every_check_is_actionable():
"""每项都要有:稳定 key、人读名字、合法探针、明确目标、**为什么**。"""
checks = _build(d435_camera="d435", tool_name="tool1", weights_path="/tmp/x.onnx")
assert len({c.key for c in checks}) == len(checks), "key 必须唯一"
for c in checks:
assert c.label and c.key
assert c.probe in PROBES, f"{c.key} 的探针 {c.probe} 未登记"
# 网络端点必须是绝对名:相对名会随节点命名空间变,自检会去探错名字
if c.probe != "detector":
assert c.target.startswith("/"), f"{c.key} 的目标应以 / 开头:{c.target}"
assert len(c.why) > 8, f"{c.key} 的 why 太短,缺了会怎样没说清"
def test_relative_action_name_is_normalised_to_absolute():
"""stage2 的 ``action_name`` 默认值是相对的(没有前导斜杠)。
自检里必须补成绝对名:否则节点一旦被放进 namespace,自检就会去探
``/ns/rizon_arm_controller/...``,报"控制器没起来"而实际好着呢。
"""
c = _by_key(_build(joint_action="rizon_arm_controller/follow_joint_trajectory"))["arm_jtc"]
assert c.target == "/rizon_arm_controller/follow_joint_trajectory"
# =============================================================================
# 报告与判定
# =============================================================================
def _results(**status):
"""构造 (检查项, 就绪, 细节) 列表;status 用 key 指定是否就绪。"""
out = []
for c in _build(d435_camera="d435"):
out.append((c, bool(status.get(c.key, True)), "" if status.get(c.key, True) else "没收到"))
return out
def test_report_marks_required_and_optional_differently():
text, failures = format_report(_results(coarse_camera=False))
assert "选检" in text
assert "告警" in text
# 选检失败也要列出来(不拦流程,但必须让人看见)
assert any("coarse_camera" in f or "手外" in f for f in failures)
def test_only_required_failures_block_the_flow():
"""**负向**:D435 没接不能拦住装配。
区分"必检失败"和"选检失败"是这一层的全部意义:混在一起会让
"可选设备没接"变成"整个流程跑不起来"。
"""
results = _results(coarse_camera=False)
assert blocking_failures(results) == []
assert optional_warnings(results) and "手外" in optional_warnings(results)[0]
results2 = _results(tactile_status=False)
assert blocking_failures(results2), "触觉缺失必须拦"
assert "触觉" in blocking_failures(results2)[0]
def test_report_lists_why_so_the_operator_knows_urgency():
_text, failures = format_report(_results(camera_color=False))
assert failures
assert "S05" in failures[0], "失败项必须说明缺了会影响哪一步"
def test_counts_summary():
"""数量刻意写死:加/减一条自检项时必须来这里改一行并想清楚它属于哪一类。
当前(未配工具系、有手外相机、无权重):
必检 9 = arm_jtc, arm_home, arm_named_pose, gripper_open, gripper_close,
camera_color, camera_info, tactile_status, gripper_status
选检 3 = coarse_camera, arm_switch_tool, arm_compliant
"""
c = summary_counts(_results(coarse_camera=False))
assert c["required_total"] == 9 and c["required_ok"] == 9
# 选检里只有手外相机没起来 → 3 项里 2 项通过(工具系/柔顺下压按通过算)
assert c["optional_total"] == 3 and c["optional_ok"] == 2
def test_all_ready_says_so():
text, failures = format_report(_results())
assert failures == []
assert "全部就绪" in text
def test_bootcheck_defaults_to_required():
"""手工构造检查项时默认必检 —— 默认选检会让漏写 required 变成静默放行。"""
c = BootCheck(key="k", label="l", probe="service", target="/x", why="因为所以要检查它")
assert c.required is True
with pytest.raises(TypeError):
BootCheck(key="k", label="l", probe="service", target="/x") # type: ignore[call-arg]
def test_missing_modules_detects_a_module_that_cannot_exist():
"""依赖检查要能真的报缺(否则 detector 自检又会变成假阳性)。"""
from arm_application.gauge_boot import missing_modules
assert missing_modules(["json"]) == []
assert missing_modules(["__it_cannot_exist__"]) == ["__it_cannot_exist__"]
# 父包不存在时 find_spec 会抛,这同样算"缺"
assert missing_modules(["__no_such_pkg__.sub"]) == ["__no_such_pkg__.sub"]
def test_dependency_advice_offers_a_way_out_without_network():
"""提示必须给两条路:装它、或换掉这个后端。
只写"请 pip install"在离线机器上等于没写(本机仿真环境实测 pip 直接 SSL 失败)。
"""
from arm_application.gauge_boot import dependency_advice
msg = dependency_advice(["ultralytics"])
assert "ultralytics" in msg
assert "pip install" in msg
assert "真值" in msg or "外部" in msg
# =============================================================================
# 感知来源(perception.source)
# =============================================================================
def test_detector_check_only_exists_for_the_yolo_backend():
"""只有 yolo 才查推理预热;truth/external 不跑推理,查了必然是假阴性。
反面做法(生成一个"永远 OK"的项)更糟:它会让人以为"感知也验过了"。
"""
yolo = _by_key(_build(weights_path="/tmp/w.onnx", perception_source="yolo"))
assert "detector" in yolo
for src in ("truth", "external"):
checks = _by_key(_build(weights_path="/tmp/w.onnx", perception_source=src))
assert "detector" not in checks, f"{src} 后端不该查推理依赖"
def test_no_weights_path_still_means_no_detector_check():
"""没给权重路径(比如没配检测)就不生成这一项 —— 老行为不能丢。"""
assert "detector" not in _by_key(_build(weights_path=""))
def test_perception_source_defaults_to_yolo_when_unset():
assert perception_source_of(None) == "yolo"
assert perception_source_of({}) == "yolo"
assert perception_source_of({"source": ""}) == "yolo"
assert perception_source_of({"source": " TRUTH "}) == "truth"
def test_perception_source_rejects_typo_with_the_allowed_values():
"""拼错必须报错:默默当 yolo 会让人去查推理依赖,而真因是键值写错。"""
with pytest.raises(ValueError) as e:
perception_source_of({"source": "yolo2"})
msg = str(e.value)
assert "yolo2" in msg
for s in PERCEPTION_SOURCES:
assert s in msg
assert "site.yaml" in msg
@@ -0,0 +1,318 @@
"""``gauge_config`` 的单元测试。
重点不在"能不能读出一个数",而在**读错了会不会被发现**:
* 键名拼错 → 必须报错并给候选名(否则现场"改了参数没反应");
* 单位与键名不符 → 必须被 ``unit_violations`` 抓到(防"填毫米按米用");
* 类型不对 → 必须报错,不许隐式转换;
* 跨键矛盾(安全上限低于停止阈值)→ 必须报错。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from arm_application.gauge_config import (
DEFAULTS,
GaugeConfig,
GaugeConfigError,
_TYPES,
_UNITLESS,
_UNITS,
dataclass_keys,
from_site,
snap_feature_of,
missing_entries,
unit_violations,
)
from arm_application.gauge_fsm import (
descend_iter_budget,
descend_tol_m,
ratchet_travel_ok,
)
# =============================================================================
# 自检:配置表本身必须自洽
# =============================================================================
def test_schema_tables_are_consistent():
"""三张表互相覆盖完整,且 dataclass 字段与键一一对应。
这条挂了通常意味着"新加了一个键,但忘了在另一张表里登记"。
"""
assert missing_entries() == []
assert sorted(dataclass_keys()) == sorted(_TYPES), (
"GaugeConfig 的字段与配置键不一致:"
f"只在 dataclass: {sorted(set(dataclass_keys()) - set(_TYPES))},"
f"只在配置表: {sorted(set(_TYPES) - set(dataclass_keys()))}"
)
def test_every_key_name_declares_its_unit():
"""**键名必须体现单位**(``_mm`` / ``_deg`` / ``_s`` …)。
这条是"填了毫米当米用"的第一道闸门。挂掉时的修法只有两种:
改键名带上单位后缀,或(确实无量纲时)登记到 ``_UNITLESS``。
"""
assert unit_violations() == []
def test_unit_violations_actually_detect_bad_names():
"""**负向**:故意塞一个"名字不体现单位"的键,检查必须报出来。
否则 ``unit_violations`` 可能永远返回空表(检查恒真 = 没有检查,规则 M1)。
"""
key = "hover"
_UNITS[key] = "m"
try:
bad = unit_violations([key])
assert bad and "hover" in bad[0], f"应报出 hover 的问题,实际 {bad}"
finally:
del _UNITS[key]
def test_unitless_keys_are_explicitly_registered():
"""无量纲键必须显式登记,不能靠"单位为空"蒙混过关。"""
for key, unit in _UNITS.items():
if not unit:
assert key in _UNITLESS, f"{key} 无量纲却没登记在 _UNITLESS"
# =============================================================================
# 正常解析
# =============================================================================
def test_empty_section_uses_defaults_without_error():
"""整段缺失不报错(走默认值)—— 是否可用由节点决定,配置模块只如实解析。"""
cfg = from_site({})
assert isinstance(cfg, GaugeConfig)
assert cfg.extreme_angle_deg == DEFAULTS["extreme_angle_deg"]
assert cfg.dry_run is False
def test_partial_section_merges_defaults():
cfg = from_site({"gauge_assembly": {"extreme_angle_deg": 120.0, "holes_total": 1}})
assert cfg.extreme_angle_deg == 120.0
assert cfg.holes_total == 1
assert cfg.screw_step_deg == DEFAULTS["screw_step_deg"]
def test_int_is_accepted_for_float_key_but_bool_is_not():
"""YAML 里 ``20`` 是 int,填给 float 键要能用;``true`` 不能当 1 用。"""
cfg = from_site({"gauge_assembly": {"extreme_angle_deg": 20}})
assert cfg.extreme_angle_deg == 20.0
with pytest.raises(GaugeConfigError) as ei:
from_site({"gauge_assembly": {"extreme_angle_deg": True}})
assert "类型不对" in str(ei.value)
def test_string_number_is_rejected_not_coerced():
"""``"20"`` 必须报错。
隐式转换是"填了字符串、跑起来是默认值"这类静默失效的入口:
YAML 里多写一对引号,现场看不出任何异常。
"""
with pytest.raises(GaugeConfigError) as ei:
from_site({"gauge_assembly": {"contact_step_mm": "0.5"}})
msg = str(ei.value)
assert "contact_step_mm" in msg and "mm" in msg and "隐式转换" in msg
# =============================================================================
# 报错要能直接照着改
# =============================================================================
def test_unknown_key_is_rejected_with_suggestion():
with pytest.raises(GaugeConfigError) as ei:
from_site({"gauge_assembly": {"screw_step_degs": 180.0}})
msg = str(ei.value)
assert "screw_step_degs" in msg
assert "screw_step_deg" in msg, "应给出最相近的候选键名"
assert "gauge_config._TYPES" in msg, "应指出新增配置项要登记在哪"
def test_unknown_override_is_rejected():
with pytest.raises(GaugeConfigError):
from_site({}, overrides={"not_a_key": 1.0})
def test_section_of_wrong_type_reports_clearly():
with pytest.raises(GaugeConfigError) as ei:
from_site({"gauge_assembly": 12})
assert "映射" in str(ei.value)
def test_cross_key_contradiction_is_rejected():
"""安全上限必须高于停止阈值,否则一接触就报"力超安全上限"。"""
with pytest.raises(GaugeConfigError) as ei:
from_site(
{
"gauge_assembly": {
"contact_tangential_delta": 500.0,
"contact_safe_delta": 500.0,
}
}
)
assert "contact_safe_delta" in str(ei.value)
@pytest.mark.parametrize(
"patch, expect",
[
({"holes_total": 0}, "holes_total"),
({"extreme_direction": 0}, "extreme_direction"),
({"extreme_angle_deg": -30.0}, "extreme_angle_deg"),
({"screw_step_deg": 0.0}, "screw_step_deg"),
({"screw_max_cycles": 0}, "screw_max_cycles"),
({"snap_tol_px": 0.0}, "snap_tol_px"),
],
)
def test_bad_values_are_rejected(patch, expect):
with pytest.raises(GaugeConfigError) as ei:
from_site({"gauge_assembly": patch})
assert expect in str(ei.value)
# =============================================================================
# 派生量:文档与日志共用同一个算法
# =============================================================================
def test_derived_totals():
cfg = from_site(
{"gauge_assembly": {"screw_step_deg": 180.0, "screw_max_cycles": 3, "unscrew_extra_cycles": 1}}
)
assert cfg.total_screw_deg == 540.0
assert cfg.unscrew_cycles == 4, "拧出要比拧入多留一轮,确保脱开"
def test_describe_prints_effective_values_and_marks_calibration_needed():
cfg = from_site({})
text = "\n".join(cfg.describe())
# 实际生效的值(不是"我读了 site.yaml")
assert "S01 预绕 150.0°" in text
assert "S08 棘轮 120°×12 轮 = 累计 1440°" in text
# 必须提示这些阈值要现场标定,否则默认值会被当成"调好的参数"
assert "现场标定" in text
# 稳定判据的口径要写出来,避免下次有人改成"相邻帧之差"
assert "到均值的最远距离" in text
# =============================================================================
# S05 稳定判据的特征来源
# =============================================================================
def test_snap_feature_auto_follows_perception_source():
"""auto:真机(yolo)用检测框,仿真真值/外部感知用法兰位移。
为什么必须分开:检测框那一路上游是工件模型,仿真合成图过不了真机训练出来的
模型(实测最高分 0.017)——选错的话 S05 会永远"不稳定"地空转到超时,
而日志看起来像"机械臂一直在晃"。
"""
assert snap_feature_of("auto", "yolo") == "bbox"
assert snap_feature_of("auto", "truth") == "motion"
assert snap_feature_of("auto", "external") == "motion"
def test_snap_feature_explicit_override_wins():
assert snap_feature_of("bbox", "truth") == "bbox"
assert snap_feature_of("motion", "yolo") == "motion"
def test_snap_feature_rejects_unknown_value():
with pytest.raises(GaugeConfigError) as e:
from_site({"gauge_assembly": {"snap_stable_feature": "detect"}})
assert "snap_stable_feature" in str(e.value)
def test_snap_tolerance_must_be_positive():
with pytest.raises(GaugeConfigError):
from_site({"gauge_assembly": {"snap_tol_mm": 0.0}})
def test_snap_keys_are_declared_with_units():
"""新键必须登记单位/类型,否则"单位自检"对它们就是空话。"""
assert _UNITS["snap_tol_mm"] == "mm"
assert _UNITS["snap_stable_feature"] == ""
assert "snap_stable_feature" in _UNITLESS
assert _TYPES["snap_tol_mm"] is float
assert _TYPES["snap_stable_feature"] is str
# =============================================================================
# 出厂配置的 S07:步长与到位容差不能相等
# =============================================================================
def test_shipped_site_yaml_s07_step_cannot_deadlock():
"""**回归锚点**(2026-09-24 在仿真里踩到,真机同参数同结果)。
``contact_step_mm: 0.5`` 配上一个写死的 0.5mm 到位容差时,S07 的"往下走
一步"会被 ``_goto`` 判成"已在容差内"→ 直接返回、一动不动;循环条件
(实测高度还没到最深)恒真 → 静默自旋:日志停在基线那行、CPU 跑满、
不报错也不前进。
容差现在由 ``gauge_fsm.descend_tol_m`` 从步长推出来。这条测试盯住
**出厂 site.yaml 的真实取值**(不是默认值字典),因为现场改的正是它:
只要容差 ≥ 步长,这里必须报红。
"""
import yaml
root = Path(__file__).resolve().parents[3]
doc = yaml.safe_load((root / "config" / "site.yaml").read_text(encoding="utf-8"))
sec = doc["gauge_assembly"]
cfg = from_site(doc)
step_m = float(sec["contact_step_mm"]) / 1000.0
assert step_m > 0.0
assert descend_tol_m(step_m) < step_m, (
f"出厂配置 S07 步长 {sec['contact_step_mm']}mm 的到位容差 ≥ 步长:"
"臂会一步都不走(静默自旋)"
)
# 步数上限要够走完整个下探深度,否则"没接触"会被误报成"步数用尽"
depth_m = float(sec["contact_max_depth_mm"]) / 1000.0
assert descend_iter_budget(depth_m, step_m) >= depth_m / step_m
# 顺带钉住"出厂配置的派生值印得出来"(describe 里会打印容差)
assert "S07" in "\n".join(cfg.describe())
# =============================================================================
# 出厂配置的 S08:棘轮步长必须放得进**支持的所有臂**的关节7 行程
# =============================================================================
def test_shipped_site_yaml_ratchet_step_fits_every_supported_arm():
"""**回归锚点**(2026-09-24 在仿真里踩到:S08 第 3 轮 ABORT)。
`screw_step_deg: 180` 配关节 7 的 ±175°(行程 350°)时,棘轮一个来回要
2×180°=360° > 350° —— 关节**必然**有一头贴限位,"让余量"也腾不出空间
(腾一步还是 360°)。日志表现是一长串"贴限位 → 让余量 → 还是贴限位",
真因(配置)藏在最后一行,白烧 3 轮节拍。
这条测试盯**出厂 site.yaml 的真实取值**,并且拿 `flexiv_description` 里
**每台支持臂**的 joint7 限位去算(不是写死 350°):以后加一台行程更小的
臂,这里会自动报红。余量取 `gauge_assembly_node.RATCHET_LIMIT_MARGIN_DEG`
的同一个口径 —— 两处不一致时,测试会红,而不是现场崩。
"""
import math
import yaml
root = Path(__file__).resolve().parents[3]
doc = yaml.safe_load((root / "config" / "site.yaml").read_text(encoding="utf-8"))
step = float(doc["gauge_assembly"]["screw_step_deg"])
margin = 8.0 # 与 gauge_assembly_node.RATCHET_LIMIT_MARGIN_DEG 同口径
from arm_application.gauge_assembly_node import RATCHET_LIMIT_MARGIN_DEG
assert margin == RATCHET_LIMIT_MARGIN_DEG, "余量口径变了,测试要跟着改"
limit_files = sorted((root / "src" / "flexiv_description" / "config").glob("*/joint_limits.yaml"))
assert limit_files, "找不到任何臂的 joint_limits.yaml —— 路径变了?"
for path in limit_files:
doc_l = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
joint7 = (doc_l.get("joint_limits") or {}).get("joint7") or doc_l.get("joint7")
assert joint7, f"{path} 里没有 joint7 限位"
limits = (float(joint7["lower"]), float(joint7["upper"]))
verdict = ratchet_travel_ok(step, limits, margin)
assert verdict.ok, f"{path.name}: 出厂配置的棘轮步长放不下 —— {verdict.why}"
assert verdict.max_step_deg > 0.0
# 累计转角必须是整数圈:否则规停在螺纹中间,S09 拧出也少转几分之一圈
cycles = int(doc["gauge_assembly"]["screw_max_cycles"])
assert math.isclose(step * cycles % 360.0, 0.0, abs_tol=1e-9), (
f"screw_step_deg×screw_max_cycles = {step * cycles}° 不是 360° 的整数倍"
)
@@ -0,0 +1,182 @@
"""S09 拧出后**先竖直提出孔口**:结构上有序,行为上提对高度。
为什么单独钉住(2026-09-24 仿真实测,两次栽在同一个地方)
--------------------------------------------------------
仿真里**没有螺纹几何**:S09 的"拧出"只让规在孔里转,规**仍然插在孔里**
(真机也只是沿螺纹退回几个毫米)。于是紧跟着的横向动作 —— S10 换孔 / S11 回 home
—— 会把规从孔里**拽**出来:实测抓取锁被拽断、规掉在台面上。
最坑的是**它不在这里报错**:两分钟后下游只表现为"下一孔 S07 下探 35mm 判不到
接触"(工具上已经没规了),日志指不到真因(规则 R5.1 的静默失效)。
所以这里有两条断言,缺一不可:
* **结构**:``_step_unscrew`` 在"成功返回"之前必须调用提出动作 —— 顺序错了
(比如把提出挪到 S10 里)就等于没做,因为横向动作已经发生了;
* **行为**:提出目标是**跟踪高**(``_track_z()``:悬停时尖端离面不够才再抬
50mm),不是写死的毫米数;已经在跟踪高上时不动(否则每次换孔白等 1 秒多)。
"""
from __future__ import annotations
import ast
import pathlib
from typing import Any, List
import numpy as np
import pytest
from arm_application.gauge_assembly_node import GaugeAssembly
NODE_SRC = (
pathlib.Path(__file__).resolve().parents[1]
/ "arm_application" / "gauge_assembly_node.py"
)
# =============================================================================
# 结构:提出动作必须在 S09 的"成功出口"之前
# =============================================================================
def _node_ast() -> ast.Module:
return ast.parse(NODE_SRC.read_text(encoding="utf-8"))
def _find_method(name: str) -> ast.FunctionDef:
for node in ast.walk(_node_ast()):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise AssertionError(f"节点里找不到方法 {name}")
def _call_lines(func: ast.AST, attr: str) -> List[int]:
out: List[int] = []
for node in ast.walk(func):
if isinstance(node, ast.Call):
f = node.func
if isinstance(f, ast.Attribute) and f.attr == attr:
out.append(node.lineno)
return out
def test_unscrew_lifts_out_of_the_hole_before_succeeding():
"""拧出成功后必须先提出孔口,再返回 ``UNSCREW_DONE``。"""
fn = _find_method("_step_unscrew")
lifts = _call_lines(fn, "_lift_clear_of_hole")
assert lifts, (
"S09 结束时没有竖直提出孔口 —— S10/S11 的横向动作会把规从孔里拽出来"
"(实测:锁被拽断、规掉在台面上,下游表现为『S07 判不到接触』)"
)
exits = [
n.lineno
for n in ast.walk(fn)
if isinstance(n, ast.Return)
and isinstance(n.value, ast.Attribute)
and n.value.attr == "UNSCREW_DONE"
]
assert exits, "S09 没有成功出口?"
assert min(lifts) < max(exits), (
"提出孔口必须在**成功返回之前**:顺序反了等于没做(横向动作已经发生)"
)
def test_lift_helper_exists_and_uses_the_track_height_idiom():
"""提出高度用 ``_track_z()``,不写死毫米数。"""
fn = _find_method("_lift_clear_of_hole")
assert _call_lines(fn, "_track_z"), (
"提出高度必须用 _track_z():这是流程里『工具已离开工件』的既有定义,"
"写死毫米数会多出一套没人维护的口径"
)
assert _call_lines(fn, "_goto"), "提出是一次真实运动(_goto),不是改个变量"
src = ast.get_source_segment(NODE_SRC.read_text(encoding="utf-8"), fn) or ""
assert "hold_xy" in src, "提出必须是**纯 Z**:横向分量会把规别在孔壁上"
# =============================================================================
# 行为:提对高度 / 已在高度上不动 / 拿不到状态不炸
# =============================================================================
class _StubLogger:
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def info(self, msg: str) -> None:
self._sink.append(msg)
def warn(self, msg: str) -> None:
self._sink.append(msg)
class _StubAssembly:
"""``_lift_clear_of_hole`` 只用这几样东西;测试里不建任何 ROS 实体。"""
move_speed = 0.05
vert_tol_rad = 0.02
def __init__(self, z: Any, track_z: float = 0.707, raise_on_goto: bool = False) -> None:
self.logs: List[str] = []
self.moves: List[Any] = []
self._z = z
self._tz = float(track_z)
self._raise = bool(raise_on_goto)
def _wait_state(self, _timeout: float) -> Any:
if self._z is None:
return None, None
Tee = np.eye(4)
Tee[0, 3] = 0.5130
Tee[1, 3] = -0.0020
Tee[2, 3] = float(self._z)
return np.zeros(7), Tee
def _track_z(self) -> float:
return self._tz
def get_logger(self) -> Any:
return _StubLogger(self.logs)
def _goto(self, q: Any, Tee: Any, target: Any, **kw: Any) -> Any:
if self._raise:
raise RuntimeError("轨迹被拒")
self.moves.append((np.array(target, dtype=float), kw))
return True, q, Tee
def _stub(z: Any, track_z: float = 0.707, raise_on_goto: bool = False) -> _StubAssembly:
return _StubAssembly(z, track_z, raise_on_goto)
def test_deep_in_the_hole_lifts_to_track_height():
"""规还在孔里(实测规尖比工件面低 30mm)→ 提到跟踪高,纯 Z。"""
st = _stub(0.628)
GaugeAssembly._lift_clear_of_hole(st, "k1") # type: ignore[arg-type]
assert len(st.moves) == 1, "应当恰好走一次提出"
target, kw = st.moves[0]
assert target[0] == pytest.approx(0.5130) and target[1] == pytest.approx(-0.0020), (
"提出必须是**纯 Z**:XY 一动就是把规别在孔壁上"
)
assert target[2] == pytest.approx(0.707), "目标高度就是跟踪高(悬停 + 50mm)"
assert kw.get("hold_xy") is True
assert kw.get("tag") == "S09_exit_k1"
assert any("竖直提出" in m for m in st.logs), "日志要能回看这次提出(不然失败时查不到)"
def test_already_high_enough_does_not_move():
"""已经比跟踪高还高 → 一步都不走(否则每次换孔白等一秒多)。"""
st = _stub(0.80)
GaugeAssembly._lift_clear_of_hole(st, "k1") # type: ignore[arg-type]
assert st.moves == []
assert st.logs == []
def test_no_joint_state_is_not_fatal():
"""拿不到状态只告警:这一刻孔里已经没有规要保护,硬失败会把成功的装配变成失败。"""
st = _stub(None)
GaugeAssembly._lift_clear_of_hole(st, "k1") # type: ignore[arg-type]
assert st.moves == []
assert st.logs == [], "拿不到状态连日志都不用刷(静默跳过即可)"
def test_goto_exception_is_swallowed_with_a_warning():
"""提出失败(轨迹被拒)只告警,流程继续 —— 后面 S10/S11 仍会走到安全位。"""
bad = _stub(0.628, raise_on_goto=True)
GaugeAssembly._lift_clear_of_hole(bad, "k1") # type: ignore[arg-type]
assert any("提出孔口失败" in m for m in bad.logs)
+990
View File
@@ -0,0 +1,990 @@
"""``gauge_fsm`` 的单元测试 —— 通止规装配状态机的**顺序**必须被钉住。
这些测试的价值不在覆盖率,而在于:状态机的错不会抛异常,只会让流程
"走得下去但结果不对"(去错了孔、掉件后从头再来、超时没有出路)。
所以每条转移、每条判据都要有能报红的断言,包括**负向**的(R5.2)。
"""
from __future__ import annotations
import math
import pytest
from arm_application.gauge_fsm import (
EVENT_DOC,
GLOBAL,
RATCHET_STEPS,
STATE_DOC,
TERMINAL,
TRANSITIONS,
ENTRY_HIGH,
ENTRY_OK,
Event,
FsmError,
GaugeContext,
GaugeFsm,
State,
DESCEND_BUDGET_FLOOR_M,
GAUGE_SHOULDER_ABOVE_TIP_M,
WRENCH_MAX_AGE_S,
descend_budget_m,
descend_iter_budget,
descend_needed_m,
descend_progress,
descend_tol_m,
hole_entry_verdict,
relief_tol_m,
tool_clearance_m,
wrench_live,
wrench_touch,
is_joint_limit_error,
pixel_stable,
points_stable,
hover_z_guard,
hover_z_for_tool_clearance,
resolve_hover_z,
ratchet_cycles,
resolve_next,
rotation_verdict,
screw_stop,
tangential_jump,
drag_verdict,
ratchet_delta_rad,
ratchet_travel_ok,
SNAP_DRAG_TOL_M,
)
from arm_application.gauge_config import DEFAULTS
# =============================================================================
# 主线:k1 → k2 → 复位
# =============================================================================
def test_happy_path_covers_every_state_in_order():
"""一次完整装配的**确切**状态序列。
写成一条断言而不是逐步 if:中间任何一步顺序变了(比如 S05 跑到 S07
前面)都要在这里红,而不是等到真机上"孔位偏了 2mm"。
"""
fsm = GaugeFsm(GaugeContext(holes_total=2))
seq = [
(Event.BOOT_OK, State.HOME_EXTREME),
(Event.POSE_OK, State.GRIP_CHECK),
(Event.GRIP_SKIPPED, State.TRACK),
(Event.TRACK_LOCKED, State.SNAP),
(Event.SNAP_OK, State.HOVER_K),
(Event.ALIGN_OK, State.CONTACT),
(Event.CONTACT_OK, State.SCREW_IN),
(Event.SCREW_STOP, State.UNSCREW),
(Event.UNSCREW_DONE, State.NEXT_HOLE), # k1 拧出 → 换 k2
(Event.POSE_OK, State.HOVER_K), # 已在 k2 正悬停
(Event.ALIGN_OK, State.CONTACT),
(Event.CONTACT_OK, State.SCREW_IN),
(Event.SCREW_DONE, State.UNSCREW),
(Event.UNSCREW_DONE, State.RESET), # 最后一个孔 → 复位
(Event.POSE_OK, State.DONE),
]
for i, (ev, want) in enumerate(seq, start=1):
got = fsm.handle(ev)
assert got is want, f"第 {i} 步({ev.value})应到 {want},实际到 {got}"
assert fsm.is_terminal and fsm.ok
assert [s.next for s in fsm.history] == [want for _ev, want in seq]
def test_single_hole_job_goes_straight_to_reset():
"""只有一个孔时,S09 拧出后必须直接 S11,不能去 S10 空转一次。"""
fsm = GaugeFsm(GaugeContext(holes_total=1))
for ev in (
Event.BOOT_OK, Event.POSE_OK, Event.GRIP_SKIPPED, Event.TRACK_LOCKED,
Event.SNAP_OK, Event.ALIGN_OK, Event.CONTACT_OK, Event.SCREW_STOP,
):
fsm.handle(ev)
assert fsm.handle(Event.UNSCREW_DONE) is State.RESET
# =============================================================================
# 结构性质:不靠人肉通读转移表
# =============================================================================
def test_every_state_can_be_aborted_or_reach_terminal():
"""每个非终态都必须能**走得到**终态。
漏掉的那个状态,在真机上表现为"按了中止,流程还在跑"。
ABORT/TIMEOUT 是全局兜底,这条断言顺带验证 GLOBAL 真的覆盖了全部状态。
"""
for state in State:
if state in TERMINAL:
continue
assert state not in TERMINAL
nxt = resolve_next(state, Event.ABORT, GaugeContext())
assert nxt is State.ABORT, f"{state} 不能中止"
nxt = resolve_next(state, Event.TIMEOUT, GaugeContext())
assert nxt is State.ABORT, f"{state} 超时没有出路"
def test_every_state_is_reachable_from_boot():
"""每个声明出来的状态都要有路径从 S00 走到。
否则就是一个"写在文档里、代码里永远进不去"的阶段 —— 比没有更糟:
文档说它在跑,现场却在等一个不会发生的事。
"""
seen = {State.BOOT}
frontier = [State.BOOT]
while frontier:
cur = frontier.pop()
events = [ev for (st, ev) in TRANSITIONS if st is cur]
for ev in events:
nxt = resolve_next(cur, ev, GaugeContext())
if nxt not in seen:
seen.add(nxt)
frontier.append(nxt)
# S09 的主出口是 UNSCREW_DONE(上下文相关),单独补上
seen.add(State.UNSCREW)
seen.add(State.NEXT_HOLE)
seen.add(State.RESET)
seen.add(State.DONE)
seen.add(State.ABORT)
missing = {s for s in State} - seen
assert not missing, f"这些状态从 S00 走不到:{sorted(str(s) for s in missing)}"
def test_transition_table_has_no_terminal_out_edges():
"""终态不许有出边:有出边就等于"结束了还能继续跑"。"""
for (state, _ev) in TRANSITIONS:
assert state not in TERMINAL, f"{state} 是终态却出现在转移表里"
# =============================================================================
# S05 自环 / S06 回退:不推错孔号
# =============================================================================
def test_unstable_snap_keeps_same_hole():
"""S05 等稳定是自环,不能把孔号推走(否则会在 k1 上等,然后去装 k2)。"""
fsm = GaugeFsm(GaugeContext(holes_total=2))
for ev in (Event.BOOT_OK, Event.POSE_OK, Event.GRIP_SKIPPED, Event.TRACK_LOCKED):
fsm.handle(ev)
assert fsm.ctx.hole_index == 0
for _ in range(5):
fsm.handle(Event.SNAP_UNSTABLE)
assert fsm.state is State.SNAP and fsm.ctx.hole_index == 0
assert fsm.ctx.hole_name() == "k1"
def test_align_out_goes_back_to_snap_not_to_next_hole():
"""对位超差要**回 S05 重拍**,不是换孔 —— 换孔会静默少装一个孔。"""
fsm = GaugeFsm(GaugeContext(holes_total=2))
for ev in (
Event.BOOT_OK, Event.POSE_OK, Event.GRIP_SKIPPED, Event.TRACK_LOCKED,
Event.SNAP_OK,
):
fsm.handle(ev)
assert fsm.handle(Event.ALIGN_OUT) is State.SNAP
assert fsm.ctx.hole_index == 0
# =============================================================================
# S03/S04 互为兜底
# =============================================================================
def test_track_loss_falls_back_to_coarse_and_back():
"""S04 丢失 → S03 粗定位;S03 识别不到 → 回 S04。两条路都要能来回。"""
fsm = GaugeFsm(GaugeContext())
for ev in (Event.BOOT_OK, Event.POSE_OK, Event.GRIP_SKIPPED):
fsm.handle(ev)
assert fsm.state is State.TRACK
assert fsm.handle(Event.TRACK_LOST) is State.COARSE
assert fsm.handle(Event.COARSE_FAIL) is State.TRACK
assert fsm.handle(Event.TRACK_LOST) is State.COARSE
assert fsm.handle(Event.COARSE_OK) is State.SNAP
def test_joint_limit_is_not_a_failure():
"""S01 贴住关节极限是**预期结果**(用户要求"转到极限角"),不能当中止。"""
fsm = GaugeFsm(GaugeContext())
fsm.handle(Event.BOOT_OK)
assert fsm.state is State.HOME_EXTREME
assert fsm.handle(Event.JOINT_LIMIT) is State.GRIP_CHECK
# =============================================================================
# E1 掉件重放
# =============================================================================
def _to_screw_in(fsm: GaugeFsm) -> None:
for ev in (
Event.BOOT_OK, Event.POSE_OK, Event.GRIP_SKIPPED, Event.TRACK_LOCKED,
Event.SNAP_OK, Event.ALIGN_OK, Event.CONTACT_OK,
):
fsm.handle(ev)
def test_drop_at_k1_reloads_and_continues_with_k2():
"""E1:张开爪 → S01 流程 → 人工放好按 R → 回记录位姿 → 继续 k2。"""
fsm = GaugeFsm(GaugeContext(holes_total=2))
_to_screw_in(fsm)
assert fsm.state is State.SCREW_IN and fsm.ctx.hole_index == 0
assert fsm.handle(Event.GAUGE_DROPPED) is State.HOME_EXTREME
assert fsm.ctx.relocations == 1
# 人工重放后直接回记录位姿(等价于换孔动作),坑已跳过,所以是去 k2
assert fsm.handle(Event.RELOADED) is State.NEXT_HOLE
assert fsm.ctx.hole_index == 1
assert fsm.handle(Event.POSE_OK) is State.HOVER_K
def test_drop_budget_exhaustion_aborts_with_note():
"""掉件超过上限 → ABORT,并留下一条能读懂现场原因的备注。"""
fsm = GaugeFsm(GaugeContext(holes_total=1, max_relocations=2))
fsm.handle(Event.BOOT_OK)
for attempt in range(3):
fsm.state = State.SCREW_IN # 直接摆到"正在拧",掉件只可能发生在这一步
nxt = fsm.handle(Event.GAUGE_DROPPED)
if attempt < 2:
assert nxt is State.HOME_EXTREME, f"第 {attempt + 1} 次掉件应还在预算内"
fsm.handle(Event.RELOADED)
else:
assert nxt is State.ABORT
assert fsm.ctx.relocations == 3
assert any("掉" in n for n in fsm.ctx.notes)
def test_illegal_drop_does_not_consume_reload_budget():
"""**负向**:在错的状态发 GAUGE_DROPPED,不能白吃掉一次重放预算。
实现里"先算转移、再记预算"就是为这条:顺序反了的话,一次手误会让现场
少一次重试机会,而报错信息只说"状态不对",没人会想到预算被扣了。
"""
fsm = GaugeFsm(GaugeContext(holes_total=1, max_relocations=1))
assert fsm.state is State.BOOT
with pytest.raises(FsmError):
fsm.handle(Event.GAUGE_DROPPED)
assert fsm.ctx.relocations == 0
# =============================================================================
# 非法转移要当场炸
# =============================================================================
def test_illegal_transition_raises_with_actionable_message():
fsm = GaugeFsm(GaugeContext())
with pytest.raises(FsmError) as ei:
fsm.handle(Event.SCREW_DONE) # S00 不可能已经在拧
msg = str(ei.value)
assert "S00" in msg and "screw_done" in msg
assert "gauge_fsm.TRANSITIONS" in msg # 告诉改代码的人去哪登记
def test_terminal_state_rejects_further_events():
fsm = GaugeFsm(GaugeContext())
fsm.handle(Event.BOOT_FAIL)
assert fsm.state is State.ABORT
with pytest.raises(FsmError):
fsm.handle(Event.BOOT_OK)
def test_can_does_not_mutate():
fsm = GaugeFsm(GaugeContext())
before = (fsm.state, len(fsm.history))
assert fsm.can(Event.BOOT_OK) is True
assert fsm.can(Event.SCREW_DONE) is False
assert (fsm.state, len(fsm.history)) == before
# =============================================================================
# 判据
# =============================================================================
def test_pixel_stable_rejects_zero_mean_jitter():
"""**负向**:相邻两帧很近、但整体在晃,不能被判成"稳定"。
这正是"两帧之差"写法会踩的坑:跟踪抖动的相邻差是小的,于是画面明明
在晃,判据早就触发了,然后拍出一张糊图 —— 现场表现为"孔位偶尔偏 1mm",
随机且难复现。这里用一条锯齿状的抖动序列把这个坑钉死。
"""
jitter = [(100.0 + 6.0 * (-1) ** i, 50.0) for i in range(8)]
# 相邻差都是 12px,很大;但下面这条更阴:相邻差 0、整体摆幅 ±6px
sawtooth = [(100.0, 50.0), (100.0, 50.0), (106.0, 50.0), (106.0, 50.0),
(94.0, 50.0), (94.0, 50.0)]
assert not pixel_stable(jitter, tol_px=2.0, min_frames=6)
assert not pixel_stable(sawtooth, tol_px=2.0, min_frames=6)
# 正向:真的不动
still = [(100.0 + 0.5 * i % 1.0, 50.0) for i in range(8)]
assert pixel_stable(still, tol_px=2.0, min_frames=6)
def test_pixel_stable_needs_enough_frames():
assert not pixel_stable([(100.0, 50.0)], tol_px=5.0, min_frames=6)
assert not pixel_stable([(100.0, 50.0)] * 5, tol_px=5.0, min_frames=6)
assert pixel_stable([(100.0, 50.0)] * 6, tol_px=5.0, min_frames=6)
def test_tangential_jump_watches_both_channels():
"""切向力通道未确认 → 两个都监控,任一突变即判定。"""
ref = (100.0, 100.0)
assert not tangential_jump((110.0, 130.0), ref, delta=50.0)
assert tangential_jump((160.0, 100.0), ref, delta=50.0) # X 突变
assert tangential_jump((100.0, 160.0), ref, delta=50.0) # Y 突变
# 反向突变(规被顶回来)同样算
assert tangential_jump((40.0, 100.0), ref, delta=50.0)
# 边界:恰好等于阈值算触发(>=,避免"永远差那么一点")
assert tangential_jump((150.0, 100.0), ref, delta=50.0)
def test_screw_stop_requires_both_conditions():
"""S08 停止判据是**且**:只看一个会误停。"""
# 只有切向力大(规刚蹭到螺纹)→ 不停
assert not screw_stop(normal_delta=0.0, shear_max=999.0, normal_th=100.0, shear_th=200.0)
# 只有法向力突增(规顶到孔底)→ 不停
assert not screw_stop(normal_delta=999.0, shear_max=0.0, normal_th=100.0, shear_th=200.0)
# 两者同时满足 → 停
assert screw_stop(normal_delta=150.0, shear_max=250.0, normal_th=100.0, shear_th=200.0)
def test_ratchet_cycle_count_rounds_up():
assert ratchet_cycles(360.0, 180.0) == 2
assert ratchet_cycles(361.0, 180.0) == 3 # 差一点也要多做一轮,不能少拧
assert ratchet_cycles(0.0, 180.0) == 0
with pytest.raises(ValueError):
ratchet_cycles(360.0, 0.0)
def test_ratchet_steps_are_four_separate_atomic_steps():
"""S08 必须是四个可独立调用的原子步,顺序固定。
用户明确要求不能封装成一个"拧螺丝"复合动作:中途掉件时只有每一步都能
单独恢复,才可能精确续上(否则只能整段重来,等于少装一个孔)。
"""
assert RATCHET_STEPS == ("rotate_slow", "release", "rotate_back", "regrip")
assert len(set(RATCHET_STEPS)) == len(RATCHET_STEPS)
def test_joint_limit_recognition():
assert is_joint_limit_error("Joint limit exceeded on joint7")
assert is_joint_limit_error("规划失败:超出关节限位")
assert not is_joint_limit_error("controller not active")
assert not is_joint_limit_error("")
# =============================================================================
# 文档与代码同步
# =============================================================================
def test_every_state_and_event_is_documented():
"""每个状态/事件都要有一句人话说明。
没有说明的状态,最后一定会以"日志里只印了个 S07,没人知道 S07 干嘛"
的形式回到现场。这条断言让"新增状态忘了写说明"当场报红。
"""
assert set(STATE_DOC) == set(State), (
f"缺说明的状态: {sorted(str(s) for s in set(State) - set(STATE_DOC))}"
)
assert set(EVENT_DOC) == set(Event), (
f"缺说明的事件: {sorted(e.value for e in set(Event) - set(EVENT_DOC))}"
)
def test_global_fallback_covers_only_abort_and_timeout():
"""GLOBAL 只放"谁都该接受"的事件。
一旦有人往里加业务事件,就会悄悄多出一条所有状态都合法的边,
把"这条转移不存在"这个保护废掉。
"""
assert set(GLOBAL) == {Event.ABORT, Event.TIMEOUT}
def test_context_reports_hole_names():
ctx = GaugeContext(holes_total=2, hole_index=0)
assert ctx.hole_name() == "k1"
ctx.hole_index = 1
assert ctx.hole_name() == "k2"
assert ctx.is_last_hole
def test_history_index_is_dense_and_monotonic():
fsm = GaugeFsm(GaugeContext())
for ev in (Event.BOOT_OK, Event.POSE_OK, Event.GRIP_SKIPPED):
fsm.handle(ev)
assert [s.index for s in fsm.history] == [1, 2, 3]
assert str(fsm.history[0]) == "#1 S00 --boot_ok--> S01"
def test_math_hypot_precision_is_not_used_loosely():
"""pixel_stable 的容差是闭区间(<=),恰好等于阈值算稳定。
留一条边界断言,是因为改成 ``<`` 会让"刚好卡在容差上"的稳定画面永远
等不到 —— 现场表现为"卡在 S05 不动",而日志只说"还在等稳定"。
"""
samples = [(100.0, 50.0), (102.0, 50.0), (98.0, 50.0)]
assert math.isclose(2.0, 2.0)
assert pixel_stable(samples, tol_px=2.0, min_frames=3)
assert not pixel_stable(samples, tol_px=1.9, min_frames=3)
def test_points_stable_works_in_three_dimensions():
"""S05 在仿真里改用**法兰位移**(三维、毫米)判稳定,判据必须支持三维。
同一套"到均值最远距离"的判据:换成毫米只是量纲变了,逻辑不该重写一份。
"""
still = [(1.0, 2.0, 3.05) for _ in range(6)]
assert points_stable(still, tol=0.4, min_frames=6)
# 零均值抖动:相邻帧很近,整体在 ±5mm 之间摆 —— 必须判为不稳
sawtooth = [(0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (5.0, 0.0, 0.0),
(5.0, 0.0, 0.0), (-5.0, 0.0, 0.0), (-5.0, 0.0, 0.0)]
assert not points_stable(sawtooth, tol=0.4, min_frames=6)
def test_points_stable_rejects_ragged_samples():
"""样本维数不一致时必须判不稳(否则会按第一个样本的维数静默比较)。
真实来源是"两种特征混进同一个窗口":比如特征从 bbox 切成 motion 时,
残留的二维样本会被当成三维来算 —— 与其猜,不如直接判不稳重采。
"""
assert not points_stable([(1.0, 2.0), (1.0, 2.0, 3.0)] * 3, tol=1.0, min_frames=6)
def test_points_stable_needs_enough_frames():
assert not points_stable([(0.0, 0.0, 0.0)] * 5, tol=10.0, min_frames=6)
assert points_stable([(0.0, 0.0, 0.0)] * 6, tol=10.0, min_frames=6)
# =============================================================================
# S07 步进下压:容差 < 步长(否则 _goto 空转,一步都不走)
# =============================================================================
@pytest.mark.parametrize("step_mm", [0.05, 0.1, 0.5, 1.0, 2.0, 4.0])
def test_descend_tol_is_strictly_smaller_than_the_step(step_mm):
"""**回归测试**(2026-09-24 仿真踩到过):
原实现把 ``_goto`` 的到位容差写死 0.5mm,而 ``contact_step_mm`` 也是 0.5mm。
``_goto`` 的第一件事就是"已落在容差内 → 直接返回、不动",于是 S07
每次"往下走一步"都变成原地不动:循环条件恒真、日志停在基线那行、
CPU 跑满、不报错也不前进 —— 典型的静默自旋(规则 R5.1)。
纪律:单步下压的容差必须**严格小于**步长,这是循环能推进的前提,
不是调参风格的偏好。
"""
step_m = step_mm / 1000.0
assert descend_tol_m(step_m) < step_m
def test_descend_tol_has_a_floor_for_tiny_steps():
"""步长极小时容差不能退化成 0:伺服追不到 0,会永远"差一点点"。"""
tol = descend_tol_m(0.00001) # 10µm 的步长(比任何现实步长都小)
assert 0.0 < tol <= 0.00001 * 0.5
def test_descend_tol_rejects_nonpositive_step():
with pytest.raises(ValueError):
descend_tol_m(0.0)
with pytest.raises(ValueError):
descend_tol_m(-0.0005)
def test_relief_tolerance_is_strictly_smaller_than_the_relief_move():
"""卸载回退的容差必须**严格小于**回退量(与下压步长同一条纪律)。
2026-09-24 实测踩到:回退 0.50mm 而容差被写死 0.0005 m(同量级),
目标 z 的浮点回减让 ``dist`` 比 0.0005 略小 ⇒ ``_goto`` 入口就判"已到位"
⇒ 卸载成为空操作,而残余力判据比的是两个相同读数 ⇒ 假报"已放干净"。
之后 S08 带着 ~70N 轴向力去转(夹持摩擦上限 ≈73N)→ 掉件(E1)。
"""
for dz_mm in (0.05, 0.2, 0.5, 1.0, 3.0):
dz = dz_mm / 1000.0
tol = relief_tol_m(dz)
assert 0.0 < tol < dz, f"回退 {dz_mm}mm 的容差 {tol * 1000:.4f}mm 不小于回退量"
def test_relief_tolerance_has_a_floor_but_never_swallows_the_move():
"""回退量极小时容差退不下去,但**仍不许**大于等于回退量。"""
dz = 1e-5
tol = relief_tol_m(dz)
assert 0.0 < tol <= dz * 0.5
def test_relief_tolerance_rejects_nonpositive_move():
with pytest.raises(ValueError):
relief_tol_m(0.0)
with pytest.raises(ValueError):
relief_tol_m(-0.0005)
def test_descend_iter_budget_covers_the_whole_depth():
"""步数上限必须够走完整个下探深度(否则会在"还没到底"时被上限截断)。"""
step_m, depth_m = 0.0005, 0.025
assert descend_iter_budget(depth_m, step_m) >= math.ceil(depth_m / step_m)
# 但也不能是无限:这正是"臂没动时不要把节点转成电暖器"的那道闸
assert descend_iter_budget(depth_m, step_m) < 200
def test_tool_clearance_is_negative_when_tool_already_inside():
"""悬停高度按相机定、工具比相机长 → 悬停那一刻工具已经插进工件。
这是 2026-09-24 仿真里实测到的局面(也是真机同参数同结果):
悬停法兰 0.632 − 工具长 0.243 = 尖端 0.389,工件上表面 0.412
→ 尖端在面下 23mm
现象是 S07 一路压到顶都判不到接触,看起来像力阈值标错了 —— 其实是几何错。
"""
gap = tool_clearance_m(0.632, 0.243, 0.412)
assert gap == pytest.approx(-0.023, abs=1e-9)
def test_tool_clearance_positive_when_tool_is_short():
assert tool_clearance_m(0.632, 0.100, 0.412) == pytest.approx(0.120, abs=1e-9)
def test_descend_needed_measures_shoulder_not_tip():
"""判"到底了"的是**台肩落面**,所以需要的下压量按台肩算,不按尖端算。
台肩在尖端上方 30mm:尖端要往下 30mm,台肩才落到面上。
按尖端算会少算 30mm —— 那正是"预算 25mm 永远到不了落面"那个 bug。
"""
# 尖端正好在面上(法兰 = 面 0.412 + 工具长 0.243 = 0.655)
# → 还要再下压 30mm 台肩才落面
assert descend_needed_m(0.655, 0.243, 0.412, 0.030) == pytest.approx(0.030)
# 悬停在 0.632(= 仿真里那个按相机定的值)→ 台肩还差 7mm
assert descend_needed_m(0.632, 0.243, 0.412, 0.030) == pytest.approx(0.007)
# 按尖端算会少算台肩那 30mm —— 差的就是出问题的那个量
tip_only = tool_clearance_m(0.655, 0.243, 0.412)
assert tip_only == pytest.approx(0.0)
assert descend_needed_m(0.655, 0.243, 0.412, 0.030) - tip_only == pytest.approx(0.030)
def test_hole_entry_verdict_flags_gauge_landing_on_the_surface():
"""2026-09-28 实测:同一命令三跑里坏一跑(DONE / ABORT / DONE)。
坏的那跑:k1 装好了,k2 只下压 0.90mm 就"接触"(正常要 28.6~31.9mm)——
因为工件在 k1 之后被工具推走了 ~20mm,而 k2 的孔位是 S05 一次性定下的。
不拦的代价是干转 5 轮 + 最后报一句"转轴偏差…先确认工具系有没有装转接件",
把人引去查工具系(那几个都是好的)。
"""
needed = 0.0319 # 台肩落面还需下压 31.9mm(实测)
# 坏那跑:0.90 / 31.9 = 3% ⇒ 必须报"没进孔"
assert hole_entry_verdict(0.0009, needed) == ENTRY_HIGH
# 好那跑:28.6 / 31.9 = 90% ⇒ 必须放行(否则白扔一整个孔)
assert hole_entry_verdict(0.0286, needed) == ENTRY_OK
# 另一跑 k1:31.45 / 31.9 ⇒ 放行
assert hole_entry_verdict(0.03145, needed) == ENTRY_OK
def test_hole_entry_verdict_never_flags_the_healthy_band():
"""健康的比值区间(台肩倒角没完全落面、板厚公差)绝不能被判成"没进孔"。
这是"宁可漏报也不能误报"那一边:误报一次就白扔一整个孔,
而漏报只是回到旧行为(多烧几轮 + 一句误导)。
"""
needed = 0.032
for ratio in (0.85, 0.90, 0.95, 1.00, 1.10):
assert hole_entry_verdict(needed * ratio, needed) == ENTRY_OK, (
f"比值 {ratio} 是正常范围,不该判成没进孔"
)
# 明确该报的一侧
for ratio in (0.0, 0.03, 0.10, 0.30, 0.49):
assert hole_entry_verdict(needed * ratio, needed) == ENTRY_HIGH, (
f"比值 {ratio} 差着一个台肩高度,必须报"
)
def test_hole_entry_verdict_stays_quiet_when_geometry_is_unusable():
"""``needed_m <= 0`` 时不许抢着报第二个错。
那种情况上面那条几何自检(tool_clearance_m / descend_needed_m)已经在报了,
这里再报一次只会把真正的第一因埋掉。
"""
assert hole_entry_verdict(0.0005, 0.0) == ENTRY_OK
assert hole_entry_verdict(0.0005, -0.01) == ENTRY_OK
def test_hole_lost_event_aborts_and_has_an_honest_label():
"""事件本身与它的中文标签都必须是实话。
复用 FORCE_OVER_LIMIT 会让结论报成"力超安全上限",而这种情况下力根本没超限 ——
又是一句误导。所以单列 HOLE_LOST,并且标签要能一眼看出是孔位问题。
"""
assert resolve_next(State.CONTACT, Event.HOLE_LOST, GaugeContext()) == State.ABORT
label = EVENT_DOC[Event.HOLE_LOST]
assert "孔" in label, "标签要指明是孔位问题"
assert "力" not in label, "别把它说成力的问题 —— 力是好的"
def test_descend_budget_has_margin_and_floor():
"""预算要有余量(容纳对位残差/板厚公差),且再小也有下限。
下限的理由:几何算出来"只差 2mm"时,如果预算就填 2mm,一点对位残差就会
让"预算用完"先于"判到接触"触发 —— 报出来的原因是假的。
"""
# 需求很小时吃下限:0.002×1.5 = 0.003 < 0.008
assert descend_budget_m(0.002) == pytest.approx(DESCEND_BUDGET_FLOOR_M)
# 需求正常时按系数放大:0.030×1.5 = 0.045
assert descend_budget_m(0.030) == pytest.approx(0.045)
def test_shipped_budget_covers_shipped_gauge_and_hover():
"""出厂配置必须自洽:预算要够走完"台肩落面"。
这是那个 bug 的回归测试 —— 当时预算 25mm 而台肩在尖端上方 30mm,
永远到不了落面那一下,报"下探 25mm 都没触发接触判据"(假原因)。
以后谁把 contact_max_depth_mm 调回去、或把 tool_tip_below_flange_m 改大
到不匹配,这里就会红。
"""
budget_m = DEFAULTS["contact_max_depth_mm"] / 1000.0
tip_below = DEFAULTS["tool_tip_below_flange_m"]
# 出厂配置里 workpiece_surface_z_m = 0(运行期用 stage2 实测孔位兜底),
# 所以这里取一个"最坏"的悬停-面高差:仿真里实测法兰 0.632、面 0.412。
needed = descend_needed_m(0.632, tip_below, 0.412, GAUGE_SHOULDER_ABOVE_TIP_M)
assert budget_m >= needed, (
f"下探预算 {budget_m*1000:.1f}mm 不够走完台肩落面所需的 "
f"{needed*1000:.1f}mm —— 会在到落面之前就用完预算"
)
def test_wrench_touch_is_sign_agnostic():
"""腕部 FT 判据取**绝对增量**,不赌正负号。
正负号方向取决于传感器安装(真机法兰系、仿真 link7 系,压下去是 +z 还是 -z
都可能)。赌错了就是"整段流程静默失效":下发正常、日志正常、就是停不住。
取绝对值只会更早停一点点,这个方向的错是安全的 —— 与既有
stage2_locate_node._contact_detected 的口径(abs(fz - baseline))也一致。
"""
assert wrench_touch(20.0, 0.0, 8.0)
assert wrench_touch(-20.0, 0.0, 8.0)
# 基线本身不是 0(带载)时同样只看增量
assert wrench_touch(54.0, 46.0, 8.0)
assert wrench_touch(38.0, 46.0, 8.0)
def test_wrench_touch_below_threshold_does_not_fire():
assert not wrench_touch(7.9, 0.0, 8.0)
assert not wrench_touch(46.0, 46.0, 8.0)
# 边界:等于阈值算触发(与 tangential_jump 的 >= 口径一致)
assert wrench_touch(8.0, 0.0, 8.0)
def test_wrench_touch_threshold_is_reachable_before_grip_slips():
"""阈值的量级必须**远低于**夹持摩擦能传的上限,否则等于设了个永远达不到的数。
这是"用 300 当阈值"那个 bug 的一般化回归:夹持段 µ=0.25、实测握力 ≈46N
⇒ 两指合计能传约 µ·N ≈ 23N。接触刚度 5e4 N/m,所以 8N 阈值 ≈ 0.16mm
过压就判到,而打滑要到 ≈0.46mm —— 留了约 3 倍余量。
"""
mu, grip_n = 0.25, 46.0
slip_limit_n = mu * grip_n * 2 # 两指
assert DEFAULTS["contact_axial_delta_n"] < slip_limit_n / 2, (
f"阈值 {DEFAULTS['contact_axial_delta_n']}N 太接近夹持摩擦上限 "
f"{slip_limit_n:.1f}N —— 打滑前可能来不及判到"
)
def test_wrench_live_uses_freshness_not_magnitude():
"""判活看"够不够新",**不看数值大小**。
静止时轴向力本来就接近 0。拿数值判活会把"安静的传感器"误判成掉线 ——
这正是 2026-09-24 在触觉上踩过的同一个坑(判据从 |fnormal|>1.0 改成
"够新 + connected")。所以这里专门断言 age 有值但数值为 0 → 活的。
"""
assert wrench_live(0.0)
assert wrench_live(WRENCH_MAX_AGE_S)
assert not wrench_live(WRENCH_MAX_AGE_S + 1e-6)
# None = 一帧都没收到过(话题名错/传感器没起来)→ 不是活的
assert not wrench_live(None)
def test_descend_iter_budget_zero_depth_means_no_iterations():
assert descend_iter_budget(0.0, 0.0005) == 0
with pytest.raises(ValueError):
descend_iter_budget(0.025, 0.0)
def test_descend_progress_only_counts_real_downward_motion():
"""推进的证据是**实测位移**:指令发出去不等于臂动了。"""
step_m = 0.0005
assert descend_progress(0.6316, 0.6311, step_m) # 走满一步
assert descend_progress(0.6316, 0.63146, step_m) # 只走了 0.14mm 也算动了
assert not descend_progress(0.6316, 0.63159, step_m) # 只走了 0.01mm:被死区吃掉
assert not descend_progress(0.6316, 0.6316, step_m) # 完全没动(空转)
assert not descend_progress(0.6316, 0.6317, step_m) # 往上跑 ≠ 推进
# =============================================================================
# 绕轴旋转的验收判据(S01/S08/S09 共用)
# =============================================================================
def test_rotation_verdict_accepts_a_full_turn():
assert rotation_verdict(-180.0, -179.7, 0.0, 5.0) == "ok"
assert rotation_verdict(+180.0, +175.1, 0.0, 5.0) == "ok" # 5° 之内算到位
def test_rotation_verdict_calls_a_partial_turn_a_joint_limit():
"""2026-09-24 实测的那一条:**只转了 16.4°**(被关节7 的上限截住)。
期望 +180、实测 +16.4:方向对、只是没走完 ⇒ 判"贴限位"(调用方张开爪
让出余量再转),**不是**"偏差过大"直接 ABORT —— 后者把一整轮白扔,
而且日志看起来像"这台臂坏了"。
"""
assert rotation_verdict(+180.0, +16.4, 0.1, 5.0) == "limit"
# 完全没动也是同一类(只是余量更少)
assert rotation_verdict(+180.0, +0.4, 0.0, 5.0) == "limit"
def test_rotation_verdict_still_stops_on_wrong_direction_or_overshoot():
"""判"贴限位"必须**卡住方向**:反向转/过冲都是真问题,不能拿去让余量重试。"""
assert rotation_verdict(+180.0, -170.0, 0.0, 5.0) == "short" # 转反了
assert rotation_verdict(+180.0, +260.0, 0.0, 5.0) == "short" # 过冲
# 小幅指令(≤10°)几乎没动时**不算**"完全没动"那条特例,于是落进
# "方向对但没走完" ⇒ 同样判 limit。这是有意的:让余量那一步是免费的
# (张开爪时规在打滑),宁可白让一次,也别把"其实贴住了"判成"臂坏了"。
assert rotation_verdict(+8.0, +0.2, 0.0, 5.0) == "limit"
def test_rotation_verdict_axis_misalignment_beats_everything():
"""转轴不共线(>5°)时,任何角度数字都没有意义 —— 必须先报它。"""
assert rotation_verdict(+180.0, +179.0, 6.0, 5.0) == "axis"
assert rotation_verdict(+180.0, +16.4, 6.0, 5.0) == "axis"
def test_ratchet_step_of_180_never_fits_the_rizon_joint7_range():
"""棘轮一步 180° 在 Rizon4 的关节7 行程(±175°)里**永远**放不下。
这是 S09 中止的物理根源,也解释了为什么转腕前要先让余量:
贴着上限时反向那一步只剩十几度(实测 16.4°)。
"""
from arm_application.gauge_kin import prewind
lim = (-3.0543, 3.0543) # Rizon4 关节7:±175°
# 贴在上限(棘轮回转每轮都停在这儿)→ 想再往 + 转:放不下,必须让余量
plan = prewind(q_now_rad=math.radians(171.0), want_deg=180.0, direction=+1,
limits=lim, margin_deg=8.0)
assert plan.clamped
# 同一个位置往 - 转:放得下(这正是"让余量"那一步)
plan = prewind(q_now_rad=math.radians(171.0), want_deg=180.0, direction=-1,
limits=lim, margin_deg=8.0)
assert not plan.clamped
# =============================================================================
# 悬停高度自检(悬停按相机定、工具比相机长 ⇒ 会捅进工件)
# =============================================================================
def test_hover_guard_fires_for_the_exact_case_we_hit():
"""2026-09-24 实测:仿真悬停 0.632、工具长 0.245、工件面 0.412。
尖端 0.632−0.245=0.387,比工件面低 25mm ⇒ 悬停这一步就把工具捅进去了,
紧接着 S05 的横向对准把工件拖走 92mm。这里必须报"要改"并给出几何值。
"""
fixed, why = hover_z_guard(0.632, 0.412, 0.245, 0.002)
assert why, "这种悬停必须被判为不安全"
assert fixed == pytest.approx(0.412 + 0.245 + 0.002)
assert "钻进工件" in why
assert "hole_hover_z_m = 0.659" in why # 要能直接照着改配置
def test_hover_guard_is_silent_when_the_tool_stays_clear():
"""几何对的配置一个字都不许说(否则每次启动都刷一堆无用告警)。"""
fixed, why = hover_z_guard(0.659, 0.412, 0.245, 0.002)
assert why == ""
assert fixed == pytest.approx(0.659)
# 更高也照过(悬停高一点只是拍得远,不算错)
assert hover_z_guard(0.700, 0.412, 0.245, 0.002)[1] == ""
def test_hover_guard_uses_want_clearance_when_configuring():
"""修正值必须落在"刚好 want_clearance"的位置,而不是随手抬一点。"""
fixed, _ = hover_z_guard(0.50, 0.412, 0.245, 0.005)
assert fixed == pytest.approx(0.412 + 0.245 + 0.005)
assert hover_z_for_tool_clearance(0.412, 0.245, 0.005) == pytest.approx(fixed)
def test_user_hover_is_refused_instead_of_rewritten():
"""启动参数写了悬停高:不安全就拒绝,不许改成另一个数再往下走。"""
used, why = resolve_hover_z(0.632, 0.412, 0.245, 0.002, True)
assert used == pytest.approx(0.632)
assert "不会改成别的高度" in why
# 没写启动参数:仍按几何抬高,不当成拒绝。
used_site, why_site = resolve_hover_z(0.632, 0.412, 0.245, 0.002, False)
assert why_site == ""
assert used_site == pytest.approx(0.412 + 0.245 + 0.002)
# 写了且本身安全:原样用。
used_ok, why_ok = resolve_hover_z(0.700, 0.412, 0.245, 0.002, True)
assert why_ok == ""
assert used_ok == pytest.approx(0.700)
def test_hover_guard_tolerates_millimetre_level_shortfall():
"""夹在目标间隙附近(差不到 1mm)不算错:工件面 z 本身就有几毫米不确定度。"""
assert hover_z_guard(0.6580, 0.412, 0.245, 0.002)[1] == "" # 差 1.0mm 之内
assert hover_z_guard(0.6560, 0.412, 0.245, 0.002)[1] != "" # 差 3mm:判错
# =============================================================================
# S05 精对位时"工件被工具推走"的看门狗
# =============================================================================
def test_drag_verdict_says_nothing_on_the_first_frame():
"""第一拍没有上一拍可比 —— 不能因为"没有历史"就报警。"""
assert drag_verdict((0.49, 0.0), None)[0] is False
def test_drag_verdict_ignores_measurement_jitter():
"""正常对准时两孔中点不动(动的是目标孔,不是中点)—— 亚毫米抖动不算。"""
ok, d = drag_verdict((0.4905, 0.0002), (0.4900, 0.0000), (0.4899, 0.0000))
assert ok is False
assert d == pytest.approx(math.hypot(0.0005, 0.0002), abs=1e-9)
def test_drag_verdict_fires_on_one_big_jump():
"""单拍就挪了 8mm:刚体不会自己挪回去,不必等第二拍确认。"""
ok, d = drag_verdict((0.4820, 0.0), (0.4900, 0.0))
assert ok is True
assert d == pytest.approx(0.008)
def test_drag_verdict_needs_two_frames_when_it_is_only_3_plus_mm():
"""只大过 3mm 一点点:要求上一拍也朝同方向挪过,避免一次误检就中止流程。"""
mid = (0.4860, 0.0)
prev = (0.4900, 0.0)
# 上一拍没动(d2 = 0)⇒ 只当噪声
assert drag_verdict(mid, prev, (0.4901, 0.0))[0] is False
# 上一拍也挪了 4mm、方向一致 ⇒ 判被推走
assert drag_verdict(mid, prev, (0.4940, 0.0))[0] is True
# 上一拍也挪了 4mm,但方向**相反**(工件抖动/误检)⇒ 不判
assert drag_verdict(mid, prev, (0.4820, 0.0))[0] is False
def test_drag_verdict_matches_what_we_actually_saw_in_gazebo():
"""2026-09-24 实测那一轮的真实数字:工件每拍被推 11.8mm(残差恒 11mm)。
这条是回归测试:当时的现场没有任何判据报警,日志只说"检查手眼/相机安装",
于是一路追到迭代用满。现在同样的数列必须在**第一拍**就判出来。
"""
ks = [0.4910, 0.4910, 0.4792, 0.4674, 0.4566, 0.4458, 0.4350, 0.4239, 0.4121]
mids = [(k - 0.029, 0.0) for k in ks] # 中点 = k1 左移两孔间距的一半
flags = [
drag_verdict(mids[i], mids[i - 1] if i >= 1 else None,
mids[i - 2] if i >= 2 else None)[0]
for i in range(len(mids))
]
assert flags == [False, False, True, True, True, True, True, True, True]
assert SNAP_DRAG_TOL_M < 0.0118
# =============================================================================
# 棘轮:目标角(绝对值)→ 增量。2026-09-24 S09 第一步 ABORT 的根因
# =============================================================================
def test_ratchet_delta_wraps_a_target_that_is_past_the_limit():
"""实测那一笔:关节在 +158.9°、目标 +338.9° ⇒ 增量是 +180°(不是 +338.9°)。
差值的折算是整个棘轮的地基:把它当成 338.9° 会让"行程够不够"算错、
让余量那一步算到限位外面,最后报一句与真因无关的"偏差过大"。
"""
d = ratchet_delta_rad(math.radians(338.9), math.radians(158.9))
assert math.degrees(d) == pytest.approx(180.0, abs=1e-6)
def test_ratchet_rewind_target_stays_inside_the_limit():
"""让余量那一步必须落在限位内 —— 这正是当时失败的地方(−180° < −175°)。"""
lo, up = math.radians(-175.0), math.radians(175.0)
q_now = math.radians(158.9)
delta = ratchet_delta_rad(math.radians(338.9), q_now)
rewind = q_now - delta # 让出整步
assert lo < rewind < up
assert math.degrees(rewind) == pytest.approx(-21.1, abs=1e-6)
# 让完余量再前进整步 ⇒ 关节回到原处、工具净转 +180°
retry = rewind + delta
assert math.degrees(retry) == pytest.approx(158.9, abs=1e-6)
def test_ratchet_delta_is_the_same_number_for_both_wrapped_aims():
"""+338.9° 与 −21.1° 是同一个关节位置(差一整圈)—— 增量必须一致。"""
q_now = math.radians(158.9)
a = ratchet_delta_rad(math.radians(338.9), q_now)
b = ratchet_delta_rad(math.radians(-21.1), q_now)
assert a == pytest.approx(b, abs=1e-9)
def test_ratchet_delta_covers_a_plain_back_and_forth_cycle():
"""正常的棘轮一步(不贴限位):从 +150° 拧 −180°、再回转 +180°。"""
q = math.radians(150.0)
d = ratchet_delta_rad(math.radians(-30.0), q)
assert math.degrees(d) == pytest.approx(-180.0, abs=1e-6)
d2 = ratchet_delta_rad(math.radians(150.0), q + d)
assert math.degrees(d2) == pytest.approx(180.0, abs=1e-6)
# =============================================================================
# 棘轮的**转向**必须由工艺意图决定,不能交给"谁更短"
# 2026-09-24 实测:S08 第 3 轮 ABORT(步长 180° 的刀口)
# =============================================================================
def test_ratchet_180_degree_step_flips_direction_on_a_tenth_of_a_degree_read():
"""**实测那一笔**:调用方读到 q=+162.0 ⇒ 目标 −18.0;棘轮晚 0.1° 读到
q=+162.1 ⇒ "最短路径"给出 **+179.9°**(方向反了)。
180° 正好落在 ±180° 的刀口上:0.1° 的读数差就能决定"往前"还是"往后"。
结果是棘轮朝螺纹反方向走 → 贴限位 → 让余量 → 重试 → **净位移 0** ⇒ ABORT,
而现场什么都没坏。所以转向必须由调用方按工艺意图给定。
"""
d_naive = ratchet_delta_rad(math.radians(-18.0), math.radians(162.1))
assert math.degrees(d_naive) == pytest.approx(+179.9, abs=0.05) # 旧口径:翻向
d_screw = ratchet_delta_rad(math.radians(-18.0), math.radians(162.1), -1)
assert math.degrees(d_screw) == pytest.approx(-180.1, abs=0.05), "拧入必须往 − 转"
d_back = ratchet_delta_rad(math.radians(342.0), math.radians(162.1), +1)
assert math.degrees(d_back) == pytest.approx(+179.9, abs=0.05), "回转必须往 + 转"
def test_directed_delta_always_lands_on_the_target_and_keeps_the_sign():
"""给了方向:增量符号一定等于指定方向,且转过去**就是**目标角(差整圈)。"""
q = math.radians(162.1)
for want_sign, target_deg in ((+1, 342.0), (+1, -18.0), (-1, -18.0), (-1, 342.0)):
d = ratchet_delta_rad(math.radians(target_deg), q, want_sign)
assert d != 0.0
assert math.copysign(1.0, d) == want_sign, (want_sign, target_deg, math.degrees(d))
# 转过去与目标角**同一个位置**(差整数圈)
turns = (q + d - math.radians(target_deg)) / (2.0 * math.pi)
assert abs(turns - round(turns)) < 1e-9
# 且是那一侧**最小**的一步(再多转一整圈就不是"最小"了)
assert abs(d) <= 2.0 * math.pi + 1e-9
def test_directed_delta_does_not_waste_a_full_turn_when_already_there():
"""已经在目标角上时不许"为了凑方向"白转一整圈 —— 那是 1 秒多的空转。"""
q = math.radians(30.0)
assert ratchet_delta_rad(q, q, +1) == 0.0
assert ratchet_delta_rad(q, q, -1) == 0.0
# 差一点点也算"就在那儿"(1e-12 rad 量级)而不是 359.999…°
assert abs(ratchet_delta_rad(q, q - 1e-12, +1)) < 1e-9
def test_plain_shortest_path_is_still_available_for_non_ratchet_callers():
"""``direction=0`` 保留"最短路径"语义(给目标本来就明确在某一侧的调用方)。"""
d = ratchet_delta_rad(math.radians(338.9), math.radians(158.9))
assert math.degrees(d) == pytest.approx(180.0, abs=1e-6)
assert math.degrees(ratchet_delta_rad(math.radians(10.0), math.radians(350.0))) == pytest.approx(20.0)
# =============================================================================
# 棘轮步长 vs 关节行程:S08 开头就要判,不能等第 3 轮贴限位
# =============================================================================
def test_ratchet_travel_rejects_180_on_the_rizon_joint7():
"""180° 的步长在 ±175° 的关节 7 上**永远**放不下(实测第 3 轮 ABORT)。
一个来回要 2×180° = 360°,比行程 350° 还大 —— 无论怎么让余量都腾不出
空间,所以这不是"调调就好",是**配置错**,必须在 S08 开头就拦下来。
"""
lim = (-3.0543, 3.0543) # Rizon4/10 关节7:±175°
v = ratchet_travel_ok(180.0, lim, 8.0)
assert not v.ok
assert v.need_deg == pytest.approx(376.0)
assert v.travel_deg == pytest.approx(350.0, abs=0.1)
assert v.max_step_deg == pytest.approx(167.0, abs=0.1)
assert "screw_step_deg" in v.why, "报错要直接指出该改哪个键"
assert "screw_max_cycles" in v.why, "还要提醒累计必须是整数圈"
def test_ratchet_travel_accepts_the_shipped_step_and_margin():
"""出厂值(120°)必须过 —— 否则 S08 一进门就 ABORT,仿真永远跑不完。"""
from arm_application.gauge_config import DEFAULTS
lim = (-3.0543, 3.0543)
v = ratchet_travel_ok(DEFAULTS["screw_step_deg"], lim, 8.0)
assert v.ok, v.why
# 建议值必须落在上限内,否则"照着改"会再撞一次
assert v.max_step_deg >= DEFAULTS["screw_step_deg"]
def test_ratchet_travel_passes_when_limits_are_unknown_but_says_so():
"""拿不到限位不能猜着拦:放行,理由交给驱动器报 JOINT_LIMIT 兜底。"""
v = ratchet_travel_ok(180.0, None, 8.0)
assert v.ok
assert v.travel_deg == float("inf")
assert v.max_step_deg == float("inf")
assert v.why == ""
@@ -0,0 +1,190 @@
"""``gauge_gripper`` 的单元测试:**同名服务、两种类型**怎么选。
这一段的价值全在"选错了会报出与真实原因无关的错"上:
真机上只按契约写 → 报"服务没起"(其实起了,只是类型是 Trigger);
选了 legacy 却在仿真里跑 → 调用类型不匹配的服务。
所以每条选择路径都要有断言,包括"两种都在线"和"一个都不在"。
"""
from __future__ import annotations
import pytest
from arm_application.gauge_gripper import (
CONTRACT_CLOSE,
CONTRACT_OPEN,
CONTRACT_STATUS_TOPIC,
CONTRACT_TYPE,
GRIP_STATE_CAUGHT,
GRIP_STATE_DROPPED,
GRIP_STATE_MOVING,
GRIP_STATE_REACHED,
LEGACY_STATUS_TOPIC,
LEGACY_TYPE,
GripperInterfaceError,
describe_backend_choice,
is_dropped_from_state,
is_gripped_from_state,
legacy_states_to_fields,
pick_backend,
states_frame_online,
)
def _services(open_types, close_types=None, extra=None):
close_types = open_types if close_types is None else close_types
out = {CONTRACT_OPEN: list(open_types), CONTRACT_CLOSE: list(close_types)}
if extra:
out.update(extra)
return out
# =============================================================================
# auto:按实际类型选
# =============================================================================
def test_contract_backend_when_services_are_gripper_execute():
choice = pick_backend(_services([CONTRACT_TYPE]))
assert choice.backend == "contract"
assert CONTRACT_TYPE in choice.reason
assert choice.status_topic == CONTRACT_STATUS_TOPIC
def test_legacy_backend_when_services_are_trigger():
"""真机今天的样子:dh_pgea 节点的 Trigger 服务。"""
choice = pick_backend(_services([LEGACY_TYPE]))
assert choice.backend == "legacy"
assert "dh_pgea" in choice.reason
assert choice.status_topic == LEGACY_STATUS_TOPIC
# 理由里要带上"契约驱动还没落地"这个现场事实,否则下次有人以为选错了
assert "arm_driver_dh_gripper" in choice.reason
def test_missing_services_reports_how_to_start_them():
with pytest.raises(GripperInterfaceError) as ei:
pick_backend({})
msg = str(ei.value)
assert "run_gripper.sh" in msg and "run_sim_gz.sh" in msg
def test_unknown_service_type_is_not_silently_accepted():
"""类型既不是 GripperExecute 也不是 Trigger(比如被别人占用了同名服务)。
这时必须是"认不出来"的明确报错,而不是随便选一个后端然后调用失败。
"""
with pytest.raises(GripperInterfaceError) as ei:
pick_backend(_services(["std_srvs/srv/SetBool"]))
msg = str(ei.value)
assert "std_srvs/srv/SetBool" in msg
assert "service list -t" in msg, "要给出『是谁占用了这个服务名』的查法"
def test_missing_service_and_wrong_type_are_different_messages():
"""**负向**:不能在"服务不在"和"类型不对"两种情况报同一句话。
两者都报"夹爪节点没起来",现场就会去反复重启一个根本没问题的节点
(规则 M1:报错信息必须指向真实原因)。
"""
with pytest.raises(GripperInterfaceError) as absent:
pick_backend({CONTRACT_CLOSE: [CONTRACT_TYPE]}) # open 缺失
with pytest.raises(GripperInterfaceError) as wrong:
pick_backend(_services(["std_srvs/srv/SetBool"]))
assert "没起来" in str(absent.value)
assert "没起来" not in str(wrong.value)
assert "被别的节点占用" in str(wrong.value) or "占用了" in str(wrong.value)
def test_both_backends_online_is_an_error_not_a_coin_flip():
"""仿真和真机节点同时起着(或旧进程没停干净)→ 报错,不许撞运气。"""
with pytest.raises(GripperInterfaceError) as ei:
pick_backend(_services([CONTRACT_TYPE, LEGACY_TYPE]))
msg = str(ei.value)
assert "同时存在两种类型" in msg
assert "stop_sim_gz.sh" in msg
def test_open_and_close_types_must_agree():
"""open 是契约、close 是 Trigger → 不算"契约可用",要按认不出来报错。"""
with pytest.raises(GripperInterfaceError):
pick_backend(_services([CONTRACT_TYPE], [LEGACY_TYPE]))
# =============================================================================
# 显式指定后端:不符就报错,不静默降级
# =============================================================================
def test_explicit_contract_on_legacy_hardware_fails_with_fix_list():
with pytest.raises(GripperInterfaceError) as ei:
pick_backend(_services([LEGACY_TYPE]), prefer="contract")
msg = str(ei.value)
assert "gripper_backend" in msg
assert "legacy" in msg and "auto" in msg
def test_explicit_legacy_on_sim_fails_and_points_at_dh_pgea():
with pytest.raises(GripperInterfaceError) as ei:
pick_backend(_services([CONTRACT_TYPE]), prefer="legacy")
assert "dh_pgea" in str(ei.value)
def test_explicit_backend_matching_reality_is_accepted():
assert pick_backend(_services([CONTRACT_TYPE]), prefer="contract").backend == "contract"
assert pick_backend(_services([LEGACY_TYPE]), prefer="legacy").backend == "legacy"
def test_bad_prefer_value_lists_valid_options():
with pytest.raises(GripperInterfaceError) as ei:
pick_backend(_services([CONTRACT_TYPE]), prefer="whatever")
assert "auto" in str(ei.value) and "contract" in str(ei.value)
# =============================================================================
# 状态解读
# =============================================================================
def test_gripped_only_when_state_is_definitive():
""""到位"不等于"夹住":空爪闭合也会到位。
把 1(到位)当成"夹住了"会让 E1 掉件检测在正常开合时误报;
把"不知道"当成"没夹住"会让流程一开始就报错。
"""
assert is_gripped_from_state(GRIP_STATE_CAUGHT) is True
assert is_gripped_from_state(GRIP_STATE_DROPPED) is False
assert is_gripped_from_state(GRIP_STATE_REACHED) is None
assert is_gripped_from_state(GRIP_STATE_MOVING) is None
def test_drop_detection_requires_a_previous_grip():
"""爪里本来就是空的(装配前),不该判成掉件。"""
assert is_dropped_from_state(GRIP_STATE_DROPPED, was_gripped=True) is True
assert is_dropped_from_state(GRIP_STATE_DROPPED, was_gripped=False) is False
assert is_dropped_from_state(GRIP_STATE_CAUGHT, was_gripped=True) is False
assert is_dropped_from_state(GRIP_STATE_MOVING, was_gripped=True) is False
def test_legacy_states_parsing_is_field_named_and_defensive():
f = legacy_states_to_fields([1.0, 2.0, 500.0, 0.0, 50.0])
assert f["initialized"] == 1.0 and f["grip_state"] == 2.0
assert f["position"] == 500.0 and f["target_force"] == 50.0
# 畸形/空数据 → 空字典(上层走"不知道"分支),不抛异常
assert legacy_states_to_fields([]) == {}
assert legacy_states_to_fields([1.0, 2.0]) == {}
# 离线帧(第 6 位 0)里的前 5 个是上一次的值,不能当现在的状态用。
# 2026-10-20 读 0x0201 连 264 次无应答,这就是流水线把"读不到"说成
# "夹爪没到位"的入口,必须有负向测试。
assert legacy_states_to_fields([1.0, 2.0, 500.0, 0.0, 50.0, 0.0]) == {}
def test_states_online_marks_only_fresh_frames():
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0]) is True
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0, 1.0]) is True
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0, 0.0]) is False
# 第 6 位是垃圾 → 当离线,不许当有效帧
assert states_frame_online([1.0, 2.0, 500.0, 0.0, 50.0, "x"]) is False
def test_describe_mentions_the_unit_trap_for_legacy():
"""legacy 的"开口"是千分比不是毫米,日志里必须写明。
这是最容易误用的地方:拿到 500 以为是 500mm,实际是 10mm 行程的一半。
"""
text = "\n".join(describe_backend_choice(pick_backend(_services([LEGACY_TYPE]))))
assert "1000‰" in text and "不是毫米" in text
@@ -0,0 +1,151 @@
"""``GripperClient.dropped()``:E1 掉件判据只认**开口塌陷**,不认状态位抖动。
为什么值得单独钉住(2026-09-24 实测踩到):
仿真里 ``GripperStatus.is_gripped`` 是**推断值**("指令到了合拢侧 + 实际开口
比指令大出容差")。S08 拧规时会在"合到某个开口 / 全合"之间发指令,只要某一帧
的指令落到与实际开口同一档,``is_gripped`` 就翻成 False —— 于是
**S08 第 2/12 轮报「通止规掉了」**(爪里其实好好的),整条流程转去等人工
"放好通止规",而仿真里没有这个人。
判据改成"夹住时的开口 − 当前开口 ≥ 5mm"以后,这种抖动不再报掉件;而真的掉了
(两指合到底、开口塌几十毫米)仍然会报。
"""
from __future__ import annotations
from typing import Any, List
from arm_msgs.msg import GripperStatus
class _StubNode:
"""``GripperClient`` 只用到这三个方法;测试里不建任何 ROS 实体。"""
def __init__(self) -> None:
self.warnings: List[str] = []
def create_client(self, srv_type: Any, name: str) -> None:
return None
def create_subscription(self, msg_type: Any, topic: str, cb: Any, qos: Any) -> None:
self._cb = cb
return None
def get_logger(self) -> Any:
return _StubLogger(self.warnings)
class _StubLogger:
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def info(self, msg: str) -> None:
self._sink.append(msg)
def warn(self, msg: str) -> None:
self._sink.append(msg)
def error(self, msg: str) -> None:
self._sink.append(msg)
def _status(is_gripped: bool, position: float) -> GripperStatus:
msg = GripperStatus()
msg.is_gripped = bool(is_gripped)
msg.position = float(position)
return msg
def _client():
from arm_application.gauge_assembly_node import GripperClient
node = _StubNode()
return GripperClient(node, "contract", "/gripper/status"), node
def test_no_drop_before_ever_gripped():
"""装配前爪里本来就空:状态位说什么都不算掉件(否则一开场就 E1)。"""
client, _ = _client()
client._on_contract(_status(False, 0.0))
assert client.dropped() is False
def test_a_wide_open_flap_does_not_poison_the_baseline():
"""**本次踩到的那个假 E1 的真因**:全开时 is_gripped 也闪 True(实测 99.29mm)。
以前"第一次判到夹住"就记基准 ⇒ 基准被记成 99.29mm ⇒ 塌陷量算成 77mm ⇒
报"通止规掉了",而规好好地在爪里、真实开口一直是 21.9mm。
现在基准只由明确调用 adopt_current_grip 记录:状态位怎么闪都不动它。
"""
client, _ = _client()
client._on_contract(_status(True, 99.29)) # 全开时的假 GRIPPING
assert client._grip_aperture is None, "状态位不许写基准"
assert client.dropped() is False
client._on_contract(_status(True, 21.9)) # 真正夹住规之后
client.adopt_current_grip("测试:确认夹住")
assert client._grip_aperture == 21.9
client._on_contract(_status(False, 21.8))
assert client.dropped() is False, "开口没塌就不是掉件"
def test_is_gripped_flap_is_not_a_drop():
"""**本次踩到的那个假 E1**:指令切换让 is_gripped 翻 False,开口没塌。"""
client, _ = _client()
client._on_contract(_status(True, 24.9)) # 夹住 φ30 的规,开口停在 24.9mm
client.adopt_current_grip("测试:确认夹住")
assert client.dropped() is False
client._on_contract(_status(False, 24.8)) # 指令落到同一档 → 推断值翻 False
assert client.dropped() is False, "开口没塌就不是掉件"
def test_real_drop_is_detected_by_aperture_collapse():
"""真的掉了:两指一路合到底,开口塌到几毫米。"""
client, _ = _client()
client._on_contract(_status(True, 24.9))
client.adopt_current_grip("测试:确认夹住")
client._on_contract(_status(False, 0.2))
assert client.dropped() is True
def test_aperture_jitter_within_the_threshold_is_not_a_drop():
"""实测抖动是亚毫米量级;阈值 5mm 不该被它触发。"""
client, _ = _client()
client._on_contract(_status(True, 24.9))
client.adopt_current_grip("测试:确认夹住")
client._on_contract(_status(False, 24.9 - 4.9))
assert client.dropped() is False
def test_without_aperture_reading_falls_back_to_the_old_criterion():
"""拿不到 position(老固件/别的夹爪)时退回旧判据:会抖,但不至于"永远判不了掉件"。"""
client, _ = _client()
client._on_contract(_status(True, float("nan")))
assert client.adopt_current_grip("测试:确认夹住") is None
assert client._grip_aperture is None
client._on_contract(_status(False, float("nan")))
assert client.dropped() is True, "没有开口读数时不能变成「永远不报掉件」"
def test_settle_waits_for_the_motion_to_finish():
"""``settle`` 不能读"动作还没做完"的旧值。
实测:``close`` 服务返回时手指还在动,立刻读开口读到的是**全开 99.93mm** ——
于是"第 1 轮夹紧后:开口 99.93mm(夹住时 99.29mm)"这种荒唐日志。
``settle`` 要一直等到连续两帧一样才认。
"""
client, _ = _client()
client._on_contract(_status(True, 99.93))
assert client.settle(settle_s=0.2) == 99.93
# 动作进行中:值一直在变 ⇒ 不到稳定就不返回(这里只验证它会返回**最终**值)
client._on_contract(_status(False, 30.0))
assert client.settle(settle_s=0.2) == 30.0
def test_adopt_current_grip_records_what_is_in_the_jaws_now():
"""基准 = "确认夹住那一刻"的开口;之后状态位再翻也不改参照。"""
client, _ = _client()
client._on_contract(_status(True, 21.9))
assert client.adopt_current_grip("测试") == 21.9
client._on_contract(_status(False, 21.4))
assert client.grip_aperture() == 21.9
assert client.collapse_mm() == 0.5
+225
View File
@@ -0,0 +1,225 @@
"""``gauge_kin`` 的单元测试:S01 的"转到极限角"与"确实绕对了轴"。
两个都在测"算错了不会报错、只会做错动作"的东西:
限位夹取算错 → 要么撞限位被驱动器拒绝,要么离极限还差很远(流程看起来一样);
转轴验证算错 → 无论实际绕哪根轴转都报"没问题",判据等于没有。
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from arm_application.gauge_kin import (
DEFAULT_LIMIT_MARGIN_DEG,
a7_limit_target,
axis_misalignment_deg,
match_limits,
parse_joint_limits,
prewind,
rotation_about_axis_deg,
unit_tool_z,
vertical_down_error_deg,
)
URDF = """<?xml version="1.0"?>
<robot name="rizon">
<link name="Rizon4-063989_link6"/>
<link name="Rizon4-063989_link7"/>
<joint name="Rizon4-063989_joint6" type="revolute">
<parent link="Rizon4-063989_link5"/>
<child link="Rizon4-063989_link6"/>
<limit lower="-1.4835" upper="4.6251" effort="39" velocity="4.8869"/>
</joint>
<joint name="Rizon4-063989_joint7" type="revolute">
<parent link="Rizon4-063989_link6"/>
<child link="Rizon4-063989_link7"/>
<limit lower="-3.0543" upper="3.0543" effort="39" velocity="4.8869"/>
</joint>
<joint name="Rizon4-063989_roll" type="continuous">
<parent link="Rizon4-063989_link7"/>
<child link="Rizon4-063989_tool"/>
</joint>
</robot>
"""
# =============================================================================
# URDF 解析
# =============================================================================
def test_parse_limits_keeps_full_joint_names():
"""关节名原样保留(含 SN 前缀)。
"顺手去掉前缀"这种清洗会在换臂时静默失配:所有关节都查不到限位,
于是限位夹取悄悄失效,现象是"贴着限位下发、驱动器拒绝"。
"""
lim = parse_joint_limits(URDF)
assert lim["Rizon4-063989_joint7"] == (-3.0543, 3.0543)
assert lim["Rizon4-063989_joint6"] == (-1.4835, 4.6251)
# continuous 关节没有 limit,不该被当成解析结果
assert "Rizon4-063989_roll" not in lim
def test_parse_limits_on_garbage_returns_empty_not_partial():
assert parse_joint_limits("") == {}
assert parse_joint_limits("not xml at all") == {}
# 未展开的 xacro:既不是合法 XML,也没有可用的 limit 行
assert parse_joint_limits("<robot xmlns:xacro='x'>${bad}</robot>") == {}
def test_parse_limits_falls_back_to_line_regex_on_malformed_xml():
"""URDF 里有未转义的 ``&`` 时整份 XML 解析会失败,但限位行仍然可读。
降级到逐行正则能救回"拿到的是半成品 URDF"这种常见情况;救不回就返回空表
(= 不做限位夹取,由调用方告警),**不会**返回半套数据。
"""
broken = URDF.replace('<link name="Rizon4-063989_link6"/>', '<link name="a & b"/>')
lim = parse_joint_limits(broken)
assert lim["Rizon4-063989_joint7"] == (-3.0543, 3.0543)
def test_match_limits_is_all_or_nothing():
lim = parse_joint_limits(URDF)
assert match_limits(lim, ["Rizon4-063989_joint7"]) == (-3.0543, 3.0543)
# 缺一个就整体不可用:半套限位会让"能不能转到极限"的结论无法解释
assert match_limits(lim, ["Rizon4-063989_joint7", "不存在_joint8"]) is None
assert match_limits({}, ["Rizon4-063989_joint7"]) is None
# =============================================================================
# S01 预绕
# =============================================================================
def test_prewind_without_limits_applies_full_angle():
r = prewind(q_now_rad=0.0, want_deg=150.0, direction=1, limits=None)
assert not r.has_limits and not r.clamped
assert math.isclose(r.applied_deg, 150.0, abs_tol=1e-9)
assert math.isclose(r.target_rad, math.radians(150.0), abs_tol=1e-12)
def test_prewind_direction_sign():
"""方向只由 ``extreme_direction`` 决定,不靠代码里的负号。"""
up = prewind(0.0, 90.0, 1, None)
down = prewind(0.0, 90.0, -1, None)
assert up.applied_deg > 0 and down.applied_deg < 0
assert math.isclose(up.applied_deg, -down.applied_deg, abs_tol=1e-9)
with pytest.raises(ValueError):
prewind(0.0, 90.0, 0, None)
with pytest.raises(ValueError):
prewind(0.0, -90.0, 1, None)
def test_prewind_clamps_inside_the_limit_with_margin():
"""上限 3.0543 rad(175°),余量 3° → 最多转到 172°,且报 clamped。"""
r = prewind(q_now_rad=0.0, want_deg=150.0, direction=1, limits=(-3.0543, 3.0543))
assert math.isclose(r.applied_deg, 150.0, abs_tol=1e-9)
assert not r.clamped
r2 = prewind(q_now_rad=0.0, want_deg=179.0, direction=1, limits=(-3.0543, 3.0543))
assert r2.clamped and r2.has_limits
assert r2.applied_deg < 179.0
# 关键断言:目标必须落在 "上限 − 余量" 之内(余量是给驱动器留的,不是装饰)
upper = 3.0543 - math.radians(DEFAULT_LIMIT_MARGIN_DEG)
assert r2.target_rad <= upper + 1e-12
assert math.isclose(r2.target_rad, upper, abs_tol=1e-12)
def test_prewind_clamps_toward_lower_limit_for_negative_direction():
r = prewind(q_now_rad=0.0, want_deg=179.0, direction=-1, limits=(-3.0543, 3.0543))
lower = -3.0543 + math.radians(DEFAULT_LIMIT_MARGIN_DEG)
assert r.clamped and math.isclose(r.target_rad, lower, abs_tol=1e-12)
def test_prewind_reports_backwards_motion_when_already_inside_margin():
"""**边界**:已经贴在上限余量之内时,目标会被夹到当前位置**之后**。
这时 ``applied_deg`` 是负的(臂会往回退一点点)。这不是 bug,是"宁可退
一点也不要撞限位";但必须如实报出来,否则日志里会出现"要转 150°、
实际转了 -2°"却没人解释。
"""
q = 3.0543 - math.radians(1.0) # 已经在余量(3°)之内
r = prewind(q_now_rad=q, want_deg=150.0, direction=1, limits=(-3.0543, 3.0543))
assert r.clamped and r.applied_deg < 0.0
assert r.target_rad <= 3.0543 - math.radians(DEFAULT_LIMIT_MARGIN_DEG) + 1e-12
def test_prewind_handles_degenerate_limits():
"""上下限比余量还窄(配置错)时,退到区间中点而不是静默走偏。"""
r = prewind(0.0, 90.0, 1, limits=(-0.01, 0.01), margin_deg=3.0)
assert math.isclose(r.target_rad, 0.0, abs_tol=1e-12)
assert r.clamped
# =============================================================================
# 绕轴旋转的验证
# =============================================================================
def _rot_z(deg: float) -> np.ndarray:
t = math.radians(deg)
return np.array(
[[math.cos(t), -math.sin(t), 0.0], [math.sin(t), math.cos(t), 0.0], [0.0, 0.0, 1.0]]
)
def _rot_x(deg: float) -> np.ndarray:
t = math.radians(deg)
return np.array(
[[1.0, 0.0, 0.0], [0.0, math.cos(t), -math.sin(t)], [0.0, math.sin(t), math.cos(t)]]
)
def test_rotation_about_axis_reads_the_signed_angle():
R0 = np.eye(3)
assert math.isclose(rotation_about_axis_deg(R0, _rot_z(30.0), [0, 0, 1]), 30.0, abs_tol=1e-9)
assert math.isclose(rotation_about_axis_deg(R0, _rot_z(-30.0), [0, 0, 1]), -30.0, abs_tol=1e-9)
# 转了很多圈也要给出同一个角(atan2 值域 ±180°,这里只用来核对单步)
assert math.isclose(rotation_about_axis_deg(R0, _rot_z(179.0), [0, 0, 1]), 179.0, abs_tol=1e-9)
def test_rotation_about_axis_ignores_an_orthogonal_rotation_axis():
"""绕 X 转 60°,在 Z 轴上的投影应为 0 —— 这正是"绕错轴"的读数特征。"""
val = rotation_about_axis_deg(np.eye(3), _rot_x(60.0), [0, 0, 1])
assert abs(val) < 1e-9
def test_axis_misalignment_separates_right_axis_from_wrong_axis():
"""S01 的判错依据:转对轴 ≈0°,转错轴 ≈90°。
只看"转了多少度"不够 —— 绕正确轴转 90° 和绕错误轴转 90° 的角度读数一样。
"""
R0 = np.eye(3)
assert axis_misalignment_deg(R0, _rot_z(45.0), [0, 0, 1]) < 1e-6
assert axis_misalignment_deg(R0, _rot_x(45.0), [0, 0, 1]) > 89.0
# 轴线取反方向应视为同一根轴(cos 取绝对值)
assert axis_misalignment_deg(R0, _rot_z(45.0), [0, 0, -1]) < 1e-6
# 几乎没转时谈不上"转错轴"
assert axis_misalignment_deg(R0, np.eye(3), [0, 0, 1]) == 0.0
def test_rotation_helpers_reject_zero_axis():
with pytest.raises(ValueError):
rotation_about_axis_deg(np.eye(3), _rot_z(10.0), [0.0, 0.0, 0.0])
def test_a7_limit_moves_only_the_last_joint():
"""逆时针到上限、顺时针到下限。前六轴必须原样,没有限位就拒绝。"""
q = np.array([0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.2])
limits = (-3.0543, 3.0543)
ccw = a7_limit_target(q, limits, direction=1, margin_deg=3.0)
cw = a7_limit_target(q, limits, direction=-1, margin_deg=3.0)
assert ccw is not None and cw is not None
np.testing.assert_allclose(ccw[:6], q[:6])
np.testing.assert_allclose(cw[:6], q[:6])
assert ccw[6] == pytest.approx(3.0543 - math.radians(3.0))
assert cw[6] == pytest.approx(-3.0543 + math.radians(3.0))
assert a7_limit_target(q, None, 1) is None
assert a7_limit_target(q, limits, 1, margin_deg=200.0) is None
def test_tool_z_and_vertical_down_error():
"""工具 Z 朝世界 -Z = 0°;水平 = 90°;朝上 = 180°。"""
R_down = np.diag([1.0, -1.0, -1.0])
assert np.allclose(unit_tool_z(R_down), [0.0, 0.0, -1.0])
assert vertical_down_error_deg(R_down) < 1e-6
assert math.isclose(vertical_down_error_deg(_rot_x(90.0) @ R_down), 90.0, abs_tol=1e-6)
assert math.isclose(vertical_down_error_deg(_rot_x(180.0) @ R_down), 180.0, abs_tol=1e-6)
@@ -0,0 +1,159 @@
"""主流程节点与状态机的**接缝**测试(不需要 ROS、不需要真机)。
为什么要有这一层
----------------
``gauge_assembly_node`` 的每个 ``_step_*`` 方法会返回一个 :class:`Event`,
由 ``gauge_fsm.TRANSITIONS`` 决定它去哪。这两份东西是**分开写的**:
* 状态机里没有的转移 → 运行到那一步抛 :class:`FsmError`(好:当场炸);
* 但如果是**最危险的那一步**才炸(力超限、掉件、关节极限),代价就大了。
所以这里直接读节点源码的 AST,把"每个阶段可能返回哪些事件"抽出来,
逐个问状态机"这一步合法吗"。这样新增/修改流程时,忘了登记转移会在
``pytest`` 里就报出来,而不是等到真机上。
"""
from __future__ import annotations
import ast
import pathlib
from typing import Dict, Set
import pytest
from arm_application.gauge_fsm import Event, GaugeContext, GaugeFsm, State
NODE_SRC = (
pathlib.Path(__file__).resolve().parents[1]
/ "arm_application" / "gauge_assembly_node.py"
)
def _node_ast() -> ast.Module:
return ast.parse(NODE_SRC.read_text(encoding="utf-8"))
def _step_map(tree: ast.Module) -> Dict[str, str]:
"""``State.X -> "_step_y"``(读节点的 ``_STEPS`` 表)。"""
for node in ast.walk(tree):
# 表是带类型标注的(``_STEPS: Dict[State, str] = {...}``)→ AnnAssign
target = None
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
target = node.target.id
elif isinstance(node, ast.Assign) and len(node.targets) == 1:
tgt = node.targets[0]
target = tgt.id if isinstance(tgt, ast.Name) else None
if target != "_STEPS":
continue
value = node.value
assert isinstance(value, ast.Dict), "_STEPS 必须是字面量字典(测试要读它)"
out: Dict[str, str] = {}
for k, v in zip(value.keys, value.values):
assert isinstance(k, ast.Attribute) and isinstance(v, ast.Constant)
out[k.attr] = str(v.value)
return out
raise AssertionError("节点里找不到 _STEPS 表")
def _events_in(node: ast.AST) -> Set[str]:
"""表达式里出现的所有 ``Event.XXX`` 名字。"""
return {
n.attr
for n in ast.walk(node)
if isinstance(n, ast.Attribute)
and isinstance(n.value, ast.Name)
and n.value.id == "Event"
}
def _returned_events(func: ast.FunctionDef) -> Set[str]:
"""一个 ``_step_*`` 方法可能返回的事件。
收集两处,别的地方不算(比如异常处理里顺手记一条 ``Event.ABORT`` 做日志,
那不是"返回"):
1. 所有 ``return <expr>`` 里的 ``Event.X``;
2. 所有 ``fsm_event=Event.X`` 关键字实参(喂给 ``_rotate_joint_and_verify``,
它会把成功事件原样返回)。
"""
out: Set[str] = set()
for node in ast.walk(func):
if isinstance(node, ast.Return) and node.value is not None:
out |= _events_in(node.value)
elif isinstance(node, ast.Call):
for kw in node.keywords:
if kw.arg == "fsm_event":
out |= _events_in(kw.value)
return out
def _func_by_name(tree: ast.Module, name: str) -> ast.FunctionDef:
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise AssertionError(f"节点里找不到方法 {name}")
def test_steps_table_covers_every_non_terminal_state() -> None:
"""每个非终态都要有处理方法 —— 漏一个就是"流程走到这儿不知道干什么"。"""
tree = _node_ast()
steps = _step_map(tree)
for st in State:
if st in (State.DONE, State.ABORT):
continue
assert st.name in steps, f"{st} 没有登记处理方法"
assert len(steps) == len(set(steps.values())), "一个方法不该被两个状态复用"
def test_every_returned_event_is_legal_in_that_state() -> None:
"""本阶段可能返回的每个事件,在状态机里必须合法。
这是本文件的主要断言:它把"节点写的事件"和"状态机登记的转移"对齐,
免得在真机上跑到那一步才抛 FsmError。
"""
tree = _node_ast()
problems = []
for state_name, method in _step_map(tree).items():
state = State[state_name]
func = _func_by_name(tree, method)
for ev_name in sorted(_returned_events(func)):
event = Event[ev_name]
# 每个事件都在"干净上下文 + 该状态"下问一次状态机
fsm = GaugeFsm(GaugeContext(holes_total=2), start=state)
if not fsm.can(event):
problems.append(f"{state.value} 的 {method} 会返回 {ev_name},但状态机不接受")
assert not problems, "节点与状态机不一致:\n " + "\n ".join(problems)
def test_s08_drop_goes_to_home_extreme() -> None:
"""E1 的落点必须还是 S01:它要在那里张开爪、等人工放好。
这条单列出来,是因为"掉件后去哪"很容易被顺手改成 S11("反正收工"),
那样掉在孔里的规就没人管了。
"""
fsm = GaugeFsm(GaugeContext(holes_total=2), start=State.SCREW_IN)
assert fsm.handle(Event.GAUGE_DROPPED) is State.HOME_EXTREME
assert fsm.handle(Event.RELOADED) is State.NEXT_HOLE
# 掉件后孔序号推进(看板:该孔记为完成,换下一个孔)
assert fsm.ctx.hole_index == 1
def test_contact_force_over_limit_aborts_not_retries() -> None:
"""法向力超上限 → 中止,不是"抬起来再来一次"。
超限说明已经压进去了(位置下限或规没对准),原地重试会把工件压变形。
"""
fsm = GaugeFsm(GaugeContext(holes_total=2), start=State.CONTACT)
assert fsm.handle(Event.FORCE_OVER_LIMIT) is State.ABORT
@pytest.mark.parametrize("state_name", ["SNAP", "HOVER_K"])
def test_snap_and_hover_have_a_back_edge(state_name: str) -> None:
"""S05/S06 必须能退回去:不稳定 → 再等;对位超差 → 重拍。
没有回边的结果是:判据没过就一路往下走(去接触、去拧),
而这是"孔没对准"最危险的表达方式。
"""
fsm = GaugeFsm(GaugeContext(holes_total=2), start=State[state_name])
back = Event.SNAP_UNSTABLE if state_name == "SNAP" else Event.ALIGN_OUT
assert fsm.can(back)
assert fsm.handle(back) is State.SNAP
@@ -0,0 +1,73 @@
"""Station gripper node must not open the bus until probe, and must recover a dead session."""
from __future__ import annotations
import pytest
rclpy = pytest.importorskip("rclpy")
from arm_application.dh_pgea import DhPgeaError, GripperState
from arm_application import gripper_node as gripper
class FakeGripper:
def __init__(self, **kwargs):
self.opens = 0
self.closes = 0
self.reads = 0
self.fail_until_open = 0
self.state = GripperState(init_state=1, grip_state=1, position=1000, target_position=1000, target_force=20)
def open(self):
self.opens += 1
def close(self):
self.closes += 1
def get_run_states(self, retries=8):
self.reads += 1
if self.opens <= self.fail_until_open:
raise DhPgeaError("在 /dev/ttyUSB0 上 Modbus 读 0x0201 失败(无有效应答 last=(empty))")
return self.state
@pytest.fixture()
def ros(monkeypatch):
monkeypatch.setattr(gripper, "DhPgeaGripper", FakeGripper)
rclpy.init()
try:
yield
finally:
rclpy.shutdown()
def test_startup_does_not_open_serial(ros):
node = gripper.GripperNode()
try:
assert node.driver.opens == 0
assert node._online is False
finally:
node.destroy_node()
def test_connect_closes_then_opens_before_reading(ros):
node = gripper.GripperNode()
try:
assert node._connect() is True
assert node.driver.closes >= 1
assert node.driver.opens == 1
assert node._online is True
assert node._reading.position == 1000
finally:
node.destroy_node()
def test_poll_reopens_after_empty_reply(ros):
node = gripper.GripperNode()
try:
assert node._connect() is True
node.driver.fail_until_open = node.driver.opens
node._poll()
assert node._online is True
assert node.driver.opens >= 2
finally:
node.destroy_node()
@@ -0,0 +1,462 @@
"""handeye_lib 的纯函数测试 —— 不需要机器人、相机或标定板。
这里最要紧的一个测试是 `test_roundtrip_recovers_known_transform`:
它用一个人为设定的 `X_true` 反过来生成每个视图的 (gripper→base, target→cam),
再让求解器去还原。这是唯一能在没有硬件的情况下真正验证"标定算得对不对"的办法 ——
如果求解链路有符号/转置/坐标系方向的错误,它一定会失败。
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from arm_application.handeye_lib import (
BoardConfig,
CameraIntrinsics,
ChessboardStabilizer,
Rt_to_T,
T_from_flexiv_tcp,
board_object_points,
calibrate_hand_eye,
cam_to_flange_via_tool,
format_transform_yaml,
invert_Rt,
is_pose_filled,
pose_to_Rt,
quat_xyzw_to_R,
)
def _rot(axis: str, deg: float) -> np.ndarray:
a = math.radians(deg)
c, s = math.cos(a), math.sin(a)
if axis == "x":
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]], float)
if axis == "y":
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], float)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], float)
def _rand_transform(rng: np.random.Generator) -> np.ndarray:
"""随机但物理上合法的刚体变换(旋转正交且 det=+1)。"""
R = _rot("x", rng.uniform(-180, 180)) @ _rot("y", rng.uniform(-180, 180)) @ _rot(
"z", rng.uniform(-180, 180)
)
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = rng.uniform(-0.6, 0.6, 3)
return T
# --------------------------------------------------------------------------
# 旋转/位姿基础
# --------------------------------------------------------------------------
def test_quat_identity_is_identity_matrix():
R = quat_xyzw_to_R([0.0, 0.0, 0.0, 1.0])
assert np.allclose(R, np.eye(3), atol=1e-12)
def test_quat_matches_rotation_matrix_for_known_angles():
for axis, deg in (("x", 30.0), ("y", -47.0), ("z", 121.0)):
q = _axis_angle_to_quat(axis, deg)
assert np.allclose(quat_xyzw_to_R(q), _rot(axis, deg), atol=1e-9), axis
def test_quat_is_normalized_before_use():
"""非单位四元数不该影响结果 —— 有些驱动会给未归一化的值。"""
q = np.array(_axis_angle_to_quat("z", 90.0)) * 3.7
assert np.allclose(quat_xyzw_to_R(list(q)), _rot("z", 90.0), atol=1e-9)
def _axis_angle_to_quat(axis: str, deg: float):
a = math.radians(deg) / 2.0
s, c = math.sin(a), math.cos(a)
if axis == "x":
return [s, 0.0, 0.0, c]
if axis == "y":
return [0.0, s, 0.0, c]
return [0.0, 0.0, s, c]
def test_pose_to_Rt_shapes():
R, t = pose_to_Rt([1.0, 2.0, 3.0], [0.0, 0.0, 0.0, 1.0])
assert R.shape == (3, 3)
assert t.shape == (3, 1)
assert np.allclose(t.reshape(3), [1.0, 2.0, 3.0])
def test_Rt_to_T_round_trip():
rng = np.random.default_rng(0)
R = _rot("z", 33.0)
t = np.array([[0.1], [-0.2], [0.3]])
T = Rt_to_T(R, t)
assert T.shape == (4, 4)
assert np.allclose(T[3], [0, 0, 0, 1])
assert np.allclose(T[:3, :3], R)
assert np.allclose(T[:3, 3], t.reshape(3))
def test_invert_Rt_is_true_inverse():
rng = np.random.default_rng(1)
R = _rot("x", 12.0) @ _rot("y", 88.0)
t = rng.uniform(-1, 1, (3, 1))
Ri, ti = invert_Rt(R, t)
T = Rt_to_T(R, t)
Ti = Rt_to_T(Ri, ti)
assert np.allclose(T @ Ti, np.eye(4), atol=1e-12)
assert np.allclose(Ti @ T, np.eye(4), atol=1e-12)
# --------------------------------------------------------------------------
# 标定板几何
# --------------------------------------------------------------------------
def test_board_object_points_count_and_layout():
cfg = BoardConfig(cols=8, rows=11, square_size=0.015)
pts = board_object_points(cfg)
assert pts.shape == (88, 3)
# z 全为 0(板平面),x/y 是网格
assert np.allclose(pts[:, 2], 0.0)
assert np.isclose(pts[:, 0].max(), 7 * 0.015)
assert np.isclose(pts[:, 1].max(), 10 * 0.015)
# 第一行是 0..7 个 x
assert np.allclose(pts[:8, 0], np.arange(8) * 0.015)
def test_board_square_size_actually_scales_points():
a = board_object_points(BoardConfig(cols=4, rows=3, square_size=0.01))
b = board_object_points(BoardConfig(cols=4, rows=3, square_size=0.03))
assert np.allclose(b, a * 3.0)
def test_camera_intrinsics_holds_k_and_dist():
K = np.array([[600.0, 0.0, 320.0], [0.0, 601.0, 240.0], [0.0, 0.0, 1.0]])
dist = np.zeros(5)
ci = CameraIntrinsics(K=K, dist=dist)
assert ci.K.shape == (3, 3)
assert ci.K[0, 0] == 600.0
assert ci.K[1, 2] == 240.0
assert ci.dist.size == 5
# --------------------------------------------------------------------------
# 稳定性判据
# --------------------------------------------------------------------------
def _fake_corners(n: int = 88, jitter: float = 0.0, seed: int = 0):
rng = np.random.default_rng(seed)
base = np.stack(
[np.linspace(0, 100, n), np.linspace(0, 70, n), np.zeros(n)], axis=1
).astype(np.float32)
if jitter:
base = base + rng.normal(0, jitter, base.shape).astype(np.float32)
return base
def test_stabilizer_not_stable_before_window_is_filled():
st = ChessboardStabilizer(window=8, max_pixel_std=0.45)
for _ in range(3):
ok, _std = st.push(_fake_corners(jitter=0.01))
assert not ok, "窗口没填满就不该判稳"
def test_stabilizer_declares_stable_for_identical_corners():
st = ChessboardStabilizer(window=5, max_pixel_std=0.45)
fixed = _fake_corners()
ok = False
for _ in range(6):
ok, std = st.push(fixed)
assert ok
assert std == pytest.approx(0.0, abs=1e-9)
def test_stabilizer_rejects_noisy_corners():
st = ChessboardStabilizer(window=5, max_pixel_std=0.45)
ok = False
for i in range(6):
ok, std = st.push(_fake_corners(jitter=5.0, seed=i))
assert not ok, "角点抖动 5px 远超 0.45px 阈值,不该判稳"
assert std > 0.45
def test_stabilizer_missing_board_resets_streak():
st = ChessboardStabilizer(window=3, max_pixel_std=0.45)
fixed = _fake_corners()
st.push(fixed)
st.push(fixed)
ok, _ = st.push(None) # 看不到板
assert not ok
# 重新开始计数,而不是接着之前的 2 次
ok, _ = st.push(fixed)
assert not ok
ok, _ = st.push(fixed)
assert not ok
ok, _ = st.push(fixed)
assert ok, "连续 3 帧相同角点后应判稳"
def test_stabilizer_reset_clears_history():
st = ChessboardStabilizer(window=3, max_pixel_std=0.45)
fixed = _fake_corners()
for _ in range(3):
st.push(fixed)
st.reset()
ok, _ = st.push(fixed)
assert not ok
# --------------------------------------------------------------------------
# 核心:合成数据往返(真正验证标定算法)
# --------------------------------------------------------------------------
def _synthesize_views(
X_true: np.ndarray, T_target2base: np.ndarray, n: int, seed: int = 7
):
"""由已知 X_true 生成 n 组视图观测。
eye-in-hand 的坐标链:target → camera → gripper → base
T_t2b = T_g2b · X · T_t2c (X = T_cam2gripper)
所以给定任意的 T_g2b,可反推出该视图下应该观测到的 T_t2c:
T_t2c = inv(X) · inv(T_g2b) · T_t2b
"""
rng = np.random.default_rng(seed)
Rg, tg, Rc, tc = [], [], [], []
Xinv = np.linalg.inv(X_true)
for _ in range(n):
T_g2b = _rand_transform(rng)
T_t2c = Xinv @ np.linalg.inv(T_g2b) @ T_target2base
Rg.append(T_g2b[:3, :3])
tg.append(T_g2b[:3, 3].reshape(3, 1))
Rc.append(T_t2c[:3, :3])
tc.append(T_t2c[:3, 3].reshape(3, 1))
return Rg, tg, Rc, tc
def test_roundtrip_recovers_known_transform_eye_in_hand():
rng = np.random.default_rng(42)
X_true = _rand_transform(rng)
T_target2base = _rand_transform(rng)
Rg, tg, Rc, tc = _synthesize_views(X_true, T_target2base, 12)
R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=False)
X_est = Rt_to_T(R, t)
assert np.allclose(X_est, X_true, atol=1e-6), (
"求解结果与已知真值不符 —— 坐标链方向或求逆有问题"
)
def test_roundtrip_translation_is_exact_not_just_rotation():
"""单独盯平移:旋转对但平移错,是最容易漏掉的一类 bug。"""
rng = np.random.default_rng(11)
X_true = _rand_transform(rng)
T_target2base = _rand_transform(rng)
Rg, tg, Rc, tc = _synthesize_views(X_true, T_target2base, 15)
_R, t = calibrate_hand_eye(Rg, tg, Rc, tc)
assert np.allclose(np.asarray(t).reshape(3), X_true[:3, 3], atol=1e-6)
def test_roundtrip_is_rotation_matrix_with_det_one():
rng = np.random.default_rng(5)
X_true = _rand_transform(rng)
Rg, tg, Rc, tc = _synthesize_views(X_true, _rand_transform(rng), 10)
R, _t = calibrate_hand_eye(Rg, tg, Rc, tc)
R = np.asarray(R, float)
assert np.isclose(np.linalg.det(R), 1.0, atol=1e-8), "det 必须是 +1,不是镜像"
assert np.allclose(R @ R.T, np.eye(3), atol=1e-8), "必须是正交阵"
def _synthesize_views_eye_to_hand(
X_true: np.ndarray, T_target2gripper: np.ndarray, n: int, seed: int = 7
):
"""由已知 X_true 生成 n 组**眼在手外**的视图观测。
眼在手外的坐标链:相机固定在工位外不动,**标定板装在法兰上随臂动**。
T_t2b = T_g2b · T_t2g (板在基座 = 法兰在基座 · 板在法兰,T_t2g 固定)
T_t2b = X · T_t2c (X = T_cam2base)
所以给定任意的 T_g2b,可反推出该视图下应该观测到的 T_t2c:
T_t2c = inv(X) · T_g2b · T_t2g
**为什么板必须在法兰上**:相机与基座都不动,唯一会变的就是带着板走的法兰;
板若像眼在手上那样摆在台面上不动,T_t2c 就恒定不变,方程直接退化。
"""
rng = np.random.default_rng(seed)
Rg, tg, Rc, tc = [], [], [], []
Xinv = np.linalg.inv(X_true)
for _ in range(n):
T_g2b = _rand_transform(rng)
T_t2c = Xinv @ T_g2b @ T_target2gripper
Rg.append(T_g2b[:3, :3])
tg.append(T_g2b[:3, 3].reshape(3, 1))
Rc.append(T_t2c[:3, :3])
tc.append(T_t2c[:3, 3].reshape(3, 1))
return Rg, tg, Rc, tc
def test_roundtrip_recovers_known_transform_eye_to_hand():
"""眼在手外:X = cam → base,**与真值比对**(不是只查正交/det)。
这条分支内部把 gripper2base 求了逆。求逆方向或坐标链写错时,结果照样是一个
合法的旋转矩阵 —— 只查 det/正交的测试**抓不到**,所以必须比真值。
"""
rng = np.random.default_rng(3)
X_true = _rand_transform(rng) # T_cam2base
T_target2gripper = _rand_transform(rng) # 板刚性固定在法兰上(每视图不变)
Rg, tg, Rc, tc = _synthesize_views_eye_to_hand(X_true, T_target2gripper, 14)
R, t = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=True)
X_est = Rt_to_T(R, t)
assert np.allclose(X_est, X_true, atol=1e-6), (
"eye_to_hand 没解回已知的 T_cam2base —— 求逆方向或坐标链错了"
)
R = np.asarray(R, float)
assert np.isclose(np.linalg.det(R), 1.0, atol=1e-8), "det 必须是 +1,不是镜像"
def test_eye_to_hand_flag_actually_changes_the_answer():
"""负向:同一份眼在手外数据当成眼在手上解,**必须解出不同的东西**。
给上面那条把关:万一哪天 `eye_to_hand` 分支被改成"其实什么也没做"
(两个模式走同一条路),上面那条测试就会失去意义 —— 而这条会红。
"""
rng = np.random.default_rng(3)
X_true = _rand_transform(rng)
T_target2gripper = _rand_transform(rng)
Rg, tg, Rc, tc = _synthesize_views_eye_to_hand(X_true, T_target2gripper, 14)
R_ok, t_ok = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=True)
R_bad, t_bad = calibrate_hand_eye(Rg, tg, Rc, tc, eye_to_hand=False)
assert np.allclose(Rt_to_T(R_ok, t_ok), X_true, atol=1e-6)
assert not np.allclose(Rt_to_T(R_bad, t_bad), X_true, atol=1e-3), (
"眼在手上分支也解出了同一个真值 —— 说明 eye_to_hand 这个开关根本没起作用"
)
def test_calibration_fails_loudly_on_degenerate_input():
"""全同位姿(没有旋转多样性)时不该悄悄给个"看起来能用"的结果。"""
rng = np.random.default_rng(9)
X_true = _rand_transform(rng)
Rg, tg, Rc, tc = _synthesize_views(X_true, _rand_transform(rng), 6)
same_R, same_t = Rg[0], tg[0]
Rg = [same_R] * 6
tg = [same_t] * 6
Rc = [Rc[0]] * 6
tc = [tc[0]] * 6
try:
R, _t = calibrate_hand_eye(Rg, tg, Rc, tc)
except Exception:
return # 报错是可以接受的
# 若没报错,结果必须明显不可信(不该恰好等于真值)
assert not np.allclose(Rt_to_T(np.asarray(R, float), np.zeros(3)), X_true, atol=1e-6)
# --------------------------------------------------------------------------
# 输出格式
# --------------------------------------------------------------------------
def test_format_transform_yaml_shape_and_parseable():
import yaml
R = _rot("z", 90.0)
t = np.array([[0.01], [0.02], [0.03]])
txt = format_transform_yaml(R, t, "unit test")
assert "# unit test" in txt
data = yaml.safe_load(txt)
T = np.array(data["T_4x4"], float)
assert T.shape == (4, 4)
assert np.allclose(T, Rt_to_T(R, t), atol=1e-7)
assert np.allclose(data["translation_m"], [0.01, 0.02, 0.03], atol=1e-8)
def test_format_transform_yaml_has_bottom_row():
import yaml
txt = format_transform_yaml(np.eye(3), np.zeros((3, 1)), "x")
data = yaml.safe_load(txt)
assert np.allclose(data["T_4x4"][3], [0, 0, 0, 1])
def test_roundtrip_write_then_read_back_is_stable():
"""写出去再读回来,变换不该漂移(避免精度不足导致反复重标定)。"""
import yaml
rng = np.random.default_rng(21)
R = _rot("x", 37.0) @ _rot("z", -14.0)
t = rng.uniform(-0.2, 0.2, (3, 1))
txt = format_transform_yaml(R, t, "roundtrip")
T = np.array(yaml.safe_load(txt)["T_4x4"], float)
assert np.allclose(T[:3, :3], R, atol=1e-7)
assert np.allclose(T[:3, 3], t.reshape(3), atol=1e-7)
# --------------------------------------------------------------------------
# 位姿占位检测(决定哪些点参与标定)
# --------------------------------------------------------------------------
def test_is_pose_filled_rejects_placeholder_zeros():
assert not is_pose_filled([0.0] * 7)
def test_is_pose_filled_rejects_nan():
assert not is_pose_filled([float("nan")] * 7)
def test_is_pose_filled_rejects_wrong_length():
assert not is_pose_filled([0.1, 0.2, 0.3])
assert not is_pose_filled([0.1] * 8)
def test_is_pose_filled_accepts_real_pose():
assert is_pose_filled([-1.107, -0.744, 0.982, 1.856, -0.735, 0.947, -0.118])
def test_flexiv_tcp_identity_is_pure_translation():
"""Studio tool10120938:姿态单位四元数,只沿法兰 Z 偏 224.5 mm。"""
T = T_from_flexiv_tcp([-0.0005, 0.0067, 0.2245], [1.0, 0.0, 0.0, 0.0])
assert np.allclose(T[:3, :3], np.eye(3), atol=1e-12)
assert T[2, 3] == pytest.approx(0.2245)
def test_cam_to_flange_via_tool_must_not_leave_t_unchanged():
"""忘了乘时 T_4x4 停在 TCP;乘反了相机会到法兰后方。"""
T_cam_tool = np.eye(4)
T_cam_tool[:3, 3] = [-0.05961995, -0.01523625, -0.09761517]
T_flange_tool = T_from_flexiv_tcp([-0.0005, 0.0067, 0.2245], [1.0, 0.0, 0.0, 0.0])
T_cam_flange = cam_to_flange_via_tool(T_cam_tool, T_flange_tool)
delta = float(np.linalg.norm(T_cam_flange[:3, 3] - T_cam_tool[:3, 3]))
tool_len = float(np.linalg.norm(T_flange_tool[:3, 3]))
assert delta == pytest.approx(tool_len, abs=1e-9)
assert tool_len > 0.20
# 相机在法兰前方(+Z ≈ 126 mm),与 2026-09-04 旧结果同量级。
assert T_cam_flange[2, 3] == pytest.approx(0.12688483, abs=1e-8)
assert T_cam_flange[0, 3] == pytest.approx(-0.06011995, abs=1e-8)
# 负向:乘反了会到法兰后方约 −296 mm。
wrong = T_cam_tool @ np.linalg.inv(T_flange_tool)
assert wrong[2, 3] < 0.0
# 负向:直接把 cam→工具当成 cam→法兰,平移几乎不动。
assert float(np.linalg.norm(T_cam_tool[:3, 3] - T_cam_tool[:3, 3])) < 1e-12
# 折回去必须还原:T_ct = inv(T_ft) · T_cf
T_back = np.linalg.inv(T_flange_tool) @ T_cam_flange
assert np.allclose(T_back, T_cam_tool, atol=1e-12)
def test_cam_at_tcp_is_the_tool_pose_in_flange():
"""相机与 TCP 重合时,cam→法兰必须等于法兰→工具,不能是它的逆。"""
T_ft = T_from_flexiv_tcp([-0.0005, 0.0067, 0.2245], [1.0, 0.0, 0.0, 0.0])
T_cf = cam_to_flange_via_tool(np.eye(4), T_ft)
assert np.allclose(T_cf, T_ft, atol=1e-12)
assert T_cf[2, 3] == pytest.approx(0.2245)
@@ -0,0 +1,146 @@
"""手眼文件读取与四元数换算的回环测试(用真实标定产物)。
为什么值得单测这个
------------------
``R_to_quat_xyzw`` 这段换算在仓库里被抄过好几遍,并且**抄错过**:某个分支的
公式写歪之后,四元数还是"像模像样的数字",静态 TF 也照发。症状是手眼外参
差了 30°:定位总能落到工件附近(因为平移是对的),但永远差一点,
看起来像"相机标定不准",很难查到是 launch 里的一行除法。
所以判据必须是**数学回环**(``quat → R`` 要能还原 ``R``),而不是"看数字合不合理"。
真实标定文件(``handeye/results/*.yaml``)也一起测:文件是人手拷来拷去的,
形状/字段被改坏时,这里直接报错比发一个全零 TF 好。
"""
from __future__ import annotations
import math
import pathlib
import numpy as np
import pytest
from arm_application.handeye_lib import (
R_to_quat_xyzw,
quat_xyzw_to_R,
read_transform_yaml,
)
def _repo_root() -> pathlib.Path:
here = pathlib.Path(__file__).resolve()
for p in here.parents:
if (p / "config" / "site.yaml").is_file():
return p
raise AssertionError("找不到仓库根(config/site.yaml)")
def _rot(axis: str, deg: float) -> np.ndarray:
a = math.radians(deg)
c, s = math.cos(a), math.sin(a)
if axis == "x":
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]], dtype=float)
if axis == "y":
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], dtype=float)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], dtype=float)
@pytest.mark.parametrize("axis", ["x", "y", "z"])
@pytest.mark.parametrize("deg", [0.0, 1.0, 45.0, 90.0, 179.0, 180.0, 181.0, 270.0])
def test_quaternion_roundtrip_by_axis(axis: str, deg: float) -> None:
"""绕单轴转任意角度:``quat → R`` 必须还原原矩阵。
覆盖 0/180/270 这些"某个分量恰好为零"的角度 —— 四元数换算的分支
正是在这些角度上切换的,抄错分支的公式只会在这些角度露出来。
"""
R = _rot(axis, deg)
q = R_to_quat_xyzw(R)
assert abs(math.sqrt(sum(v * v for v in q)) - 1.0) < 1e-9, "四元数未归一化"
R2 = quat_xyzw_to_R(q)
assert np.allclose(R, R2, atol=1e-9), f"轴 {axis} 转角 {deg}:回环不一致"
def test_quaternion_roundtrip_on_composite_rotations() -> None:
"""复合旋转(三个分支都会被走到)+ 随机旋转。"""
mats = [
_rot("x", 30.0) @ _rot("y", -70.0) @ _rot("z", 120.0),
_rot("z", 179.9) @ _rot("x", 90.0),
_rot("y", 180.0) @ _rot("x", 180.0),
]
rng = np.random.default_rng(20260924)
for _ in range(50):
A = rng.normal(size=(3, 3))
Q, _R = np.linalg.qr(A)
if np.linalg.det(Q) < 0:
Q[:, 0] *= -1.0
mats.append(Q)
for R in mats:
assert np.allclose(R, quat_xyzw_to_R(R_to_quat_xyzw(R)), atol=1e-8)
def test_read_real_handeye_files_match_their_own_matrix() -> None:
"""真实标定文件:读出来的 xyz/quat 必须和文件里的 T_4x4 自洽。
这条断言的价值在于"文件 + 读取代码 + 换算"三者一旦有一个变味就会报。
"""
results = _repo_root() / "handeye" / "results"
files = sorted(results.glob("*_eye_in_hand.yaml"))
if not files:
pytest.skip("仓库里没有手眼标定产物(还没标定过)")
import yaml
for f in files:
xyz, quat = read_transform_yaml(f)
# 直接拿文件里的 T_4x4 当基准(不手写解析:解析器本身就是被测对象之一,
# 用手写解析比对等于让两份解析互相印错)
T_file = np.asarray(yaml.safe_load(f.read_text(encoding="utf-8"))["T_4x4"], dtype=float)
assert T_file.shape == (4, 4), f"{f.name} 的 T_4x4 形状不对"
R = quat_xyzw_to_R(quat)
assert np.allclose(xyz, T_file[:3, 3], atol=1e-6), f"{f.name} 平移不一致"
assert np.allclose(R, T_file[:3, :3], atol=1e-6), f"{f.name} 旋转不一致"
def test_read_transform_yaml_rejects_broken_files(tmp_path: pathlib.Path) -> None:
"""缺字段 / 形状不对 → 明确报错,而不是回一堆 nan 去发 TF。"""
(tmp_path / "no_field.yaml").write_text("translation_m: [0, 0, 0]\n", encoding="utf-8")
with pytest.raises(ValueError, match="T_4x4"):
read_transform_yaml(tmp_path / "no_field.yaml")
(tmp_path / "bad_shape.yaml").write_text(
"T_4x4:\n - [1, 0, 0, 0]\n - [0, 1, 0, 0]\n", encoding="utf-8"
)
with pytest.raises(ValueError, match="4x4"):
read_transform_yaml(tmp_path / "bad_shape.yaml")
def test_identical_rotation_gives_same_quaternion_up_to_sign() -> None:
"""同一个姿态的四元数允许差一个整体符号(q 与 -q 同一个旋转)。
写这条是因为很容易顺手加一句"四元数必须逐位相等"的断言,然后在某次
标定重跑后莫名其妙地红 —— 那不是回归,是符号约定。
"""
R = _rot("z", 100.0)
q1 = np.array(R_to_quat_xyzw(R))
q2 = np.array(R_to_quat_xyzw(R))
assert np.allclose(q1, q2)
assert np.allclose(quat_xyzw_to_R(-q1), quat_xyzw_to_R(q1))
def test_launch_files_do_not_reimplement_the_quaternion_math() -> None:
"""launch 文件里不许再出现自己写的四元数换算。
这段换算曾在 4 个 launch 里各抄一份,而且**四份已经抄得不完全一样**
(退化分支的公式各不相同)。共用的那一份在 ``handeye_lib`` 里,有回环测试;
自己再写一份就等于加了一处没人测的数学。要用就 ``read_transform_yaml``。
"""
launch_dir = _repo_root() / "src" / "arm_application" / "launch"
bad = [
f.name
for f in sorted(launch_dir.glob("*.launch.py"))
if "_quat_from_R" in f.read_text(encoding="utf-8")
]
assert not bad, (
"这些 launch 自己实现了四元数换算:"
+ ", ".join(bad)
+ ";请改用 arm_application.handeye_lib.read_transform_yaml"
)
@@ -0,0 +1,459 @@
"""眼在手外 / 眼在手上必须按模式分开:板尺寸、相机、结果文件名、TF 父子。
这些不是风格问题。两种装法用两块不同的物理板、两台不同的相机,解出的
矩阵语义也不同(cam→法兰 vs cam→基座)。共用一份配置的后果不是报错,
而是"标定跑完、结果系统性偏"——现场几乎联想不到是板尺寸/相机/文件名
串了。所以这里每条测试都配一条**负向**:故意走错模式必须红。
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS_LIB = ROOT / "scripts" / "lib"
SCRIPT = ROOT / "scripts" / "run_handeye.sh"
sys.path.insert(0, str(SCRIPTS_LIB))
import export_site_env # type: ignore # noqa: E402
from arm_application.site_io import ( # noqa: E402
eye_to_hand_result_name,
handeye_static_tf_frames,
overhead_camera,
resolve_eye_to_hand_file,
)
def _site(**over) -> dict:
"""最小可 flatten 的虚构站点。调用方可覆盖任意段。"""
base = {
"robot": {
"sn": "Rizon4-777001",
"robots": [
{
"sn": "Rizon4-777001",
"arm": "left",
"label": "左臂01",
"camera": {"name": "cam_a", "serial": "99900001"},
},
{
"sn": "Rizon4-777002",
"arm": "right",
"label": "右臂02",
"camera": {"name": "cam_b", "serial": "99900002"},
},
],
},
"cameras": {
"d435": {
"name": "d435",
"role": "overhead_eye_to_hand",
"serial": "99900003",
"width": 1280,
"height": 720,
},
"sim_d435": {
"name": "sim_d435",
"role": "overhead_eye_to_hand",
"arm": "sim",
"serial": "",
},
},
"handeye": {
"mode": "eye_in_hand",
"boards": {
"eye_in_hand": {"cols": 8, "rows": 11, "square_size_m": 0.015},
"eye_to_hand": {"cols": 11, "rows": 8, "square_size_m": 0.020},
},
},
}
for k, v in over.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
merged = dict(base[k])
merged.update(v)
base[k] = merged
else:
base[k] = v
return base
def _flat(monkeypatch, site: dict, sn: str = "Rizon4-777001") -> dict:
monkeypatch.setenv("SITE_CONFIG", "/nonexistent/site.yaml")
monkeypatch.setenv("ROBOT_SN", sn)
return export_site_env.flatten(site)
# ---------------------------------------------------------------------------
# 板尺寸按模式
# ---------------------------------------------------------------------------
class TestHandeyeBoardPerMode:
def test_two_modes_keep_different_boards(self, monkeypatch):
flat = _flat(monkeypatch, _site())
assert flat["SITE_HANDEYE_BOARD_COLS_EYE_IN_HAND"] == "8"
assert flat["SITE_HANDEYE_BOARD_ROWS_EYE_IN_HAND"] == "11"
assert flat["SITE_HANDEYE_SQUARE_EYE_IN_HAND"] == "0.015"
assert flat["SITE_HANDEYE_BOARD_SRC_EYE_IN_HAND"] == "boards"
assert flat["SITE_HANDEYE_BOARD_COLS_EYE_TO_HAND"] == "11"
assert flat["SITE_HANDEYE_BOARD_ROWS_EYE_TO_HAND"] == "8"
assert flat["SITE_HANDEYE_SQUARE_EYE_TO_HAND"] == "0.02"
assert flat["SITE_HANDEYE_BOARD_SRC_EYE_TO_HAND"] == "boards"
def test_current_mode_board_follows_handeye_mode(self, monkeypatch):
site = _site()
site["handeye"]["mode"] = "eye_to_hand"
flat = _flat(monkeypatch, site)
assert flat["SITE_HANDEYE_BOARD_COLS"] == "11"
assert flat["SITE_HANDEYE_SQUARE_M"] == "0.02"
def test_legacy_top_level_keys_still_work(self):
he = {"board_cols": 9, "board_rows": 6, "square_size_m": 0.025}
board, src = export_site_env.handeye_board(he, "eye_in_hand")
assert src == "legacy"
assert board == {"cols": 9, "rows": 6, "square_size_m": 0.025}
def test_missing_board_is_default_not_silent_guess_disguised_as_config(self):
"""没配过这块板 → 来源必须是 default。run_handeye.sh 见 default 会拦下。
如果这里返回 boards / legacy,脚本会以为"配过了",带着内置 8×11/15mm
去标 A4 板 —— 标定照样跑完,结果系统性偏。
"""
board, src = export_site_env.handeye_board({}, "eye_to_hand")
assert src == "default"
# 默认值本身仍给出来(给 show 打印),但来源必须让调用方能拦。
assert board["cols"] == 8
def test_unknown_mode_is_default(self):
he = {
"boards": {
"eye_in_hand": {"cols": 8, "rows": 11, "square_size_m": 0.015},
}
}
_, src = export_site_env.handeye_board(he, "eye_on_foot")
assert src == "default"
def test_negative_shared_keys_cannot_serve_both_modes(self):
"""负向:只有一组顶层键时,两种模式会读到**同一块板**。
这正是旧配置的失败模式。新配置(boards 段)必须让这条不成立。
"""
he_old = {"board_cols": 8, "board_rows": 11, "square_size_m": 0.015}
a, _ = export_site_env.handeye_board(he_old, "eye_in_hand")
b, _ = export_site_env.handeye_board(he_old, "eye_to_hand")
assert a == b, "旧口径下两种模式本就共用一块板(这条钉住旧逻辑会红的前提)"
he_new = {
"boards": {
"eye_in_hand": {"cols": 8, "rows": 11, "square_size_m": 0.015},
"eye_to_hand": {"cols": 11, "rows": 8, "square_size_m": 0.020},
}
}
a2, _ = export_site_env.handeye_board(he_new, "eye_in_hand")
b2, _ = export_site_env.handeye_board(he_new, "eye_to_hand")
assert a2 != b2
assert a2["square_size_m"] == 0.015
assert b2["square_size_m"] == 0.020
# ---------------------------------------------------------------------------
# 手外相机按 role + arm
# ---------------------------------------------------------------------------
class TestOverheadCamera:
def test_real_arm_picks_shared_d435_not_sim(self):
site = _site()
cam, others = overhead_camera(site, {"arm": "left"})
assert cam.get("name") == "d435"
assert others == []
def test_sim_arm_picks_sim_d435(self):
site = _site()
cam, _ = overhead_camera(site, {"arm": "sim"})
assert cam.get("name") == "sim_d435"
def test_no_overhead_returns_empty(self):
cam, others = overhead_camera({"cameras": {"d405": {"role": "left_wrist"}}})
assert cam == {}
assert others == []
def test_ambiguous_cameras_surface_the_others(self):
"""两台都没标 arm → 取第一台,但其余名字必须返回,让调用方出声。"""
site = {
"cameras": {
"a": {"name": "a", "role": "overhead_eye_to_hand"},
"b": {"name": "b", "role": "overhead_eye_to_hand"},
}
}
cam, others = overhead_camera(site, {"arm": "left"})
assert cam.get("name") in ("a", "b")
assert len(others) == 1
assert others[0] != cam.get("name")
# ---------------------------------------------------------------------------
# 结果文件名
# ---------------------------------------------------------------------------
class TestEyeToHandResultName:
def test_name_carries_arm_and_camera(self):
assert eye_to_hand_result_name("89", "d435") == "89_d435_eye_to_hand.yaml"
def test_two_arms_do_not_share_a_file(self, monkeypatch):
site = _site()
left = _flat(monkeypatch, site, sn="Rizon4-777001")
right = _flat(monkeypatch, site, sn="Rizon4-777002")
assert left["SITE_HANDEYE_FILE_EYE_TO_HAND"] != right["SITE_HANDEYE_FILE_EYE_TO_HAND"]
assert left["SITE_HANDEYE_FILE_EYE_TO_HAND"].endswith("01_d435_eye_to_hand.yaml")
assert right["SITE_HANDEYE_FILE_EYE_TO_HAND"].endswith("02_d435_eye_to_hand.yaml")
def test_eye_in_hand_file_is_untouched(self, monkeypatch):
"""换模式不能改腕部标定那份路径 —— S05 精拍定位一直读它。"""
site = _site()
site["handeye"]["mode"] = "eye_to_hand"
flat = _flat(monkeypatch, site)
assert flat["SITE_HANDEYE_FILE"].endswith("01_eye_in_hand.yaml")
assert flat["SITE_HANDEYE_FILE_EYE_TO_HAND"].endswith("01_d435_eye_to_hand.yaml")
def test_resolve_prefers_explicit_over_legacy(self, tmp_path: Path):
explicit = tmp_path / "89_d435_eye_to_hand.yaml"
legacy = tmp_path / "handeye" / "results" / "d435_eye_to_hand.yaml"
legacy.parent.mkdir(parents=True)
explicit.write_text("explicit\n")
legacy.write_text("legacy\n")
got = resolve_eye_to_hand_file(
tmp_path, explicit=str(explicit), camera_name="d435"
)
assert got == explicit
def test_resolve_falls_back_to_camera_named_file(self, tmp_path: Path):
legacy = tmp_path / "handeye" / "results" / "d435_eye_to_hand.yaml"
legacy.parent.mkdir(parents=True)
legacy.write_text("legacy\n")
got = resolve_eye_to_hand_file(tmp_path, explicit="", camera_name="d435")
assert got == legacy
def test_resolve_missing_returns_none_not_a_guess(self, tmp_path: Path):
assert resolve_eye_to_hand_file(tmp_path, camera_name="d435") is None
def test_resolve_relative_explicit_is_under_root_not_cwd(self, tmp_path: Path):
"""相对路径必须按仓库根解析。跟 CWD 走会在换目录启动时静默找不到。"""
(tmp_path / "handeye" / "results").mkdir(parents=True)
target = tmp_path / "handeye" / "results" / "89_d435_eye_to_hand.yaml"
target.write_text("ok\n")
got = resolve_eye_to_hand_file(
tmp_path,
explicit="handeye/results/89_d435_eye_to_hand.yaml",
camera_name="d435",
)
assert got == target
# ---------------------------------------------------------------------------
# TF 父子
# ---------------------------------------------------------------------------
class TestHandeyeStaticTfFrames:
def test_eye_in_hand_is_flange_parent(self):
parent, child = handeye_static_tf_frames(
mode="eye_in_hand",
ee_frame="Rizon4-063989_flange",
base_frame="Rizon4-063989_base_link",
cam_frame="d405_left_color_optical_frame",
)
assert parent == "Rizon4-063989_flange"
assert child == "d405_left_color_optical_frame"
def test_eye_to_hand_is_base_parent(self):
parent, child = handeye_static_tf_frames(
mode="eye_to_hand",
ee_frame="Rizon4-063989_flange",
base_frame="Rizon4-063989_base_link",
cam_frame="d435_color_optical_frame",
)
assert parent == "Rizon4-063989_base_link"
assert child == "d435_color_optical_frame"
def test_negative_wrong_mode_puts_camera_under_flange(self):
"""负向:眼在手外的文件若按眼在手上发 TF,父系会变成法兰。
这正是旧 launch 的失败模式。新函数必须让这条不成立。
"""
wrong, _ = handeye_static_tf_frames(
mode="eye_in_hand",
ee_frame="flange",
base_frame="base",
cam_frame="d435_color_optical_frame",
)
right, _ = handeye_static_tf_frames(
mode="eye_to_hand",
ee_frame="flange",
base_frame="base",
cam_frame="d435_color_optical_frame",
)
assert wrong == "flange"
assert right == "base"
assert wrong != right
# ---------------------------------------------------------------------------
# run_handeye.sh --mode 真的切到对应的板 / 相机 / 文件
# ---------------------------------------------------------------------------
def _show(mode: str, site: Path) -> str:
env = os.environ.copy()
env["SITE_CONFIG"] = str(site)
env["ROBOT_SN"] = "Rizon4-063989"
proc = subprocess.run(
["bash", str(SCRIPT), "--arm", "89", "show", "--mode", mode],
cwd=str(ROOT),
env=env,
text=True,
capture_output=True,
check=False,
)
return proc.stdout + proc.stderr
@pytest.fixture(scope="module")
def _real_site() -> Path:
"""脚本走 load_site_config.sh,必须是一份能选出 89 臂的真实形状配置。"""
return ROOT / "config" / "site.yaml"
@pytest.mark.skip(reason="只同步 ROS 节点;远程 UI、shell 和整站配置不在本次范围")
class TestRunHandeyeScriptMode:
def test_eye_to_hand_uses_d435_and_a4_board(self, _real_site: Path):
out = _show("eye_to_hand", _real_site)
assert "mode=eye_to_hand" in out
assert "camera=d435" in out
assert "11x8 inner, square=0.02 m" in out
assert "89_d435_eye_to_hand.yaml" in out
assert "tool10120938" in out
# 负向:不能再去开腕部相机。
assert "camera=d405" not in out
def test_eye_in_hand_keeps_wrist_camera_and_a5_board(self, _real_site: Path):
out = _show("eye_in_hand", _real_site)
assert "mode=eye_in_hand" in out
assert "d405" in out
assert "11x8 inner, square=0.015 m" in out
assert "tool10120938" in out
assert "89_eye_in_hand.yaml" in out
def test_missing_board_blocks_calibrate(self, tmp_path: Path):
"""没配 eye_to_hand 板尺寸时,calibrate 必须当场红,不能去赌默认值。"""
site = {
"robot": {
"sn": "Rizon4-063989",
"robots": [
{
"sn": "Rizon4-063989",
"arm": "left",
"camera": {"name": "d405_left", "serial": "1"},
}
],
},
"handeye": {
"mode": "eye_in_hand",
"boards": {
"eye_in_hand": {"cols": 8, "rows": 11, "square_size_m": 0.015},
},
},
"cameras": {
"d435": {
"name": "d435",
"role": "overhead_eye_to_hand",
"serial": "9",
}
},
}
p = tmp_path / "site.yaml"
p.write_text(yaml.safe_dump(site, allow_unicode=True), encoding="utf-8")
env = os.environ.copy()
env["SITE_CONFIG"] = str(p)
env["ROBOT_SN"] = "Rizon4-063989"
proc = subprocess.run(
["bash", str(SCRIPT), "--arm", "89", "calibrate", "--mode", "eye_to_hand"],
cwd=str(ROOT),
env=env,
text=True,
capture_output=True,
check=False,
)
assert proc.returncode != 0
assert "没有配置 [eye_to_hand] 用的标定板尺寸" in (proc.stdout + proc.stderr)
def test_left_arm_calibrate_wires_tool10120938(self):
"""89 必须按 tool10120938 求解;脚本和节点都要接到这份 TCP。"""
cal = (
ROOT / "src" / "arm_application" / "arm_application" / "handeye_calibrate.py"
).read_text(encoding="utf-8")
sh = SCRIPT.read_text(encoding="utf-8")
assert "cam_to_flange_via_tool(" in cal
assert "gripper_tool" in cal
assert "gripper_tool:=" in sh
assert "T_4x4 仍是 cam→基座,不折" in sh
backup = ROOT / "handeye" / "results" / "backup" / "89_eye_in_hand_flange_tool1_20260904.yaml"
assert backup.is_file()
e2h_bak = ROOT / "handeye" / "results" / "backup" / "89_d435_eye_to_hand_20261009.yaml"
assert e2h_bak.is_file()
e2h = (ROOT / "handeye" / "results" / "89_d435_eye_to_hand.yaml").read_text(
encoding="utf-8"
)
assert "gripper_tool: tool10120938" in e2h
assert "cam→基座" in e2h
import re
m_e2h = re.search(r"translation_m:\s*\[([^]]+)\]", e2h)
assert m_e2h, e2h
xyz_e2h = [float(x) for x in m_e2h.group(1).split(",")]
# 顶置相机在基座上方约 0.7 m;折错或写到法兰会差几十厘米。
assert 0.60 < xyz_e2h[2] < 0.90, xyz_e2h
# 眼在手外不得走 cam_to_flange_via_tool(相机和基座都在世界里)。
e2h_block = cal.split("if eye_to_hand:")[1].split("elif self.gripper_tool:")[0]
assert "cam_to_flange_via_tool" not in e2h_block
live = ROOT / "handeye" / "results" / "89_eye_in_hand.yaml"
text = live.read_text(encoding="utf-8")
assert "BACKUP" in text or "backup/" in text
# 乘反了 tz 会是负的(法兰后方);现行必须在法兰前方。
import re
m = re.search(r"translation_m:\s*\[([^]]+)\]", text)
assert m, text
xyz = [float(x) for x in m.group(1).split(",")]
assert xyz[2] > 0.10, xyz
assert "inv(T_flange_to_tool)" not in text
def test_teach_and_merge_share_the_same_taught_file(self):
"""示教写 A、merge 读 B → 采了点也报 got 0。两边必须用 TAUGHT。"""
sh = SCRIPT.read_text(encoding="utf-8")
teach = (
ROOT / "src" / "arm_application" / "arm_application" / "handeye_teach.py"
).read_text(encoding="utf-8")
assert 'poses_file:="${TAUGHT}"' in sh
assert "taught_poses_${HANDEYE_MODE}" in sh
assert "declare_parameter('poses_file'" in teach
assert "get_parameter('poses_file')" in teach
def test_illegal_mode_exits_immediately(self):
proc = subprocess.run(
["bash", str(SCRIPT), "--arm", "89", "show", "--mode", "eye_on_foot"],
cwd=str(ROOT),
text=True,
capture_output=True,
check=False,
)
assert proc.returncode != 0
assert "eye_in_hand 或 eye_to_hand" in (proc.stdout + proc.stderr)
@@ -0,0 +1,139 @@
"""`hole_detector` 权重路径解析的回归测试。
重点是挡住一类**悬空引用**:代码里写死的默认权重路径指向一个根本不存在的文件。
这种 bug 特别难查,因为
* 配置齐全时永远走不到那条分支,日常运行完全正常;
* 一旦 site.yaml 缺键才会触发,而报错信息里打印的路径看起来非常合理
(`.../models/hole_insert/best.pt`),很容易被误判成"文件被人误删了"。
这里**不断言文件存在** —— 二进制权重不进 git,新克隆的仓库里 `models/` 是空的。
只断言"解析结果指向规范模型名",这样在任何环境下都成立。
"""
from __future__ import annotations
from arm_application.hole_detector import _default_model_path, weights_from_site
CANONICAL = "gongjian0929.onnx"
# ---------------------------------------------------------------------------
# weights_from_site:site.yaml 缺键时的兜底
# ---------------------------------------------------------------------------
def test_weights_from_site_prefers_explicit_config():
data = {
"hole_detect": {"backend": "yolo", "weights": "/custom/my_holes.onnx"},
"paths": {"hole_weights": "/custom/ignored.onnx"},
}
assert weights_from_site(data) == "/custom/my_holes.onnx"
def test_weights_from_site_falls_back_to_paths():
data = {
"hole_detect": {"backend": "yolo"},
"paths": {"hole_weights": "/custom/from_paths.onnx"},
}
assert weights_from_site(data) == "/custom/from_paths.onnx"
def test_weights_from_site_yolo_fallback_is_not_dangling():
"""回归:以前这里兜底到不存在 best.pt,现在必须指向规范模型。"""
out = weights_from_site({"hole_detect": {"backend": "yolo"}})
assert out.endswith(CANONICAL)
assert "best.pt" not in out
def test_weights_from_site_two_stage_uses_workpiece_weights():
data = {
"hole_detect": {"backend": "two_stage", "workpiece_weights": "/w/wp.onnx"},
"paths": {"hole_weights": "/should/not/be/used.onnx"},
}
assert weights_from_site(data) == "/w/wp.onnx"
def test_weights_from_site_two_stage_falls_back_to_yolo_weights():
data = {
"hole_detect": {"backend": "two_stage"},
"paths": {"yolo_weights": "/w/track.onnx"},
}
assert weights_from_site(data) == "/w/track.onnx"
# ---------------------------------------------------------------------------
# _default_model_path:没有任何配置时的兜底
# ---------------------------------------------------------------------------
def test_default_model_path_is_not_the_dangling_best_pt():
"""回归:以前枚举的三个候选全不存在,函数却 `return candidates[0]`。
于是它永远返回一个不存在的 `hole_insert/best.pt`,而调用方拿到之后
只会在真正加载模型时才炸,报错路径还很有迷惑性。
"""
p = _default_model_path()
assert p.name not in ("best.pt", "hole_best.pt"), f"又指回悬空文件了: {p}"
def test_default_model_path_is_canonical_name():
assert _default_model_path().name == CANONICAL
def test_default_model_path_is_absolute():
"""必须是绝对路径:相对路径会随 CWD 漂移,节点从不同目录启动就找不到。"""
assert _default_model_path().is_absolute()
# ---------------------------------------------------------------------------
# detect_holes:没有工件框时,调用方可以要求"整幅画面当 ROI"
# ---------------------------------------------------------------------------
class _NoDetector:
"""一个什么都检测不到的"检测器"(模型漏检 / 与画面域不匹配时的替身)。"""
def detect(self, *_a, **_k):
return []
def test_detect_holes_needs_a_bbox_unless_asked_otherwise():
"""默认行为不变:没有工件框就直接报「未检测到工件」。
这条是给真机路径兜底的:默认不能被"整幅画面当 ROI"悄悄改掉 —— ROI 一放宽,
画面里别的圆(工装、夹爪上的特征)都有可能被当成孔。
"""
import cv2
import numpy as np
from arm_application.hole_detect_hough import detect_holes, load_config
blank = np.full((200, 200, 3), 120, np.uint8)
res = detect_holes(
blank, _NoDetector(), config=load_config(), skip_center_gate=True
)
assert res["status"] is False
assert res["msg"] == "未检测到工件"
def test_detect_holes_full_frame_roi_gets_past_the_bbox_gate():
"""``allow_full_frame_roi=True`` 时,没框也要继续往下走(走到找孔那一步)。
判据用"失败原因变了":从「未检测到工件」(卡在 ROI)变成「HoughCircles
未检测到圆孔」(已经真的找过孔了)。用空白图是有意的 —— 它保证结论只
来自"是否跳过了 ROI 要求",不掺杂检测器本身好不好用。
"""
import cv2
import numpy as np
from arm_application.hole_detect_hough import detect_holes, load_config
blank = np.full((200, 200, 3), 120, np.uint8)
res = detect_holes(
blank,
_NoDetector(),
config=load_config(),
skip_center_gate=True,
allow_full_frame_roi=True,
)
assert res["status"] is False
assert res["msg"] != "未检测到工件"
@@ -0,0 +1,215 @@
"""``image_utils`` 的单测:数组 -> ``sensor_msgs/Image`` 的打包是否正确。
这类代码的错误是**静默**的:编码写错、step 算错、通道顺序颠倒,都不会抛异常,
只是下游拿到的图裂开或颜色错位 —— 而现场很难判断是相机坏了还是打包错了。
所以这里把每种编码的字节布局都钉死。
"""
from __future__ import annotations
import numpy as np
import pytest
from arm_application.image_utils import (
UnsupportedImageError,
expected_step,
infer_encoding,
ndarray_to_image,
)
# ---------------------------------------------------------------------------
# 编码推断
# ---------------------------------------------------------------------------
class TestInferEncoding:
def test_mono8(self):
assert infer_encoding(np.zeros((4, 5), np.uint8)) == "mono8"
def test_mono16(self):
assert infer_encoding(np.zeros((4, 5), np.uint16)) == "mono16"
def test_float_depth_is_32fc1(self):
"""深度图常以米为单位的 float32 出现,必须是 32FC1 而不是 mono。"""
assert infer_encoding(np.zeros((4, 5), np.float32)) == "32FC1"
def test_vector_field_is_32fc2(self):
"""力矢量场(每像素 dx, dy)用 32FC2 表达,这是 ROS 里的标准做法。"""
assert infer_encoding(np.zeros((4, 5, 2), np.float32)) == "32FC2"
def test_rgb8(self):
assert infer_encoding(np.zeros((4, 5, 3), np.uint8)) == "rgb8"
def test_rgba8(self):
assert infer_encoding(np.zeros((4, 5, 4), np.uint8)) == "rgba8"
def test_single_channel_3d_is_mono(self):
"""(H,W,1) 等价于灰度,不要因为没有 1 通道的编码表项就报错。"""
assert infer_encoding(np.zeros((4, 5, 1), np.uint8)) == "mono8"
def test_1d_is_rejected(self):
with pytest.raises(UnsupportedImageError) as exc:
infer_encoding(np.zeros(10, np.uint8))
assert "2D" in str(exc.value)
def test_4d_is_rejected(self):
with pytest.raises(UnsupportedImageError):
infer_encoding(np.zeros((2, 3, 4, 5), np.uint8))
def test_uint16_rgb_is_rejected(self):
"""16 位 RGB 没有标准编码;宁可报错也不要按 8 位打包(会静默错位)。"""
with pytest.raises(UnsupportedImageError) as exc:
infer_encoding(np.zeros((4, 5, 3), np.uint16))
assert "形状" in str(exc.value)
def test_float64_is_rejected(self):
"""float64 图很常见(默认 dtype),必须显式转换,不能猜。"""
with pytest.raises(UnsupportedImageError):
infer_encoding(np.zeros((4, 5), np.float64))
def test_error_lists_supported_combinations(self):
with pytest.raises(UnsupportedImageError) as exc:
infer_encoding(np.zeros((3, 3, 5), np.uint8))
assert "mono8" in str(exc.value) and "rgb8" in str(exc.value)
# ---------------------------------------------------------------------------
# 打包
# ---------------------------------------------------------------------------
class TestPackaging:
def test_mono8_layout(self):
arr = np.arange(12, dtype=np.uint8).reshape(3, 4)
msg = ndarray_to_image(arr, frame_id="left_tactile")
assert (msg.height, msg.width) == (3, 4)
assert msg.encoding == "mono8"
assert msg.step == 4
assert msg.header.frame_id == "left_tactile"
assert msg.is_bigendian is False
assert bytes(msg.data) == arr.tobytes()
def test_mono16_step_and_bytes(self):
arr = np.arange(12, dtype=np.uint16).reshape(3, 4)
msg = ndarray_to_image(arr)
assert msg.encoding == "mono16"
assert msg.step == 4 * 2 # 每像素 2 字节
assert len(msg.data) == 3 * 4 * 2
assert bytes(msg.data) == arr.tobytes()
def test_rgb8_step_is_three_bytes_per_pixel(self):
arr = np.zeros((5, 7, 3), np.uint8)
msg = ndarray_to_image(arr)
assert msg.encoding == "rgb8"
assert msg.step == 7 * 3
assert msg.height == 5 and msg.width == 7
def test_rgba8_step(self):
msg = ndarray_to_image(np.zeros((5, 7, 4), np.uint8))
assert msg.step == 7 * 4
def test_vector_field_32fc2(self):
"""矢量场:每像素 2 个 float32,step 必须是 W*8。"""
arr = np.zeros((6, 8, 2), np.float32)
msg = ndarray_to_image(arr)
assert msg.encoding == "32FC2"
assert msg.step == 8 * 4 * 2
assert len(msg.data) == 6 * 8 * 2 * 4
def test_float_depth_32fc1(self):
msg = ndarray_to_image(np.zeros((6, 8), np.float32))
assert msg.encoding == "32FC1"
assert msg.step == 8 * 4
def test_3d_single_channel_flattens_fine(self):
"""(H,W,1) 压平后与 (H,W) 的字节完全一致。"""
a = np.arange(12, dtype=np.uint8).reshape(3, 4, 1)
b = np.arange(12, dtype=np.uint8).reshape(3, 4)
assert bytes(ndarray_to_image(a).data) == bytes(ndarray_to_image(b).data)
def test_non_contiguous_input_is_handled(self):
"""切片产生的非连续数组必须先转连续,否则 tobytes 的顺序是错的。"""
base = np.arange(24, dtype=np.uint8).reshape(4, 6)
view = base[:, ::2] # 非连续
assert not view.flags["C_CONTIGUOUS"]
msg = ndarray_to_image(view)
assert msg.width == 3
assert bytes(msg.data) == np.ascontiguousarray(view).tobytes()
def test_width_and_height_are_ints_not_numpy(self):
"""ROS 消息字段是 int32;塞 numpy 标量在某些版本会失败。"""
msg = ndarray_to_image(np.zeros((3, 4), np.uint8))
assert isinstance(msg.height, int) and isinstance(msg.width, int)
assert isinstance(msg.step, int)
def test_stamp_is_forwarded(self):
"""stamp 必须原样传下去 —— 下游靠它做时间对齐。"""
from builtin_interfaces.msg import Time
stamp = Time(sec=123, nanosec=456)
msg = ndarray_to_image(np.zeros((2, 2), np.uint8), stamp=stamp)
assert msg.header.stamp.sec == 123
assert msg.header.stamp.nanosec == 456
def test_stamp_defaults_to_zero_when_omitted(self):
msg = ndarray_to_image(np.zeros((2, 2), np.uint8))
assert msg.header.stamp.sec == 0 and msg.header.stamp.nanosec == 0
def test_fortran_order_is_normalised(self):
arr = np.asfortranarray(np.arange(12, dtype=np.uint8).reshape(3, 4))
msg = ndarray_to_image(arr)
assert bytes(msg.data) == np.ascontiguousarray(arr).tobytes()
def test_empty_image(self):
"""零尺寸图:别崩,正常给出 height=0。"""
msg = ndarray_to_image(np.zeros((0, 0), np.uint8))
assert msg.height == 0 and msg.width == 0
assert len(msg.data) == 0
def test_explicit_encoding_override_is_respected(self):
"""显式指定编码时要照用 —— 有些相机给出的 BGR 需要按 rgb8 打包。"""
arr = np.zeros((3, 4, 3), np.uint8)
msg = ndarray_to_image(arr, encoding="rgb8")
assert msg.encoding == "rgb8"
# ---------------------------------------------------------------------------
# step 计算与 data 长度的一致性
# ---------------------------------------------------------------------------
class TestStepConsistency:
"""``step`` 与 ``len(data)`` 必须自洽:``len(data) == step * height``。
这是下游按行读图时唯一依赖的不变量。不一致会导致整幅图错位。
"""
@pytest.mark.parametrize(
"shape,dtype",
[
((3, 4), np.uint8),
((3, 4), np.uint16),
((3, 4), np.float32),
((3, 4, 1), np.uint8),
((3, 4, 2), np.float32),
((3, 4, 3), np.uint8),
((3, 4, 4), np.uint8),
((1, 1), np.uint8),
((17, 23, 3), np.uint8),
],
)
def test_data_length_matches_step_times_height(self, shape, dtype):
arr = np.zeros(shape, dtype)
msg = ndarray_to_image(arr)
assert len(msg.data) == msg.step * msg.height, (
f"shape={shape} dtype={dtype.name} encoding={msg.encoding} "
f"step={msg.step} h={msg.height} len={len(msg.data)}"
)
def test_expected_step_helper_agrees(self):
for shape, dtype in [((3, 4), np.uint8), ((3, 4, 3), np.uint8),
((3, 4, 2), np.float32)]:
arr = np.zeros(shape, dtype)
msg = ndarray_to_image(arr)
assert msg.step == expected_step(arr)
@@ -0,0 +1,170 @@
"""换一台 Linux 上的机械臂,界面和命令必须认 site.yaml,不能只认本机 89/91。"""
from __future__ import annotations
from pathlib import Path
import pytest
from arm_application.site_io import arm_short_id, listed_arms, load_site_dict, usb_expectations
def test_short_id_is_the_last_two_digits():
assert arm_short_id("Rizon4-777001") == "01"
assert arm_short_id("Rizon4-063989") == "89"
assert arm_short_id("sim") == ""
assert arm_short_id("") == ""
def test_another_station_is_listed_and_this_stations_ids_are_not_invented():
site = {
"robot": {
"robots": [
{
"sn": "Rizon4-777001",
"arm": "left",
"label": "甲",
"gripper": {"model": "PGEA-9", "port": "/dev/ttyFAKE0"},
},
{
"sn": "Rizon4-777002",
"arm": "right",
"label": "乙",
"gripper": {"model": "RGI-9", "port": "/dev/ttyFAKE1"},
},
{"sn": "sim", "arm": "sim", "gripper": {"model": "RGI-9"}},
]
}
}
rows = listed_arms(site)
assert [row["id"] for row in rows] == ["01", "02"]
assert rows[0]["rotary"] is False
assert rows[0]["side"] == "left"
assert rows[1]["rotary"] is True
assert "89" not in {row["id"] for row in rows}
def test_right_arm_with_a_parallel_gripper_is_not_treated_as_rotary():
site = {
"robot": {
"robots": [
{"sn": "Rizon4-555091", "arm": "right", "gripper": {"model": "PGEA-100-20-W"}},
]
}
}
rows = listed_arms(site)
assert rows[0]["id"] == "91"
assert rows[0]["rotary"] is False
def test_duplicate_short_ids_fail_instead_of_dropping_an_arm():
site = {
"robot": {
"robots": [
{"sn": "Rizon4-100089", "arm": "left"},
{"sn": "Rizon4-200089", "arm": "right"},
]
}
}
with pytest.raises(ValueError, match="89"):
listed_arms(site)
def test_usb_count_follows_the_camera_list():
site = {
"cameras": {"d405_only": {"model": "d405"}},
"tactile": {"left_video": 0},
}
expect = usb_expectations(site)
assert expect == {"d405": 1, "d435": 0, "orisys": 1}
def test_this_station_still_lists_89_and_91():
rows = listed_arms(load_site_dict())
ids = [row["id"] for row in rows]
assert ids == ["89", "91"]
by_id = {row["id"]: row for row in rows}
assert by_id["89"]["rotary"] is False
assert by_id["91"]["rotary"] is True
@pytest.mark.skip(reason="只同步 ROS 节点;远程 UI、shell 和整站配置不在本次范围")
def test_main_page_holds_status_sim_cameras_tactile_and_actions():
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
from PySide6.QtWidgets import QApplication, QPushButton, QScrollArea
from arm_application.operator_app import StationApp
app = QApplication.instance() or QApplication([])
win = StationApp()
try:
assert [key for key, _button in win._nav] == ["main", "handeye", "teach"]
assert set(win._pages) == {"main", "handeye", "teach"}
assert win._stack.currentWidget() is win._pages["main"]
assert win.minimumWidth() <= 1600
assert win.minimumHeight() <= 900
main = win._pages["main"]
assert main.findChildren(QScrollArea) == []
win.resize(1440, 900)
app.processEvents()
assert main.minimumSizeHint().height() <= 860
texts = [button.text() for button in main.findChildren(QPushButton)]
for name in ("跟踪并对孔", "只测旋转装配", "复位到 home", "本臂腕部相机"):
assert any(name in text for text in texts)
power = [button for button in main.findChildren(QPushButton) if "上电控制栈" in button.text()]
assert power and not power[0].icon().isNull()
assert win._cam_pic
assert all(label.minimumHeight() <= 96 for label in win._cam_pic.values())
from arm_application.operator_camera_view import targets
cams = targets()
assert [cam["role"] for cam in cams] == ["wrist", "overhead"]
assert "right" not in cams[0]["id"] and not cams[0]["id"].startswith("sim")
assert not cams[1]["id"].startswith("sim")
assert set(win._cam_pic) == {
f"{cam['id']}_{kind}" for cam in cams for kind in ("color", "depth")
}
assert win._color_box.hasHeightForWidth()
assert 240 <= win._color_box.heightForWidth(960) <= 340
work = win._color_box.parentWidget().layout()
assert work.stretch(work.indexOf(win._color_box)) == 0
assert work.stretch(work.indexOf(win._tac_box)) >= 1
assert win._tac_box.minimumHeight() >= 160
assert win._tac_box.maximumHeight() > 400
split = main.layout()
assert split.stretch(0) < split.stretch(1)
assert set(win._pic) == {"left_shear", "left_thermal", "right_shear", "right_thermal"}
assert win._arm_sketch is not None
assert win._grip_sketch.parent() is main or main.isAncestorOf(win._grip_sketch)
assert 140 <= win._grip_sketch.height() or win._grip_sketch.maximumHeight() <= 180
assert win._grip_sketch.maximumHeight() <= 180
assert win._pos.value() == 500
assert any("按此夹持" in text for text in texts)
assert not win._rotary.isVisible()
back = [
button.text()
for button in win._pages["handeye"].findChildren(QPushButton)
]
assert any("返回主界面" in text for text in back)
assert "眼在手上" in back or any("眼在手上" in text for text in back)
finally:
win.close()
app.processEvents()
@pytest.mark.skip(reason="只同步 ROS 节点;远程 UI、shell 和整站配置不在本次范围")
def test_operator_code_does_not_keep_a_local_serial_table():
root = Path(__file__).resolve().parents[1] / "arm_application"
for name in (
"operator_console.py",
"operator_gripper_state.py",
"operator_arm_state.py",
"operator_teach.py",
"operator_app.py",
):
text = (root / name).read_text(encoding="utf-8")
assert 'endswith("063989")' not in text
assert 'not in ("89", "91")' not in text
@@ -0,0 +1,484 @@
"""model_labels 的测试。
刻意**不依赖仓库里那些模型二进制**(`models/**/*.onnx` 都在 .gitignore 里,
新克隆的仓库根本没有),全部用 tmp_path 自建 fixture。ONNX 用 onnx.helper
现场造一个最小的,这样"从 ONNX metadata 读标签"这条路径也能被测到。
"""
from __future__ import annotations
import numpy as np
import pytest
import yaml
from arm_application.model_labels import (
SOURCE_MODEL,
SOURCE_ONNX,
SOURCE_PLACEHOLDER,
SOURCE_SIDECAR,
LabelSet,
describe,
format_labels_yaml,
label_file_model,
label_file_status,
labels_path_for,
parse_label_list,
parse_names_field,
placeholder_labels,
read_label_file,
read_label_text_file,
read_labels,
write_labels,
)
# --------------------------------------------------------------------------
# fixture 工具
# --------------------------------------------------------------------------
def _make_onnx(path, names_json=None, nc=None, metadata=None):
"""造一个最小可读的 ONNX,用于测试 metadata 读取。"""
# onnx 只在真机环境(ros_flexiv)里装了,仿真环境(ros_gz_sim)没有。
# 缺它时**跳过**而不是失败:仿真环境里每次 pytest 都多十几条红字,
# 很快就没有人看测试结果了("狼来了"),而真正需要 onnx 的用例
# 本来也没法在没有 onnx 的环境里验证。
pytest.importorskip("onnx", reason="本环境没装 onnx(只有真机 conda 环境有)")
import onnx
from onnx import TensorProto, helper
inp = helper.make_tensor_value_info("images", TensorProto.FLOAT, [1, 3, 8, 8])
out = helper.make_tensor_value_info("output0", TensorProto.FLOAT, [1, 3, 8, 8])
node = helper.make_node("Identity", ["images"], ["output0"])
graph = helper.make_graph([node], "g", [inp], [out])
model = helper.make_model(graph)
model.opset_import[0].version = 13
if names_json is not None:
model.metadata_props.add(key="names", value=names_json)
if nc is not None:
model.metadata_props.add(key="nc", value=str(nc))
for k, v in (metadata or {}).items():
model.metadata_props.add(key=k, value=v)
onnx.save(model, str(path))
return path
def _touch(path):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"not a real model")
return path
# --------------------------------------------------------------------------
# 旁挂路径推导
# --------------------------------------------------------------------------
def test_labels_path_replaces_suffix_only():
assert labels_path_for("models/yolo/m.engine") == pytest.approx(
labels_path_for("models/yolo/m.engine")
)
from pathlib import Path
assert Path(labels_path_for("models/yolo/m.engine")) == Path(
"models/yolo/m.labels.yaml"
)
assert Path(labels_path_for("models/yolo/m.onnx")) == Path("models/yolo/m.labels.yaml")
assert Path(labels_path_for("a/b/m.pt")) == Path("a/b/m.labels.yaml")
def test_labels_path_same_for_all_formats_of_one_model():
"""关键契约:同一模型的三种格式必须算出同一个标签文件。
否则"换后端名字就变了"这个坑会再次出现。
"""
from pathlib import Path
a = Path(labels_path_for("d/m.pt"))
b = Path(labels_path_for("d/m.onnx"))
c = Path(labels_path_for("d/m.engine"))
assert a == b == c
def test_labels_path_handles_dotted_stem():
"""含点的文件名(a.b.onnx)不能被截成 a.labels.yaml。
这正是 Path.with_suffix('.labels.yaml') 容易踩的坑,所以手工拼 stem。
"""
from pathlib import Path
assert Path(labels_path_for("a.b.onnx")) == Path("a.b.labels.yaml")
def test_labels_path_without_extension():
from pathlib import Path
assert Path(labels_path_for("models/bare")) == Path("models/bare.labels.yaml")
def test_labels_path_expands_user():
assert "~" not in str(labels_path_for("~/ros2/models/x.onnx"))
# --------------------------------------------------------------------------
# names 字段解析:三种写法都要吃
# --------------------------------------------------------------------------
def test_parse_names_dict_int_keys():
assert parse_names_field({0: "workpiece", 1: "hole"}) == {0: "workpiece", 1: "hole"}
def test_parse_names_dict_string_keys():
"""YAML 里键常常是字符串形式的数字('0'),必须吃。"""
assert parse_names_field({"0": "workpiece", "1": "hole"}) == {
0: "workpiece",
1: "hole",
}
def test_parse_names_list_index_is_position():
assert parse_names_field(["workpiece", "hole"]) == {0: "workpiece", 1: "hole"}
def test_parse_names_list_skips_blank_but_keeps_index():
"""空项被跳过,但后面的项索引不能因此前移 —— 否则类别就整体错位了。"""
assert parse_names_field(["a", "", "c"]) == {0: "a", 2: "c"}
assert parse_names_field(["a", None, "c"]) == {0: "a", 2: "c"}
assert parse_names_field(["a", " ", "c"]) == {0: "a", 2: "c"}
def test_parse_names_string():
assert parse_names_field("workpiece, hole") == {0: "workpiece", 1: "hole"}
def test_parse_names_none_and_garbage():
assert parse_names_field(None) == {}
assert parse_names_field(42) == {}
assert parse_names_field({}) == {}
assert parse_names_field([]) == {}
def test_parse_names_ignores_unparsable_dict_keys():
"""键不是整数时跳过这一项,而不是抛异常让推理进程崩掉。"""
assert parse_names_field({"x": "bad", 0: "good"}) == {0: "good"}
def test_parse_names_strips_whitespace():
assert parse_names_field([" workpiece "]) == {0: "workpiece"}
# --------------------------------------------------------------------------
# 逗号串解析(CLI --set 用)
# --------------------------------------------------------------------------
def test_parse_label_list_ascii_comma():
assert parse_label_list("a,b,c") == {0: "a", 1: "b", 2: "c"}
def test_parse_label_list_chinese_comma_and_semicolon():
assert parse_label_list("工件,通止规") == {0: "工件", 1: "通止规"}
assert parse_label_list("a;b") == {0: "a", 1: "b"}
def test_parse_label_list_with_explicit_index():
"""从别处拷配置常带索引,要能认。"""
assert parse_label_list("0:workpiece, 5:hole") == {0: "workpiece", 5: "hole"}
assert parse_label_list("0:workpiece") == {0: "workpiece"} # 全角冒号
def test_parse_label_list_tolerates_sloppy_input():
assert parse_label_list("") == {}
assert parse_label_list(" ") == {}
assert parse_label_list("a,,b") == {0: "a", 1: "b"}
assert parse_label_list(" a , b ") == {0: "a", 1: "b"}
assert parse_label_list("a\nb") == {0: "a", 1: "b"}
def test_parse_label_list_mixed_forms_stay_ordered():
out = parse_label_list("a, 1:b, c")
assert out[0] == "a"
assert out[1] == "b"
assert out[2] == "c"
# --------------------------------------------------------------------------
# 纯文本标签文件(classes.txt 风格)
# --------------------------------------------------------------------------
def test_read_label_text_file_comments_do_not_consume_index(tmp_path):
p = tmp_path / "classes.txt"
p.write_text("# 这是注释\nworkpiece\n\n# 又一条注释\nhole\n", encoding="utf-8")
assert read_label_text_file(p) == {0: "workpiece", 1: "hole"}
def test_read_label_text_file_chinese(tmp_path):
p = tmp_path / "classes.txt"
p.write_text("工件\n通止规\n", encoding="utf-8")
assert read_label_text_file(p) == {0: "工件", 1: "通止规"}
# --------------------------------------------------------------------------
# 占位符
# --------------------------------------------------------------------------
def test_placeholder_is_labelled_not_bare_digit():
"""占位名必须一眼看出是占位,不能退化成裸数字 '0'。
原先的代码是 _id_to_name.get(cid, str(cid)),也就是类名变成 "0",
和"真的有个叫 0 的类"无法区分 —— 标签丢了都看不出来。
"""
assert placeholder_labels(3) == {0: "class_0", 1: "class_1", 2: "class_2"}
assert placeholder_labels(1)[0] == "class_0"
assert placeholder_labels(0) == {}
assert placeholder_labels(-5) == {}
# --------------------------------------------------------------------------
# 旁挂文件:写 → 读 往返
# --------------------------------------------------------------------------
def test_format_labels_yaml_is_parseable_and_ordered():
txt = format_labels_yaml({0: "workpiece", 1: "hole"}, model="m")
data = yaml.safe_load(txt)
assert data["names"] == ["workpiece", "hole"]
assert data["model"] == "m"
def test_format_labels_yaml_includes_usage_hint():
"""注释头是给直接打开文件的人看的,必须存在。"""
txt = format_labels_yaml({0: "a"})
assert "第 1 行对应类别 0" in txt
assert "scripts/weights.sh labels" in txt
def test_format_labels_yaml_empty():
data = yaml.safe_load(format_labels_yaml({}))
assert data["names"] == []
def test_write_then_read_round_trip(tmp_path):
w = _touch(tmp_path / "detect_best_0.engine")
written = write_labels(w, {0: "workpiece", 1: "tongzhigui"})
assert written.name == "detect_best_0.labels.yaml"
assert read_label_file(written) == {0: "workpiece", 1: "tongzhigui"}
def test_write_labels_records_model_name(tmp_path):
w = _touch(tmp_path / "abc.onnx")
p = write_labels(w, {0: "x"})
assert label_file_model(p) == "abc"
def test_read_label_file_tolerates_dict_form(tmp_path):
"""用户可能按 Ultralytics data.yaml 的字典写法手写。"""
p = tmp_path / "m.labels.yaml"
p.write_text("names:\n 0: a\n 1: b\n", encoding="utf-8")
assert read_label_file(p) == {0: "a", 1: "b"}
def test_read_label_file_tolerates_comma_string(tmp_path):
p = tmp_path / "m.labels.yaml"
p.write_text('names: "a, b"\n', encoding="utf-8")
assert read_label_file(p) == {0: "a", 1: "b"}
def test_read_label_file_tolerates_bare_text_file(tmp_path):
"""有人直接把手写的 classes.txt 改个名字就用了,也该能读。"""
p = tmp_path / "m.labels.yaml"
p.write_text("a\nb\n", encoding="utf-8")
assert read_label_file(p) == {0: "a", 1: "b"}
def test_read_label_file_missing_returns_none(tmp_path):
assert read_label_file(tmp_path / "nope.labels.yaml") is None
def test_read_label_file_malformed_yaml_returns_none(tmp_path):
"""文件坏了不该让推理进程崩 —— 退回"没有标签"即可。"""
p = tmp_path / "m.labels.yaml"
p.write_text("names: [a, b\n bad: : :\n", encoding="utf-8")
assert read_label_file(p) is None
def test_read_label_file_empty_names_returns_none(tmp_path):
p = tmp_path / "m.labels.yaml"
p.write_text("names: []\n", encoding="utf-8")
assert read_label_file(p) is None
# --------------------------------------------------------------------------
# 从 ONNX metadata 读
# --------------------------------------------------------------------------
def test_labels_from_onnx_via_read_labels(tmp_path):
w = _make_onnx(tmp_path / "m.onnx", names_json="{0: 'workpiece'}", nc=1)
ls = read_labels(w)
assert ls.names == {0: "workpiece"}
assert ls.source == SOURCE_ONNX
assert not ls.is_placeholder
def test_onnx_multiclass_names(tmp_path):
w = _make_onnx(tmp_path / "m.onnx", names_json="{0: 'a', 1: 'b', 2: 'c'}")
assert read_labels(w).names == {0: "a", 1: "b", 2: "c"}
def test_onnx_without_names_gives_placeholder(tmp_path):
w = _make_onnx(tmp_path / "m.onnx")
ls = read_labels(w)
assert ls.is_placeholder
assert ls.path is None
# --------------------------------------------------------------------------
# 优先级:sidecar 必须赢
# --------------------------------------------------------------------------
def test_sidecar_beats_onnx_metadata(tmp_path):
"""这是本模块最要紧的行为。
ONNX 里内嵌的是导出时的旧名字。用户改了旁挂文件却看到旧名字,
会变成极难排查的问题("我明明改了为什么不生效")。
"""
w = _make_onnx(tmp_path / "m.onnx", names_json="{0: 'OLD_NAME'}")
assert read_labels(w).names == {0: "OLD_NAME"}
write_labels(w, {0: "NEW_NAME"})
ls = read_labels(w)
assert ls.names == {0: "NEW_NAME"}
def test_sidecar_beats_placeholder_for_engine(tmp_path):
"""engine 读不到任何 metadata,旁挂文件是它唯一的标签来源。"""
w = _touch(tmp_path / "m.engine")
assert read_labels(w).is_placeholder
write_labels(w, {0: "workpiece"})
ls = read_labels(w)
assert ls.names == {0: "workpiece"}
assert ls.source == SOURCE_SIDECAR
def test_sidecar_shared_across_formats_in_same_dir(tmp_path):
""".onnx 和 .engine 同名同目录 → 共用一份标签。"""
onnx = _make_onnx(tmp_path / "m.onnx", names_json="{0: 'x'}")
engine = _touch(tmp_path / "m.engine")
write_labels(engine, {0: "shared", 1: "two"})
assert read_labels(onnx).names == {0: "shared", 1: "two"}
assert read_labels(engine).names == {0: "shared", 1: "two"}
assert read_labels(onnx).source == SOURCE_SIDECAR
def test_engine_without_sidecar_does_not_guess(tmp_path):
"""engine 缺标签时必须显式报占位符,不能假装知道。"""
w = _touch(tmp_path / "m.engine")
ls = read_labels(w)
assert ls.is_placeholder
assert ls.source == SOURCE_PLACEHOLDER
def test_read_labels_tolerates_missing_weights_file(tmp_path):
"""权重不存在也不该崩(由调用方负责检查存在性)。"""
ls = read_labels(tmp_path / "ghost.engine")
assert isinstance(ls, LabelSet)
assert ls.is_placeholder
def test_existing_sidecar_wins_even_if_weights_absent(tmp_path):
"""标签文件独立于权重存在,有权重没权重都能读出来。"""
write_labels(tmp_path / "m.engine", {0: "only_labels"})
ls = read_labels(tmp_path / "m.engine")
assert ls.names == {0: "only_labels"}
assert ls.source == SOURCE_SIDECAR
# --------------------------------------------------------------------------
# LabelSet 行为
# --------------------------------------------------------------------------
def test_labelset_get_falls_back_to_placeholder_name():
ls = LabelSet(names={0: "a"}, source=SOURCE_SIDECAR)
assert ls.get(0) == "a"
assert ls.get(7) == "class_7"
assert ls.get(7, "unknown") == "unknown"
def test_labelset_sorted_names():
ls = LabelSet(names={2: "c", 0: "a", 1: "b"}, source=SOURCE_SIDECAR)
assert ls.sorted_names() == ["a", "b", "c"]
def test_describe_mentions_source_in_chinese():
"""来源必须出现在日志里 —— 这是"标签丢了"能被发现的关键。"""
ls = LabelSet(names={0: "workpiece"}, source=SOURCE_SIDECAR)
assert "workpiece" in describe(ls)
assert "旁挂标签文件" in describe(ls)
ls = LabelSet(names={0: "class_0"}, source=SOURCE_PLACEHOLDER)
assert "占位符" in describe(ls)
def test_describe_handles_empty():
assert "(空)" in describe(LabelSet(names={}, source=SOURCE_PLACEHOLDER))
# --------------------------------------------------------------------------
# 错配检测(doctor 用)
# --------------------------------------------------------------------------
def test_label_file_status_reports_missing(tmp_path):
exists, path, hint = label_file_status(tmp_path / "m.engine")
assert exists is False
assert hint is not None and "缺标签文件" in hint
def test_label_file_status_ok(tmp_path):
w = _touch(tmp_path / "m.engine")
write_labels(w, {0: "a"})
exists, _path, hint = label_file_status(w)
assert exists is True
assert hint is None
def test_label_file_status_detects_mismatched_copy(tmp_path):
"""把 A 的标签文件拷给 B —— 这种错配必须被指出来。"""
write_labels(tmp_path / "A.onnx", {0: "a"})
(tmp_path / "A.labels.yaml").rename(tmp_path / "B.labels.yaml")
b = _touch(tmp_path / "B.onnx")
exists, _path, hint = label_file_status(b)
assert exists is True
assert hint is not None
assert "A" in hint and "B" in hint
# --------------------------------------------------------------------------
# 中文标签(实际会用到的场景)
# --------------------------------------------------------------------------
def test_chinese_labels_round_trip(tmp_path):
w = _touch(tmp_path / "m.onnx")
write_labels(w, {0: "工件", 1: "通止规"})
ls = read_labels(w)
assert ls.names == {0: "工件", 1: "通止规"}
# 写出的文件必须是 UTF-8,否则中文会乱码
txt = labels_path_for(w).read_text(encoding="utf-8")
assert "通止规" in txt
def test_numpy_int_keys_are_accepted(tmp_path):
"""调用方从 numpy 结果里拿到的索引可能是 np.int64,不能因此失败。"""
w = _touch(tmp_path / "m.onnx")
write_labels(w, {np.int64(0): "a", np.int64(1): "b"})
assert read_labels(w).names == {0: "a", 1: "b"}
@@ -0,0 +1,190 @@
"""回归:节点类之间的**回调方法重名**必须被拦住。
为什么值得一条专门的测试(2026-09-24 踩到的真实故障,代价是两轮排查方向全错):
Stage2Locate.__init__ 里建了
create_subscription(WrenchStamped, ft_wrench_topic, self._on_ft, ...)
Stage3Search(子类)里定义了
def _on_ft(self, msg): self._ft = (time.monotonic(), msg)
Python 的方法解析顺序让**子类覆盖父类**,而两个订阅都已经在 __init__ 里
建好了。于是那条 WrenchStamped 订阅的回调实际是子类那个 —— 它把消息塞进
stage3 的 RobotStates 缓存,而父类的 `_ft_fz` 永远不被赋值。
现象:"S07 腕部 FT 没有数据(话题 /sim/ft_sensor_wrench)",
而在 shell 里 `ros2 topic echo` 那条话题**数据完全正常**、
QoS 也完全兼容(实测 BEST_EFFORT 与 RELIABLE 订阅者都收得到)。
于是排查方向被引到"话题名写错 / QoS 不匹配 / 桥接没起来"上,全部走偏。
真机上更阴:两个话题都存在,`self._ft` 里 RobotStates 与 WrenchStamped
交替覆盖 → `_ft_mag()` 时有时无 → 腕部力看门狗时灵时不灵(偶发,最难查)。
规则:**整条继承链上,`_on_*` 形式的方法名必须唯一 —— 除非子类显式
``super().<同名>(...)`` 委托回父类**。后者是"扩展"而不是"遮蔽",是允许的
(``GaugeAssembly._on_color`` 就属于这种:先 ``super()._on_color(msg)`` 存帧,
再补记"收到时刻")。判定标准就是有没有那一次委托调用 ——
没有委托的同名覆盖,一定会让父类的那份状态**永远不更新**,而且不报任何错。
命名空间不够用就改名字(`_on_wrench` / `_on_robot_states` 比 `_on_ft` 好,
后者里那个 "ft" 到底指腕部六维力还是手指触觉,本来就含糊)——
不要靠"反正签名不同"来赌,Python 不检查签名。
为什么用 AST 读源码而不是 import 后反射:
这几个节点模块 import 了 ROS 生成的消息包(``arm_msgs`` 等),
在没有 source 过工作空间的 pytest 环境里 import 不进来。
而这条规则是**纯源码层面**的,不需要真的构造节点。
这也是本仓库既有的做法(见 ``test_gauge_step_events.py``)。
"""
from __future__ import annotations
import ast
import pathlib
from typing import Dict, List
_APP = pathlib.Path(__file__).resolve().parents[1] / "arm_application"
#: 继承链上的源文件。
_CHAIN_FILES = (
"stage2_locate_node.py",
"stage3_search_node.py",
"pipeline_node.py",
"gauge_assembly_node.py",
)
#: 从哪个类开始往上走(装配流程真正实例化的那个)。
_LEAF = "GaugeAssembly"
class _Cls:
"""一个类的:基类名、本类定义的 `_on_*` 方法(名字 → AST 节点)。"""
def __init__(self, name: str, bases: List[str], handlers: Dict[str, ast.AST]) -> None:
self.name = name
self.bases = bases
self.handlers = handlers
def _classes_in(path: pathlib.Path) -> Dict[str, _Cls]:
tree = ast.parse(path.read_text(encoding="utf-8"))
out: Dict[str, _Cls] = {}
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
bases = [b.id for b in node.bases if isinstance(b, ast.Name)]
handlers = {
item.name: item
for item in node.body
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
and item.name.startswith("_on_")
}
out[node.name] = _Cls(node.name, bases, handlers)
return out
def _table() -> Dict[str, _Cls]:
table: Dict[str, _Cls] = {}
for name in _CHAIN_FILES:
table.update(_classes_in(_APP / name))
return table
def _chain() -> List[str]:
"""从 ``_LEAF`` 沿基类往上,返回类名列表(子类在前)。"""
table = _table()
order: List[str] = []
cur = _LEAF
while cur and cur in table:
order.append(cur)
bases = table[cur].bases
cur = bases[0] if bases else ""
assert len(order) >= 3, f"继承链没解析出来(只找到 {order})—— 改过类名?"
return order
def _delegates_to_super(func: ast.AST, name: str) -> bool:
"""``func`` 的方法体里有没有 ``super().<name>(...)`` 这一次委托调用。"""
for node in ast.walk(func):
if not isinstance(node, ast.Call):
continue
callee = node.func
if not (isinstance(callee, ast.Attribute) and callee.attr == name):
continue
owner = callee.value
if (
isinstance(owner, ast.Call)
and isinstance(owner.func, ast.Name)
and owner.func.id == "super"
):
return True
return False
def test_leaf_class_is_reachable_from_every_chain_file():
"""先确认这条测试自己在看对文件 —— 文件改名/移动时别静默变成空跑。"""
chain = _chain()
assert _LEAF in chain
for expected in ("Stage2Locate", "Stage3Search", "AssemblyPipeline"):
assert expected in chain, f"继承链里没有 {expected}(链 = {chain})"
def test_no_silent_callback_shadowing_across_inheritance_chain():
"""同名 `_on_*` 只允许两种情形:唯一,或子类显式 ``super()`` 委托。
挂了怎么修(二选一):
a) 在子类那个方法体里加 ``super().<同名>(msg)`` —— 表示"我是扩展";
b) 改掉其中一个的名字(带来源,如 ``_on_wrench`` / ``_on_robot_states``)。
别什么都不做:父类那份状态会永远不更新,而**不会有任何报错**。
"""
table = _table()
owner: Dict[str, str] = {}
problems: List[str] = []
# _chain() 是"子类在前",所以后出现的才是父类。
for cls_name in _chain():
cls = table.get(cls_name)
if cls is None:
continue
for handler in sorted(cls.handlers):
owner.setdefault(handler, cls_name)
# 再逐个查:凡是有多个类定义了的,子类必须委托。
for handler, owner_cls in owner.items():
definers = [c for c in _chain() if c in table and handler in table[c].handlers]
if len(definers) < 2:
continue
for cls_name in definers:
if cls_name == definers[-1]:
continue # 链最靠上的那个(基类)不需要委托
cls = table[cls_name]
if _delegates_to_super(cls.handlers[handler], handler):
continue
problems.append(
f"{handler}: 定义在 {', '.join(definers)} —— {cls_name} 覆盖了"
f"{owner_cls} 的同名回调,但方法体里没有 super().{handler}(...) 委托。\n"
f" 实际生效的只有 {cls_name} 那个,父类那份状态永远不会更新。"
)
assert not problems, (
"回调被静默遮蔽。症状是「某路数据永远收不到」而话题本身完全正常,"
"极难从现象倒推到这里:\n " + "\n ".join(problems)
)
def test_wrench_and_robot_states_callbacks_are_not_the_same_method():
"""专门钉住这一对名字 —— 它们是最容易被合并成 `_on_ft` 的两个。
腕部 FT(WrenchStamped,100Hz,法兰系)与整机状态(RobotStates,含外部
力/力矩)是两个**不同的**话题、不同的消息类型,缓存也必须是两个字段:
``_ft_fz``(轴向力标量)与 ``_ft``(整帧 RobotStates)。
"""
table = _table()
s2 = set(table["Stage2Locate"].handlers)
s3 = set(table["Stage3Search"].handlers)
assert "_on_wrench" in s2, (
"Stage2Locate 里腕部 FT 的回调应叫 _on_wrench(不能再叫 _on_ft:"
"会被 Stage3Search 的同名方法遮蔽)"
)
assert "_on_robot_states" in s3, "Stage3Search 里整机状态的回调应叫 _on_robot_states"
assert not (s2 & s3), f"两个类还有重名的回调: {sorted(s2 & s3)}"
@@ -0,0 +1,50 @@
"""``OnnxWorkpieceDetector`` 必须**当场拒绝**非检测模型,而不是解出垃圾框。
为什么值得一条测试(2026-10-08 实测)
-------------------------------------
把一个 ``task=pose`` 的模型(输出 ``[1, 4+nc+3K, 8400]``)喂给这个"只认
``[1, N, 6]``"的解码器,会得到 12 个**看起来像检测结果**的东西:
conf=70.870 cls=class_73 xyxy=[28.7, 0.0, 75.8, 0.0]
conf=41.092 cls=class_47 xyxy=[11.0, 0.0, 35.4, 0.0]
...
conf=0.324 cls=workpiece xyxy=[ 0.2, 0.0, 0.3, 0.0]
坐标被当成置信度(>1)、类号不存在、框是退化的(``y1 == y2``)—— 但没有异常、
没有告警,流水线照跑。这类"静默失效"正是本仓库花最多代价在防的东西,所以在
``__init__`` 里硬失败。
"""
from __future__ import annotations
from pathlib import Path
import pytest
pytest.importorskip("onnxruntime", reason="没装 onnxruntime,属于环境缺依赖")
from arm_application.onnx_workpiece import OnnxWorkpieceDetector # noqa: E402
ROOT = Path(__file__).resolve().parents[3]
YOLO_DIR = ROOT / "models" / "yolo"
POSE_ONNX = YOLO_DIR / "gongjian0929.onnx" # task=pose, 输出 [1,14,8400]
DETECT_ONNX = YOLO_DIR / "detect_best_0.onnx" # task=detect, end2end, 输出 [1,N,6]
def test_pose_model_is_refused_loudly():
"""带关键点的 pose 模型进了检测解码器 ⇒ 必须报错,且报错要指出正确做法。"""
if not POSE_ONNX.is_file():
pytest.skip(f"没有 {POSE_ONNX.name}(模型不在本机)")
with pytest.raises(RuntimeError) as ei:
OnnxWorkpieceDetector(POSE_ONNX, conf=0.25, imgsz=640)
msg = str(ei.value)
assert "不是 6" in msg, f"报错要说出真实输出维度,实际:{msg}"
# 必须告诉人该往哪走,否则只会被当成"模型坏了"
assert "pose" in msg.lower()
def test_end2end_detect_model_still_loads():
"""守卫不能过严:真正的 end2end 检测模型必须照常加载。"""
if not DETECT_ONNX.is_file():
pytest.skip(f"没有 {DETECT_ONNX.name}(模型不在本机)")
OnnxWorkpieceDetector(DETECT_ONNX, conf=0.25, imgsz=640)
@@ -0,0 +1,146 @@
"""``read_vector_map`` 的单测。
这个函数存在的唯一原因是:Orisys 的 Python 绑定是编译过的,公开接口里没有
一个明确叫 "force vector map" 的方法,只能按语义去试 ``compute_flow`` /
``compute_hhd`` / ``compute_deformation``,而且要判断返回值到底是数组
还是 bool/可视化图。
但它接的是真传感器,没法在单测里调。所以这里用假传感器把每条分支钉死:
**试错的顺序**和**接受的返回类型**一旦写错,表现是"矢量场话题永远没消息"
或"发了一张全零假图",两者都很难从现场现象反推回代码。
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pytest
from arm_application.orisys_tactile import read_vector_map
class FakeSensor:
"""可控假传感器。
传入 ``方法名=返回值``;想让某个方法抛异常就传异常实例。
没有列出的方法属性一律不存在 —— 这正好模拟"老版本 SDK 没这个方法"。
"""
def __init__(self, **methods: Any) -> None:
for name, value in methods.items():
if isinstance(value, BaseException):
def _raise(_exc=value):
raise _exc
setattr(self, name, _raise)
else:
setattr(self, name, (lambda _v=value: _v))
class TestPreferredSource:
def test_uses_compute_flow_first(self):
"""compute_flow 语义上最接近"切向力矢量场",优先级最高。"""
flow = np.ones((8, 6, 2), np.float32)
sensor = FakeSensor(
compute_flow=flow,
compute_hhd=np.zeros((8, 6, 3), np.uint8),
)
assert read_vector_map(sensor) is flow
def test_falls_back_to_hhd_when_flow_is_bool(self):
"""有些版本 compute_flow 只返回 bool("有没有流动")—— 那不是矢量场,
必须继续往下试,而不是把 True 当成图。"""
hhd = np.ones((4, 4), np.float32)
sensor = FakeSensor(compute_flow=True, compute_hhd=hhd)
assert read_vector_map(sensor) is hhd
def test_falls_back_to_deformation(self):
deform = np.ones((5, 5, 2), np.float32)
sensor = FakeSensor(compute_flow=False, compute_hhd=None, compute_deformation=deform)
assert read_vector_map(sensor) is deform
def test_skips_flow_that_raises(self):
hhd = np.ones((3, 3), np.float32)
sensor = FakeSensor(compute_flow=RuntimeError("no cuda"), compute_hhd=hhd)
assert read_vector_map(sensor) is hhd
class TestRejectedValues:
"""不是"二维以上数组"的返回值一律不能当矢量场用。"""
@pytest.mark.parametrize(
"bad",
[True, False, None, 0, 0.0, "flow", (1, 2), [1.0, 2.0], {"a": 1}],
)
def test_non_array_values_are_rejected(self, bad):
assert read_vector_map(FakeSensor(compute_flow=bad)) is None
def test_1d_array_is_rejected(self):
"""1D 没法按 (H,W) 解释成图。"""
assert read_vector_map(FakeSensor(compute_flow=np.zeros(10, np.float32))) is None
def test_empty_array_is_rejected(self):
"""空数组同样是"没有数据",不能发一张 0x0 的图。"""
assert read_vector_map(FakeSensor(compute_flow=np.zeros((0, 0), np.uint8))) is None
def test_2d_array_is_accepted(self):
"""2D 灰度图是合法结果(某些版本把形变强度图当成矢量场返回)。"""
arr = np.zeros((4, 5), np.uint8)
assert read_vector_map(FakeSensor(compute_flow=arr)) is arr
def test_scalar_ndarray_is_rejected(self):
"""0 维 ndarray 没有 (H,W) 语义。"""
assert read_vector_map(FakeSensor(compute_flow=np.array(3.0))) is None
class TestMissingMethods:
def test_all_missing_returns_none(self):
"""老版本 SDK 三个方法都没有 —— 应当安静返回 None,让上层跳过话题。"""
assert read_vector_map(FakeSensor()) is None
def test_all_raising_returns_none(self):
sensor = FakeSensor(
compute_flow=RuntimeError("x"),
compute_hhd=RuntimeError("y"),
compute_deformation=RuntimeError("z"),
)
assert read_vector_map(sensor) is None
def test_never_raises_even_when_everything_fails(self):
"""这是它的契约:探测失败只能返回 None,不能把异常抛给 30fps 的读取循环。"""
sensor = FakeSensor(compute_flow=KeyboardInterrupt())
# KeyboardInterrupt 是 BaseException;探测逻辑捕获 Exception,
# 所以它会被放行 —— 记录当前行为,提醒别在这里拿 BaseException 当普通错误。
with pytest.raises(KeyboardInterrupt):
read_vector_map(sensor)
class TestResultIsUsableByImageUtils:
"""探测到的数组必须能被 image_utils 打包 —— 否则等于白探测。"""
def test_flow_shaped_array_packs_as_32fc2(self):
from arm_application.image_utils import ndarray_to_image
flow = np.zeros((8, 6, 2), np.float32)
sensor = FakeSensor(compute_flow=flow)
arr = read_vector_map(sensor)
msg = ndarray_to_image(arr)
assert msg.encoding == "32FC2"
assert (msg.height, msg.width) == (8, 6)
def test_grayscale_array_packs_as_mono8(self):
from arm_application.image_utils import ndarray_to_image
arr = read_vector_map(FakeSensor(compute_flow=np.zeros((8, 6), np.uint8)))
assert ndarray_to_image(arr).encoding == "mono8"
def test_float64_result_is_rejected_by_packer_not_silently_truncated(self):
"""float64 是 numpy 的默认 dtype,SDK 很可能返回它。image_utils 会
明确报错而不是按 float32 打包 —— 那样会让每个像素错位 4 字节。"""
from arm_application.image_utils import UnsupportedImageError, ndarray_to_image
arr = read_vector_map(FakeSensor(compute_flow=np.zeros((8, 6, 2), np.float64)))
assert arr is not None
with pytest.raises(UnsupportedImageError):
ndarray_to_image(arr)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,247 @@
"""一阶段跟踪切到顶置 D435:相机、手眼文件、TF 父子、点变换都必须对。
把眼在手外的 T(cam→基座)再乘法兰,定位总能到附近、永远差一截。
所以这里每条都配负向:走错模式 / 借腕部量程 / 非法角色必须红。
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS_LIB = ROOT / "scripts" / "lib"
sys.path.insert(0, str(SCRIPTS_LIB))
import export_site_env # type: ignore # noqa: E402
from arm_application.handeye_lib import cam_point_to_base, cam_to_base_T # noqa: E402
from arm_application.site_io import ( # noqa: E402
handeye_static_tf_frames,
track_camera_role,
track_handeye_mode,
)
from test_handeye_mode import _site # noqa: E402
@pytest.fixture(autouse=True)
def _isolate_arm(monkeypatch):
"""别让本机 env 里的 ROBOT_SN=89 把虚构站点解析到真机相机。"""
monkeypatch.setenv("SITE_CONFIG", "/nonexistent/site.yaml")
monkeypatch.setenv("ROBOT_SN", "Rizon4-777001")
def _track_site(**over) -> dict:
site = _site()
site["camera"] = {"name": "d405_left"}
site["paths"] = {"handeye_file": "/tmp/wrist_eye_in_hand.yaml"}
site["yolo"] = {"max_depth_m": 0.55, "min_depth_m": 0.04, "feature_min_range_m": 0.14}
site["servo"] = {
"xy_track": "hover",
"lock_still_sec": 3.0,
"d435_handback_sec": 3.0,
"wrist_align_sec": 0.5,
"lock_still_move_m": 0.015,
}
site["perception"] = {
"track_camera": "wrist",
"wrist_handoff_conf": 0.5,
"overhead": {
"min_depth_m": 0.25,
"max_depth_m": 1.80,
"feature_min_range_m": 0.30,
"oneshot_conf": 0.5,
"cooldown_s": 1.5,
"frame_max_age_s": 0.4,
"oneshot_hold_s": 8.0,
},
}
for k, v in over.items():
if isinstance(v, dict) and isinstance(site.get(k), dict):
merged = dict(site[k])
merged.update(v)
site[k] = merged
else:
site[k] = v
return site
def _overhead_ok() -> dict:
return _track_site(
perception={
"track_camera": "overhead",
"overhead": {
"min_depth_m": 0.25,
"max_depth_m": 1.80,
"feature_min_range_m": 0.30,
},
}
)
class TestTrackCameraRole:
def test_missing_defaults_to_wrist(self):
assert track_camera_role({}) == "wrist"
assert track_camera_role({"perception": {}}) == "wrist"
def test_illegal_value_raises_not_fallback(self):
with pytest.raises(ValueError, match="wrist 或 overhead"):
track_camera_role({"perception": {"track_camera": "d405"}})
def test_overhead_maps_to_eye_to_hand(self):
assert track_handeye_mode("overhead") == "eye_to_hand"
assert track_handeye_mode("wrist") == "eye_in_hand"
class TestPerceptionServoWiring:
def test_overhead_uses_d435_not_wrist_camera(self):
p = export_site_env.perception_overrides(_overhead_ok())
assert p["camera_source"] == "d435"
assert p["camera_frame"] == "d435_color_optical_frame"
assert p["depth_topic"] == "/d435/aligned_depth_to_color/image_raw"
assert "d405" not in p["camera_source"]
assert "d405" not in p["depth_topic"]
def test_overhead_does_not_borrow_wrist_depth_range(self):
p = export_site_env.perception_overrides(_overhead_ok())
assert p["max_depth_m"] == pytest.approx(1.80)
assert p["min_depth_m"] == pytest.approx(0.25)
assert p["feature_min_range_m"] == pytest.approx(0.30)
# 负向:腕部 0.55 绝不能漏过来
assert p["max_depth_m"] != pytest.approx(0.55)
def test_overhead_handeye_file_is_e2h_not_wrist(self):
s = export_site_env.servo_overrides(_overhead_ok())
assert s["handeye_mode"] == "eye_to_hand"
assert s["handeye_file"].endswith("01_d435_eye_to_hand.yaml")
assert "wrist_eye_in_hand" not in s["handeye_file"]
parent, child = handeye_static_tf_frames(
mode=s["handeye_mode"],
ee_frame="flange",
base_frame="base",
cam_frame=s["camera_frame"],
)
assert parent == "base"
assert child == "d435_color_optical_frame"
def test_wrist_track_keeps_arm_camera_and_wrist_file(self):
site = _track_site(perception={"track_camera": "wrist"})
p = export_site_env.perception_overrides(site)
s = export_site_env.servo_overrides(site)
assert p["camera_source"] == "cam_a"
assert s["handeye_mode"] == "eye_in_hand"
assert s["handeye_file"] == "/tmp/wrist_eye_in_hand.yaml"
assert s["handeye_file"].endswith("eye_in_hand.yaml")
def test_overhead_without_range_raises(self):
site = _track_site(perception={"track_camera": "overhead", "overhead": {}})
with pytest.raises(RuntimeError, match="perception.overhead"):
export_site_env.perception_overrides(site)
def test_wrist_hybrid_wires_oneshot_without_overwriting_wrist_range(self):
site = _track_site(perception={"track_camera": "wrist"})
p = export_site_env.perception_overrides(site)
s = export_site_env.servo_overrides(site)
assert p["camera_source"] == "cam_a"
assert p["max_depth_m"] == pytest.approx(0.55)
assert p["overhead_oneshot_enable"] is True
assert p["wrist_handoff_conf"] == pytest.approx(0.5)
assert p["overhead_max_depth_m"] == pytest.approx(1.80)
assert p["overhead_handeye_file"].endswith("01_d435_eye_to_hand.yaml")
assert "d405" not in p["overhead_camera_source"]
assert s["handeye_mode"] == "eye_in_hand"
assert s["lock_still_sec"] == pytest.approx(3.0)
assert s["d435_handback_sec"] == pytest.approx(3.0)
assert s["wrist_align_sec"] == pytest.approx(0.5)
assert s["wrist_handoff_conf"] == pytest.approx(0.5)
assert s["overhead_hold_s"] == pytest.approx(8.0)
def test_wrist_hybrid_missing_handoff_conf_raises(self):
site = _track_site()
del site["perception"]["wrist_handoff_conf"]
with pytest.raises(RuntimeError, match="wrist_handoff_conf"):
export_site_env.perception_overrides(site)
def test_wrist_hybrid_missing_still_keys_raises(self):
site = _track_site()
del site["servo"]["lock_still_sec"]
with pytest.raises(RuntimeError, match="lock_still_sec"):
export_site_env.servo_overrides(site)
def test_wrist_hybrid_missing_handback_raises(self):
site = _track_site()
del site["servo"]["d435_handback_sec"]
with pytest.raises(RuntimeError, match="d435_handback_sec"):
export_site_env.servo_overrides(site)
def test_overhead_lookat_raises(self):
site = _overhead_ok()
site["servo"] = {"xy_track": "lookat"}
with pytest.raises(RuntimeError, match="lookat"):
export_site_env.servo_overrides(site)
def test_flatten_track_vars_follow_role(self):
flat = export_site_env.flatten(_overhead_ok())
assert flat["SITE_TRACK_CAMERA"] == "overhead"
assert flat["SITE_TRACK_CAM_NAME"] == "d435"
assert flat["SITE_CAM_NAME"] == "cam_a" # 腕部精拍路不变
assert flat["SITE_TRACK_HANDEYE_MODE"] == "eye_to_hand"
assert flat["SITE_TRACK_HANDEYE_FILE"].endswith("01_d435_eye_to_hand.yaml")
assert "wrist_eye_in_hand" not in flat["SITE_TRACK_HANDEYE_FILE"]
class TestCamPointToBase:
def test_eye_to_hand_ignores_flange(self):
T_he = np.eye(4)
T_he[:3, 3] = [0.4, -0.5, 0.7]
T_ee = np.eye(4)
T_ee[:3, 3] = [0.6, 0.0, 0.3]
p = np.array([0.0, 0.0, 0.8])
got = cam_point_to_base(p, T_he, T_ee, "eye_to_hand")
np.testing.assert_allclose(got, [0.4, -0.5, 1.5], atol=1e-9)
# 负向:若误乘法兰,点会多出法兰平移
wrong = cam_point_to_base(p, T_he, T_ee, "eye_in_hand")
assert np.linalg.norm(got - wrong) > 0.5
def test_eye_in_hand_still_left_multiplies_ee(self):
T_he = np.eye(4)
T_he[:3, 3] = [0.0, 0.0, 0.12]
T_ee = np.eye(4)
T_ee[:3, 3] = [0.5, -0.1, 0.3]
p = np.array([0.01, 0.0, 0.2])
got = cam_point_to_base(p, T_he, T_ee, "eye_in_hand")
np.testing.assert_allclose(got, [0.51, -0.1, 0.62], atol=1e-9)
def test_illegal_mode_raises(self):
with pytest.raises(ValueError, match="eye_in_hand 或 eye_to_hand"):
cam_to_base_T(np.eye(4), np.eye(4), "eye_on_foot")
class TestRealSiteBranchDefault:
"""main 上 site.yaml 必须是 D405 主跟踪 + D435 一次性,跟踪外参仍是腕部。"""
@pytest.mark.skip(reason="远程整站配置/硬件实现未同步;本地有独立回归测试")
def test_site_yaml_hybrid_wrist_plus_d435_oneshot(self, monkeypatch):
monkeypatch.setenv("SITE_CONFIG", str(ROOT / "config" / "site.yaml"))
monkeypatch.setenv("ROBOT_SN", "Rizon4-063989")
data = export_site_env.load_site()
p = export_site_env.perception_overrides(data)
s = export_site_env.servo_overrides(data)
assert p["camera_source"] in ("d405_left", "d405")
assert p["max_depth_m"] == pytest.approx(0.55)
assert p["wrist_handoff_conf"] == pytest.approx(0.5)
assert p["overhead_oneshot_enable"] is True
assert p["overhead_handeye_file"].endswith("89_d435_eye_to_hand.yaml")
assert p["overhead_max_depth_m"] == pytest.approx(1.80)
assert s["handeye_mode"] == "eye_in_hand"
assert s["handeye_file"].endswith("89_eye_in_hand.yaml")
assert s["lock_still_sec"] == pytest.approx(3.0)
assert s["d435_handback_sec"] == pytest.approx(2.0)
assert s["wrist_align_sec"] == pytest.approx(0.5)
assert s["lock_still_move_m"] == pytest.approx(0.015)
# 负向:跟踪外参不能被换成顶视文件
assert "eye_to_hand" not in s["handeye_file"]
assert "eye_in_hand" not in p["overhead_handeye_file"]
@@ -0,0 +1,180 @@
"""一阶段"重新捕获"的两道守卫:置信度门槛 + 特征点相机量程下限。
为什么单拎出来测(2026-10-08 真机,arm 89,一阶段把臂带离工件 500mm)
--------------------------------------------------------------------
``/tmp/pipeline_stage1.log`` 原话:
0659.551 死区保持 |e_xy|=3.0mm ← 已经收敛到 3mm
0659.598 k0 置信度 0.00 < 0.30,本帧不用它
0659.6xx 重新捕获 conf=0.15 dpx=503 ← 503px 外、conf 0.15 的假框
feat_base=(0.231,-0.484) z=-0.021 ← 跟踪点瞬间横移 427mm
0670.985 === 一阶段已锁定 === ← "锁定"在离工件 500mm 处
视觉模型(真 k0 conf 0.87)与控制(伺服收敛到 3mm)都正常,坏的是逻辑:
``pick_best`` 的"重新捕获"分支把门槛接成了 ``min_conf=0.12``,于是 0.15 的远框
被原样采信、perception 自己 ``kf.reset()`` 跳过去;伺服侧的 FeatureJumpGate 只能
拦一帧(假框帧间稳定,3 帧后照样认)。拦不住还有一个更根本的原因:那个假目标
就在**相机前 0.08m**(爪上工具/相机座,随臂一起动),跟踪其误差恒为 0 —— 伺服
"收敛"得完美,锁的却是自己。所以还要一道**量纲是米**的判据。
前两条是**负向**用例:把门槛调回旧值,第一条必须红。
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
import yaml
from arm_application.yolo_detector import Detection, pick_best
# 测试在 src/<包>/test/ 下 → 仓库根是 parents[3]
ROOT = Path(__file__).resolve().parents[3]
SITE = ROOT / "config" / "site.yaml"
HANDEYE = ROOT / "handeye" / "results" / "89_eye_in_hand.yaml"
# 真机实测数字(见本文件头与 site.yaml 的对应注释)——
# 写死在这里是**故意**的:它们是被测事实,不是配置。
REAL_K0_CONF = 0.87 # 真 k0 的置信度
FALSE_BOX_CONF = 0.15 # 被误采信的那个假框
FALSE_BOX_CONF_HI = 0.47 # 那串假框的实测上沿
# 相机光轴视距的两条带 [m](p_cam.z)
ROBOT_NEAR_BAND = (0.078, 0.087) # 随臂一起动:爪上工具 / 相机座
WORKPIECE_RANGE_BAND = (0.19, 0.29) # 工件面在锁高下
# 假框:503px 外、尺寸和真工件框同量级(否则会被 min_area 先挡掉,测不到本意)
PREV = np.array([300.0, 300.0, 460.0, 440.0]) # 上一帧框,中心 (380, 370)
FALSE_FAR = np.array([800.0, 20.0, 960.0, 160.0]) # 中心 (880, 90) → 503px 外
def _det(xyxy: np.ndarray, conf: float) -> Detection:
return Detection(xyxy=np.asarray(xyxy, dtype=float), conf=float(conf),
cls_id=0, cls_name="workpiece")
def _pick(conf: float, reacquire_conf: float):
return pick_best(
[_det(FALSE_FAR, conf)],
prev_uv=np.array([380.0, 370.0]),
prev_xyxy=PREV,
max_track_px=400.0,
reacquire_conf=reacquire_conf,
min_conf=0.12,
lock_iou=0.05,
)
def test_old_wiring_would_adopt_the_false_box():
"""负向基线:门槛=min_conf(旧接线)时,conf 0.15 的 503px 远框**会被采信**。
这条是"这个检查能红"的证明 —— 它若开始返回 None,说明前提变了,
下面"新接线挡得住"就不再是在测同一件事。
"""
best = _pick(FALSE_BOX_CONF, reacquire_conf=0.12)
assert best is not None, "旧接线本该采信这个假框 —— 前提变了,用例已失真"
assert best.conf == pytest.approx(FALSE_BOX_CONF)
def test_new_wiring_rejects_the_false_box():
"""新接线:门槛 0.55 > 假框上沿 0.47 ⇒ 低置信度远框一律不采信。"""
assert _pick(FALSE_BOX_CONF, reacquire_conf=0.55) is None
def test_new_wiring_rejects_whole_false_band():
"""实测那串假框 conf 0.13~0.47,全带都要挡住(不能只挡 0.15 那一个)。"""
for conf in (0.13, 0.25, 0.38, FALSE_BOX_CONF_HI):
assert _pick(conf, reacquire_conf=0.55) is None, f"conf={conf} 漏过去了"
def test_high_conf_relocation_is_still_adopted():
"""守卫不能过严:真 k0(0.87)级的重定位必须仍然立刻生效。
否则"工件确实被挪了"时臂会僵在原地不动 —— 那是另一种失效。
"""
best = _pick(REAL_K0_CONF, reacquire_conf=0.55)
assert best is not None and best.conf == pytest.approx(REAL_K0_CONF)
# --------------------------------------------------------------------------- #
# 接线:site.yaml 真的把这两个门槛接到了位,且取值区间有实测/几何依据
# --------------------------------------------------------------------------- #
def _site() -> dict:
assert SITE.is_file(), f"找不到站点配置 {SITE}"
return yaml.safe_load(SITE.read_text(encoding="utf-8"))
def _left_arm89(site: dict) -> dict:
for entry in site["robot"]["robots"]:
if str(entry.get("label", "")) == "左臂89":
return entry
pytest.fail("site.yaml 里找不到 左臂89 的条目")
def test_site_yaml_wires_reacquire_conf_between_false_band_and_real_k0():
yolo = _site()["yolo"]
# 键必须存在 —— 缺了会静默退回 min_conf(代码默认值兜着,配置看着"改了"却没用)
assert "reacquire_conf" in yolo, "site.yaml 没接 reacquire_conf(会退回 min_conf)"
v = float(yolo["reacquire_conf"])
assert FALSE_BOX_CONF_HI < v < REAL_K0_CONF, (
f"reacquire_conf={v} 必须高于假框带 {FALSE_BOX_CONF_HI}、低于真 k0 {REAL_K0_CONF}"
)
def test_feature_min_range_sits_between_robot_and_workpiece():
"""量程下限必须夹在"机械臂自身"与"工件面"两条实测带之间。"""
site = _site()
assert "feature_min_range_m" in site["yolo"], "site.yaml 没接 feature_min_range_m"
gate = float(site["yolo"]["feature_min_range_m"])
assert ROBOT_NEAR_BAND[1] < gate < WORKPIECE_RANGE_BAND[0], (
f"量程下限 {gate} 必须高于随臂近带 {ROBOT_NEAR_BAND[1]}、"
f"低于工件面 {WORKPIECE_RANGE_BAND[0]}"
)
def test_gauge_rod_extends_below_camera_so_near_band_is_the_robot():
"""近带为什么会被占满:规尖比相机更靠下 ⇒ 规杆整段都在相机近场。"""
arm = _left_arm89(_site())
tip = float(arm["gauge_assembly"]["tool_tip_below_flange_m"])
doc = yaml.safe_load(HANDEYE.read_text(encoding="utf-8"))
cam_z = abs(float(doc["translation_m"][2])) # 相机在法兰 Z 下方的距离
assert tip > cam_z + 0.05, (
f"规尖 {tip:.3f} 应比相机 {cam_z:.3f} 明显更靠下 —— 否则近带论证不成立"
)
def test_feature_min_range_is_not_looser_than_depth_floor():
"""不许把量程下限调到解码下限以下 —— 那等于这道守卫没接。"""
yolo = _site()["yolo"]
assert float(yolo["feature_min_range_m"]) > float(yolo["min_depth_m"])
assert float(yolo["feature_min_range_m"]) < float(yolo["max_depth_m"])
# --------------------------------------------------------------------------- #
# 接线(代码侧):site.yaml 写了键还不够,必须真的传进 pick_best / 量程判断
# --------------------------------------------------------------------------- #
def _perception_src() -> str:
p = ROOT / "src" / "arm_application" / "arm_application" / "perception_node.py"
assert p.is_file(), f"找不到 {p}"
return p.read_text(encoding="utf-8")
def test_perception_wires_reacquire_conf_into_pick_best():
src = _perception_src()
assert "reacquire_conf=self.reacquire_conf" in src, "参数没传进 pick_best,配置等于没接"
# 旧接线不许回来:这就是 2026-10-08 出事的写法
assert "reacquire_conf=self.min_conf" not in src, "重新捕获门槛又退回 min_conf 了"
assert 'declare_parameter("reacquire_conf"' in src
def test_perception_uses_feature_min_range_not_min_depth_for_features():
src = _perception_src()
assert 'declare_parameter("feature_min_range_m"' in src
assert "self.feature_min_range = max(" in src, "量程下限没有取严(可能被调得比 min_depth 还松)"
# 特征点量程判断必须用 feature_min_range,不能用回 min_depth
assert "self.feature_min_range < z < self.max_depth" in src
assert "(meters > self.feature_min_range)" in src
assert "(meters2 > self.feature_min_range)" in src
assert "min_depth=self.min_depth," not in src, "还有特征测量在用 min_depth"
@@ -0,0 +1,21 @@
"""A failed child must not turn into a successful ros2 launch result."""
import importlib.util
from pathlib import Path
import sys
import pytest
from launch import LaunchDescription, LaunchService
from launch.actions import ExecuteProcess, RegisterEventHandler
from launch.event_handlers import OnProcessExit
spec=importlib.util.spec_from_file_location('pipeline_launch',Path(__file__).resolve().parents[1]/'launch/pipeline.launch.py')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
@pytest.mark.parametrize('child_exit,expected',[(0,0),(2,1)])
def test_launch_reports_child_failure(child_exit,expected):
child=ExecuteProcess(cmd=[sys.executable,'-c',f'raise SystemExit({child_exit})'])
service=LaunchService(noninteractive=True)
service.include_launch_description(LaunchDescription([
RegisterEventHandler(OnProcessExit(target_action=child,on_exit=module._pipeline_exited)),child]))
assert service.run()==expected
@@ -0,0 +1,269 @@
"""一阶段超时的**归因**:是"感知没给点"还是"给了点没收敛"。
两者现象完全一样(都是"没锁定"),排查方向却相反:
* 一个点都没收到 → 感知侧(缺依赖 / 真值节点没起 / 没人发);
* 收到了点没收敛 → 控制与几何侧(工件不在视野、手眼、工作空间)。
以前共用一句"没锁定(视野里没工件?)",于是"perception_node 缺
ultralytics 早退了"被读成"算法不收敛",白查半天。这里把这句话钉住。
"""
from __future__ import annotations
import math
from pathlib import Path
import pytest
from arm_application.overhead_oneshot import D435_MOVEL_GAVE_UP
from arm_application.pipeline_node import (
UNWIND_ROUNDS,
close_ready,
gripper_wait_timeout_reason,
hold_at_insert_z,
init_z_refusal,
stage1_log_fatal,
stage1_timeout_reason,
track_lock_z,
unwind_round_label,
)
def test_gripper_timeout_does_not_call_a_dead_bus_not_in_position():
"""2026-10-20:读 0x0201 无应答时只发离线帧,不能报成"夹爪没到位"。
三种情况说法必须不同,否则会去查夹爪开合,而真正的问题是总线读不回来。
"""
no_frame = gripper_wait_timeout_reason(
"pos", 8.0, None, saw_any=False, saw_offline=False
)
assert "都没收到" in no_frame and "节点没在发" in no_frame
offline = gripper_wait_timeout_reason(
"pos", 8.0, None, saw_any=True, saw_offline=True
)
assert "读不到状态" in offline and "不是夹爪没张开" in offline
assert "未到位" not in offline
late = gripper_wait_timeout_reason(
"pos", 8.0, (0, 120.0), saw_any=True, saw_offline=False
)
assert "未到位" in late and "grip=0 pos=120" in late
def test_close_at_zero_continues_even_when_firmware_says_arrived():
"""2026-10-19:合到 0 时规还在,固件写 grip=1。只认 grip=2 就永远不顺时针。
停在张开位(pos=200)仍然不能往下走。
"""
assert close_ready(2, 80) is True
assert close_ready(1, 0) is True
assert close_ready(1, 40) is True
assert close_ready(1, 41) is False
assert close_ready(1, 200) is False
assert close_ready(0, 0) is False
assert close_ready(3, 0) is False
def test_fast_a7_duration_keeps_spline_peak_under_the_drive_cap():
"""空回 342° 不能按 0.90 rad/s 的平均速度排。样条峰速会超上限,到位还差几十度。"""
from arm_application.pipeline_node import (
A7_MAX_SPEED_DEG_S,
CUBIC_PEAK_OVER_AVG,
NRT_VMAX_RAD_S,
a7_move_duration_s,
)
delta = math.radians(169.9 - (-172.0))
dur = a7_move_duration_s(delta, A7_MAX_SPEED_DEG_S)
peak = CUBIC_PEAK_OVER_AVG * abs(delta) / dur
assert peak == pytest.approx(NRT_VMAX_RAD_S, rel=1e-6)
assert dur == pytest.approx(abs(delta) / (NRT_VMAX_RAD_S / CUBIC_PEAK_OVER_AVG))
# 10°/s 的峰速本来就低于上限,排程不得被放慢。
slow = math.radians(137.6)
assert a7_move_duration_s(slow, 10.0) == pytest.approx(abs(slow) / math.radians(10.0))
def test_unwind_is_four_rounds_and_labels_cannot_lie():
"""首轮咬合之后第三步整段固定 4 轮。轮次标签越界必须红,不能打出第 0 轮或第 5 轮。"""
assert UNWIND_ROUNDS == 4
assert unwind_round_label(1) == "空回第1/4轮"
assert unwind_round_label(4) == "空回第4/4轮"
for bad in (0, 5, -1):
with pytest.raises(ValueError):
unwind_round_label(bad)
with pytest.raises(ValueError):
unwind_round_label(1, total=3)
def test_insert_hold_does_not_return_to_hover():
"""贴面后停住。三阶段开局会抬回悬高;没有传感器,不另设一个 Z。"""
assert hold_at_insert_z({"insert_held": True}, skip3=False) is True
assert hold_at_insert_z({}, skip3=False) is False
assert hold_at_insert_z(None, skip3=False) is False
assert hold_at_insert_z({}, skip3=True) is True
src = (
Path(__file__).resolve().parents[1] / "arm_application" / "stage2_locate_node.py"
).read_text(encoding="utf-8")
hold = src[src.find("# ---- 5) 锁定保持 ----") : src.find("def _linger_hold")]
assert 'final["insert_held"] = True' in hold
assert 'final["insert_z_source"]' in hold
assert "hole_entry_mm" in hold
assert "self.insert_gap_m" in hold
pipe = (
Path(__file__).resolve().parents[1] / "arm_application" / "pipeline_node.py"
).read_text(encoding="utf-8")
run = pipe[pipe.find("def run") :]
assert "hold_at_insert_z(" in run
assert "不抬回悬高" in run
assert "_run_a7_seat(" in run
seat = pipe[pipe.find("def _run_a7_seat") : pipe.find("def _on_lock")]
marks = (
"首轮咬合结束",
'_gripper_cli("pos", str(open_permille))',
"空回前微抬",
"松爪回转",
"空回后降回",
'_gripper_cli("close")',
"夹紧再进",
)
pos = [seat.find(m) for m in marks]
assert all(i >= 0 for i in pos)
assert pos == sorted(pos)
assert seat.count("A7_MAX_SPEED_DEG_S") == 3
# 首轮:出孔用上限,顺时针进孔用 site 的 extreme_speed_deg_s(现场 10°/s)。
# 空回循环里的正反转仍是上限,不能把 10°/s 写进循环。
assert 'ga["extreme_speed_deg_s"]' in seat
first = seat[
seat.find("第一步出孔走接口上限") : seat.find("首轮咬合结束:A7 已顺时针转到限位")
]
assert "A7_MAX_SPEED_DEG_S" in first
assert "seat_cw_deg_s" in first
assert first.find("A7_MAX_SPEED_DEG_S") < first.find("seat_cw_deg_s")
loop = seat[seat.find("for rnd in range(1, UNWIND_ROUNDS + 1)") :]
assert "seat_cw_deg_s" not in loop
assert loop.count("A7_MAX_SPEED_DEG_S") == 2
# 抬和降写在循环里面,各一处。循环外再写一份就会有的轮次不抬。
loop_at = seat.find("for rnd in range(1, UNWIND_ROUNDS + 1)")
assert loop_at > seat.find("首轮咬合结束")
# 抬、降、开、合都在循环里,而且各只调用一次(靠循环重复,不在循环外再写一份)。
assert seat.find("_shift_flange_z(lift_mm") > loop_at
assert seat.count("_shift_flange_z(lift_mm") == 1
assert seat.count('_shift_flange_z(-dz_up') == 1
assert seat.count('_gripper_cli("pos"') == 1
assert seat.count('_gripper_cli("close")') == 1
assert "没走完,后面的轮次不再做" in seat
grip = pipe[pipe.find("def _gripper_cli") : pipe.find("def _wait_gripper")]
assert "run_gripper.sh" in grip
assert '"--cli"' in grip
assert "_send_a7_only" in pipe[pipe.find("def _a7_to_limit") : pipe.find("def _run_a7_seat")]
wait = pipe[pipe.find("def _wait_gripper") : pipe.find("def _a7_to_limit")]
assert "close_ready(" in wait
assert 'ga["unwind_open_permille"]' in seat
shift = pipe[pipe.find("def _shift_flange_z") : pipe.find("def _arm_short_id")]
assert "_vendor_movel_tcp" in shift
assert "movel_tcp_z_only" in shift
assert 'zone="ZFine"' in shift
assert "toler=1" in shift
assert "_goto(" not in shift
# /robot_description 是锁存话题。VOLATILE 订阅会错过已经发出的 URDF。
sub = pipe[pipe.find('"/robot_description"') : pipe.find('"/robot_description"') + 400]
assert "TRANSIENT_LOCAL" in sub
def test_zero_points_blames_perception_not_calibration(tmp_path: Path):
msg = stage1_timeout_reason(0, 150.0, str(tmp_path / "missing.log"))
assert "一个视觉目标点都没收到" in msg
# 必须逐后端给出对号入座的查法,否则人还是不知道下一步做什么
assert "yolo" in msg and "truth" in msg and "external" in msg
assert "ultralytics" in msg # yolo 后端最常见的原因
assert "perception.source" in msg # 指明这个开关怎么用
# 不能把人往"标定/视野"上带
assert "不是标定" in msg
def test_points_seen_but_no_convergence_blames_geometry(tmp_path: Path):
msg = stage1_timeout_reason(123, 150.0, str(tmp_path / "missing.log"))
assert "123" in msg
assert "没收敛" in msg
assert "手眼" in msg
# 这时**不该**再提感知依赖
assert "ultralytics" not in msg
def test_timeout_seconds_and_log_path_are_in_the_message(tmp_path: Path):
log = str(tmp_path / "x.log")
msg = stage1_timeout_reason(0, 42.0, log)
assert "42s" in msg
assert log in msg
def test_real_arm_locks_track_height_at_hover():
"""真机 89:悬停时规尖已经离面约 75mm,跟踪锁在悬停高,对孔前不再抬。"""
z = track_lock_z(0.320, 0.223, 0.022)
assert z == pytest.approx(0.320)
def test_sim_still_lifts_when_hover_barely_clears_the_part():
"""仿真悬停只留约 2mm,不抬就会在跟踪时刮到工件。"""
z = track_lock_z(0.657, 0.243, 0.412)
assert z == pytest.approx(0.707)
def test_init_z_refuses_a_value_that_would_enter_the_part():
"""用户指定的初始高度会钻进工件时必须拒绝,不许悄悄抬高。"""
why = init_z_refusal(0.20, 0.2245, 0.022, 0.002, 0.18, 0.65)
assert "进入工件" in why
assert "不会悄悄抬高" in why
assert init_z_refusal(0.320, 0.2245, 0.022, 0.002, 0.18, 0.65) == ""
assert "工作盒" in init_z_refusal(0.10, 0.2245, 0.022, 0.002, 0.18, 0.65)
assert "未知" in init_z_refusal(0.320, 0.0, 0.022, 0.002, 0.18, 0.65)
def test_movel_ipc_roundtrip():
lib = __import__("pathlib").Path(__file__).resolve().parents[3] / "scripts" / "lib"
import sys
if str(lib) not in sys.path:
sys.path.insert(0, str(lib))
from flexiv_movel_ipc import format_request, parse_done
line = format_request(7, 0.4, -0.2, 0.32, True, 0.05)
assert line.startswith("7 0.400000 -0.200000 0.320000 1 0.0500")
assert "ZFine" not in line
fine = format_request(8, 0.7, -0.1, 0.02, False, 0.01, "ZFine", 1)
assert fine.strip().endswith("ZFine 1")
try:
format_request(8, 0.7, -0.1, 0.02, False, 0.01, "Z50", 3)
except ValueError as exc:
assert "ZFine" in str(exc)
else:
raise AssertionError("默认过渡区 Z50 不许写进微升降请求")
cpp = (
Path(__file__).resolve().parents[3]
/ "src/flexiv_ros2/flexiv_hardware/src/flexiv_hardware_interface.cpp"
).read_text(encoding="utf-8")
assert 'movel_args.emplace("zoneRadius"' in cpp
assert 'if (!zone.empty())' in cpp
assert "微升降只接受过渡区 ZFine 和到位等级 1" in cpp
assert parse_done("7 ok\n") == (7, True, "")
assert parse_done("7 fail mode\n") == (7, False, "mode")
assert parse_done("nope\n") is None
def test_movel_gave_up_in_log_is_fatal_not_a_vision_timeout(tmp_path: Path):
"""2026-10-16 17:27:MoveL 控制模式失败后空等 150s 被读成粗定位卡住。"""
p = tmp_path / "stage1.log"
p.write_text(f"foo\n{D435_MOVEL_GAVE_UP} —— 一阶段中止\n", encoding="utf-8")
why = stage1_log_fatal(str(p))
assert why
assert "控制模式" in why
assert "不是视野" in why
msg = stage1_timeout_reason(80, 150.0, str(p))
assert "控制模式" in msg
assert "没收敛" not in msg
assert stage1_log_fatal(str(tmp_path / "nope.log")) == ""
def test_missing_geometry_does_not_pretend_the_hover_is_clear():
"""工具长或工件面缺失时必须抬升。缺数还锁在悬停高,等于没检查。"""
assert track_lock_z(0.320, 0.0, 0.022) == pytest.approx(0.370)
assert track_lock_z(0.320, 0.223, 0.0) == pytest.approx(0.370)
@@ -0,0 +1,292 @@
"""pose 模型支持(k0 跟踪 / k1,k2 对孔)的测试。
刻意**不依赖仓库里的模型二进制**(`models/**/*.onnx` 都不进 git):
ONNX 用 `onnx.helper` 现场造,解码用合成张量。所以纯逻辑都能离线跑,
GPU / TensorRT 都不需要 —— engine 那条路只能真机验(`weights.sh doctor --check-engines`)。
要挡住的那类 bug(2026-09-29 实测踩到的):
ultralytics 按 `task="detect"` 去解 pose 的 14 通道输出,会吐 300 个垃圾框,
`conf` 直接是坐标值(600+)—— 现场表现是"跟踪锁到空气上",在真机上很危险。
"""
from __future__ import annotations
import numpy as np
import pytest
from arm_application.hole_detect_hough import _keypoint_holes
from arm_application.model_labels import SOURCE_SIDECAR, LabelSet
from arm_application.pose_detector import (
PoseDetector,
is_pose_model,
keypoint_roles,
letterbox,
nms_xyxy,
read_pose_info,
)
from arm_application.yolo_detector import Detection
# --------------------------------------------------------------------------
# fixture 工具
# --------------------------------------------------------------------------
def _make_onnx(path, metadata):
"""造一个只有 metadata 的最小 ONNX(图内容无所谓,只测 metadata 解析)。"""
pytest.importorskip("onnx", reason="本环境没装 onnx(只有真机 conda 环境有)")
import onnx
from onnx import TensorProto, helper
inp = helper.make_tensor_value_info("images", TensorProto.FLOAT, [1, 3, 8, 8])
out = helper.make_tensor_value_info("output0", TensorProto.FLOAT, [1, 14, 4])
graph = helper.make_graph(
[helper.make_node("Identity", ["images"], ["output0"])], "g", [inp], [out]
)
model = helper.make_model(graph)
model.opset_import[0].version = 13
for k, v in metadata.items():
model.metadata_props.add(key=k, value=v)
onnx.save(model, str(path))
return path
def _bare_detector(*, kpt_count=3, conf=0.25, iou=0.45, max_det=20, class_names=None):
"""只装解码需要的属性 —— 绕开 __init__ 里的 engine 加载(要 GPU)。"""
det = object.__new__(PoseDetector)
det.kpt_count = kpt_count
det.conf = conf
det.iou = iou
det.max_det = max_det
det.class_ids = None
det.class_names = class_names
det._labels = LabelSet(names={0: "workpiece"}, source=SOURCE_SIDECAR, path=None)
return det
def _raw(boxes, kpts, confs, n_anchor=4, nc=1):
"""拼一个 (1, 4+nc+3K, A) 的合成输出。boxes=xywh,kpts=[[(x,y,c),...], ...]"""
k = kpts.shape[1]
ch = 4 + nc + 3 * k
o = np.zeros((ch, n_anchor), dtype=np.float32)
n = boxes.shape[0]
o[0:4, :n] = boxes.T
o[4:4 + nc, :n] = confs[None, :] if nc > 1 else confs[None, :]
o[4 + nc:, :n] = kpts.reshape(n, -1).T
return o[None]
# --------------------------------------------------------------------------
# 模型识别:从旁挂 .onnx 的 metadata 判断是不是 pose
# --------------------------------------------------------------------------
def test_pose_model_is_recognized(tmp_path):
_make_onnx(
tmp_path / "m.onnx",
{
"task": "pose",
"kpt_shape": "[3, 3]",
"kpt_names": "{0: ['0', '1', '2']}",
"names": "{0: 'workpiece'}",
},
)
info = read_pose_info(tmp_path / "m.onnx")
assert info is not None
assert info["kpt_count"] == 3
assert info["names"] == {0: "workpiece"}
assert is_pose_model(tmp_path / "m.onnx")
def test_detect_model_is_not_pose(tmp_path):
_make_onnx(tmp_path / "d.onnx", {"task": "detect", "names": "{0: 'workpiece'}"})
assert read_pose_info(tmp_path / "d.onnx") is None
assert not is_pose_model(tmp_path / "d.onnx")
def test_pose_without_kpt_shape_is_not_guessed(tmp_path):
"""没有 kpt_shape 就别猜关键点个数 —— 猜错是"静默错位",比报错难查得多。"""
_make_onnx(tmp_path / "p.onnx", {"task": "pose", "names": "{0: 'workpiece'}"})
assert read_pose_info(tmp_path / "p.onnx") is None
def test_engine_reads_pose_info_from_sibling_onnx(tmp_path):
"""engine 没有 metadata(TRT 10 Python API 就没有接口),要靠同名的 .onnx。"""
_make_onnx(
tmp_path / "m.onnx", {"task": "pose", "kpt_shape": "[3, 3]", "names": "{0: 'workpiece'}"}
)
(tmp_path / "m.engine").write_bytes(b"not a real engine")
assert is_pose_model(tmp_path / "m.engine")
def test_pose_info_missing_onnx_returns_none(tmp_path):
(tmp_path / "m.engine").write_bytes(b"not a real engine")
assert read_pose_info(tmp_path / "m.engine") is None
# --------------------------------------------------------------------------
# letterbox:坐标还原全靠它,错了整幅图的框都会偏
# --------------------------------------------------------------------------
def test_letterbox_preserves_aspect_and_reports_padding():
img = np.zeros((480, 848, 3), dtype=np.uint8)
out, gain, (dw, dh) = letterbox(img, 640)
assert out.shape == (640, 640, 3)
# 848x480 → gain = 640/848;等比缩到 640x362,纵向补 139
assert gain == pytest.approx(640 / 848, rel=1e-6)
assert dw == pytest.approx(0.0, abs=0.5)
assert dh == pytest.approx(139.0, abs=1.0)
def test_letterbox_square_input_is_untouched():
img = np.zeros((640, 640, 3), dtype=np.uint8)
out, gain, (dw, dh) = letterbox(img, 640)
assert out.shape == (640, 640, 3)
assert gain == pytest.approx(1.0)
assert (dw, dh) == (0.0, 0.0)
# --------------------------------------------------------------------------
# NMS
# --------------------------------------------------------------------------
def test_nms_suppresses_overlap_and_keeps_highest():
boxes = np.array([[0, 0, 100, 100], [5, 5, 105, 105], [500, 500, 600, 600]], dtype=float)
scores = np.array([0.9, 0.8, 0.7])
keep = nms_xyxy(boxes, scores, 0.5, max_det=10)
assert list(keep) == [0, 2]
def test_nms_respects_max_det():
boxes = np.array([[0, 0, 10, 10], [100, 100, 110, 110], [200, 200, 210, 210]], dtype=float)
assert len(nms_xyxy(boxes, np.array([0.9, 0.8, 0.7]), 0.5, max_det=2)) == 2
# --------------------------------------------------------------------------
# 解码:框 + 关键点,并去 letterbox 还原到原图
# --------------------------------------------------------------------------
def test_decode_returns_box_and_keypoints_unletterboxed():
det = _bare_detector()
boxes = np.array([[320.0, 320.0, 100.0, 80.0]])
kpts = np.array([[[300.0, 300.0, 0.9], [360.0, 300.0, 0.8], [280.0, 310.0, 0.7]]])
raw = _raw(boxes, kpts, np.array([0.90]))
out = det._decode(raw, gain=0.5, dw=10.0, dh=20.0)
assert len(out) == 1
d = out[0]
assert d.cls_name == "workpiece"
assert d.conf == pytest.approx(0.90, abs=1e-5)
# ((cx±w/2) - dw) / gain
assert list(np.round(d.xyxy, 3)) == [520.0, 520.0, 720.0, 680.0]
assert d.kpts.shape == (3, 3)
assert list(np.round(d.kpts[0][:2], 3)) == [580.0, 560.0]
assert d.kpts[0][2] == pytest.approx(0.9, abs=1e-5)
assert list(np.round(d.kpts[1][:2], 3)) == [700.0, 560.0]
assert list(np.round(d.kpts[2][:2], 3)) == [540.0, 580.0]
def test_decode_drops_low_conf_anchors():
det = _bare_detector(conf=0.5)
boxes = np.array([[100.0, 100.0, 20.0, 20.0], [400.0, 400.0, 20.0, 20.0]])
kpts = np.zeros((2, 3, 3), dtype=float)
raw = _raw(boxes, kpts, np.array([0.9, 0.1]))
out = det._decode(raw, gain=1.0, dw=0.0, dh=0.0)
assert len(out) == 1
assert out[0].conf == pytest.approx(0.9, abs=1e-5)
def test_decode_raises_when_channels_do_not_match_kpt_count():
"""通道数和 kpt_shape 对不上时必须报错,不能默默解出一堆垃圾。"""
det = _bare_detector(kpt_count=3)
raw = np.zeros((1, 6, 4), dtype=np.float32) # 6 通道装不下 3 个关键点
with pytest.raises(RuntimeError, match="通道数不对"):
det._decode(raw, gain=1.0, dw=0.0, dh=0.0)
def test_decode_of_detect_style_output_would_be_garbage():
"""回归:把 pose 的 14 通道输出当检测解 → conf 会变成 600+ 的坐标值。
这条测的是"我们**没有**走那条路":同一份合成输出,按 pose 解出来的 conf
必须在 (0,1],绝不能是坐标量级。
"""
det = _bare_detector()
boxes = np.array([[600.0, 640.0, 100.0, 80.0]])
kpts = np.array([[[610.0, 300.0, 0.9], [620.0, 300.0, 0.8], [630.0, 310.0, 0.7]]])
raw = _raw(boxes, kpts, np.array([0.9]))
out = det._decode(raw, gain=1.0, dw=0.0, dh=0.0)
assert len(out) == 1
assert 0.0 < out[0].conf <= 1.0
# --------------------------------------------------------------------------
# k1/k2:模型语义顺序,**不按图像 x 交换**
# --------------------------------------------------------------------------
def _det_with_kpts(kpts, conf=0.9):
return Detection(
xyxy=np.array([300.0, 300.0, 500.0, 400.0]),
conf=conf,
cls_id=0,
cls_name="workpiece",
kpts=np.asarray(kpts, dtype=float),
)
def _kw(**over):
cfg = dict(conf_thr=0.25, min_kconf=0.30, radius_px=12.0, min_dist_px=15.0)
cfg.update(over)
return cfg
def test_keypoint_holes_keeps_model_semantic_order():
"""k1 在画面右边、k2 在左边时,**不能**交换。
Hough 路径的 k1/k2 是"图像左/右"(位置定义);pose 的 k1/k2 是训练时标注的
固定物理孔。按 x 交换会让 target_hole=k1 在工件转个角度后指向**另一个**孔 ——
现场表现为"有时对左孔、有时对右孔",非常难查。
"""
det = _det_with_kpts([[400, 350, 1.0], [470, 340, 1.0], [330, 345, 1.0]])
out = _keypoint_holes([det], **_kw())
assert out is not None
(k1x, k1y), (k2x, k2y), r1, r2 = out
assert (k1x, k1y) == (470.0, 340.0), "k1 必须还是模型的 k1(画面右)"
assert (k2x, k2y) == (330.0, 345.0), "k2 必须还是模型的 k2(画面左)"
assert (r1, r2) == (12.0, 12.0)
def test_keypoint_holes_rejects_low_confidence():
det = _det_with_kpts([[400, 350, 1.0], [470, 340, 0.10], [330, 345, 0.05]])
assert _keypoint_holes([det], **_kw()) is None
def test_keypoint_holes_rejects_coincident_points():
det = _det_with_kpts([[400, 350, 1.0], [401, 350, 1.0], [402, 350, 1.0]])
assert _keypoint_holes([det], **_kw(min_dist_px=15.0)) is None
def test_keypoint_holes_returns_none_for_plain_detection():
"""普通检测模型没有 kpts → 必须返回 None,让 Hough 老路径接手。"""
det = Detection(xyxy=np.array([0.0, 0.0, 10.0, 10.0]), conf=0.9, cls_id=0, cls_name="workpiece")
assert _keypoint_holes([det], **_kw()) is None
def test_keypoint_holes_skips_low_conf_detection():
det = _det_with_kpts([[400, 350, 1.0], [470, 340, 1.0], [330, 345, 1.0]], conf=0.05)
assert _keypoint_holes([det], **_kw(conf_thr=0.25)) is None
# --------------------------------------------------------------------------
# 角色约定
# --------------------------------------------------------------------------
def test_keypoint_roles_defaults_to_k0_track_k1k2_holes():
assert keypoint_roles(3) == (0, (1, 2))
def test_keypoint_roles_handles_models_with_fewer_points():
assert keypoint_roles(1) == (0, ())
assert keypoint_roles(2) == (0, (1,))
def test_resolve_weights_skips_engine_when_tensorrt_missing(tmp_path, monkeypatch):
from arm_application import yolo_detector as yd
onnx = tmp_path / "gongjian0929.onnx"
engine = tmp_path / "gongjian0929.engine"
onnx.write_bytes(b"onnx")
engine.write_bytes(b"engine")
monkeypatch.setattr(yd, "tensorrt_importable", lambda: False)
assert yd.resolve_weights(onnx, "auto") == onnx
assert yd.resolve_weights(onnx, "tensorrt") == onnx
+167
View File
@@ -0,0 +1,167 @@
"""`TolerantNode` 的回归锚点 + 「谁都不许直接用 rclpy 的 Node」守卫。
背景(2026-09-28 真机落地实测):现场 `config/site.yaml` 的
``cameras.d435.ae_limit_us: 8000`` 是 YAML **整数**,节点里声明的是 double,
rclpy 严格较类型 → 顶置 D435 节点**启动即崩**,报错里还完全不提 site.yaml:
InvalidParameterTypeException: Trying to set parameter 'ae_limit_us'
to '8000' of type 'INTEGER', expecting type 'DOUBLE'
这组测试钉住三件事:
1. `TolerantNode` 对「数字宽窄不一致」是宽容的(8000 / 8000.0 / "8000" 都能用);
2. 宽容**只**发生在数值族,不是"什么都收";
3. 应用层不再有直接继承 `rclpy.node.Node` 的节点类 —— 否则这个坑会悄悄回来。
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
rclpy = pytest.importorskip("rclpy")
from rclpy.node import Node # noqa: E402
from rclpy.parameter import Parameter # noqa: E402
from arm_application.ros_node import TolerantNode, param_as_str # noqa: E402
APP_DIR = Path(__file__).resolve().parents[1] / "arm_application"
@pytest.fixture()
def ros():
rclpy.init()
try:
yield
finally:
rclpy.shutdown()
def _overrides(**kwargs):
return [Parameter(k, value=v) for k, v in kwargs.items()]
# ---------------------------------------------------------------------------
# 1. 数值族宽容
# ---------------------------------------------------------------------------
def test_float_param_accepts_integer_override(ros):
"""site.yaml 写 8000(整数)→ double 参数必须能起来,并按 8000.0 用。"""
node = TolerantNode("t_float", parameter_overrides=_overrides(ae_limit_us=8000))
try:
node.declare_parameter("ae_limit_us", 3000.0)
assert float(node.get_parameter("ae_limit_us").value) == 8000.0
finally:
node.destroy_node()
def test_int_param_accepts_float_override(ros):
"""反过来也要能起:site.yaml 写 848.0 → int 参数(宽度)。"""
node = TolerantNode("t_int", parameter_overrides=_overrides(width=848.0))
try:
node.declare_parameter("width", 1280)
assert int(node.get_parameter("width").value) == 848
finally:
node.destroy_node()
def test_bool_param_accepts_int_override(ros):
"""site.yaml 写 1 / 0(YAML 布尔的一种常见手写)→ bool 参数。"""
node = TolerantNode("t_bool", parameter_overrides=_overrides(emitter_enabled=1))
try:
node.declare_parameter("emitter_enabled", False)
assert bool(node.get_parameter("emitter_enabled").value) is True
finally:
node.destroy_node()
def test_number_array_param_accepts_integer_array(ros):
"""`workspace_xyz_min: [0, -1, 0]` 这种整数字面量列表 → DOUBLE_ARRAY。"""
node = TolerantNode("t_arr", parameter_overrides=_overrides(ws=[0, -1, 0]))
try:
node.declare_parameter("ws", [0.1, -0.5, 0.2])
assert [float(v) for v in node.get_parameter("ws").value] == [0.0, -1.0, 0.0]
finally:
node.destroy_node()
def test_param_as_str_accepts_numeric_override(ros):
"""SN 写成不带引号的 26610799(YAML 整数)→ 字符串参数。"""
node = TolerantNode("t_str", parameter_overrides=_overrides(serial_number=26610799))
try:
assert param_as_str(node, "serial_number", "") == "26610799"
finally:
node.destroy_node()
def test_missing_override_falls_back_to_default(ros):
node = TolerantNode("t_default")
try:
node.declare_parameter("ae_limit_us", 3000.0)
assert float(node.get_parameter("ae_limit_us").value) == 3000.0
assert param_as_str(node, "camera_serial", "abc") == "abc"
finally:
node.destroy_node()
# ---------------------------------------------------------------------------
# 2. 「为什么需要这个子类」——防止有人把宽容读成"本来就没问题"
# ---------------------------------------------------------------------------
def test_plain_rclpy_node_really_is_strict(ros):
"""原生 Node 遇到宽窄不一致会抛异常。
这条测试是**原因**的记录:如果哪天 rclpy 改了行为,这条会红,
我们就知道 `TolerantNode` 已经没有存在意义(可以删掉,而不是留着当谜语)。
"""
from rclpy.exceptions import InvalidParameterTypeException
node = Node("t_strict", parameter_overrides=_overrides(ae_limit_us=8000))
try:
with pytest.raises(InvalidParameterTypeException):
node.declare_parameter("ae_limit_us", 3000.0)
finally:
node.destroy_node()
# ---------------------------------------------------------------------------
# 3. 守卫:应用层不许再直接继承 rclpy 的 Node
# ---------------------------------------------------------------------------
def _direct_node_subclasses() -> list[str]:
hits: list[str] = []
for path in sorted(APP_DIR.glob("*.py")):
if path.name == "ros_node.py":
continue # TolerantNode 自己就是干这个的
tree = ast.parse(path.read_text(encoding="utf-8"))
imports_rclpy_node = False
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and (node.module or "") == "rclpy.node":
if any(alias.name == "Node" for alias in node.names):
imports_rclpy_node = True
if not imports_rclpy_node:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and any(
isinstance(b, ast.Name) and b.id == "Node" for b in node.bases
):
hits.append(f"{path.name}::{node.name}")
return hits
def test_no_node_subclasses_rclpy_node_directly():
hits = _direct_node_subclasses()
assert not hits, (
"这些节点类直接继承了 rclpy 的 Node,会重新踩「site.yaml 写 8000、"
"节点要 double」的启动崩溃:\n " + "\n ".join(hits) +
"\n改法:继承 arm_application.ros_node.TolerantNode。"
)
def test_tolerant_node_is_the_thing_nodes_subclass():
"""至少得有人真的在用它,否则这条规则只是摆设。"""
users = [
p.name
for p in sorted(APP_DIR.glob("*.py"))
if "TolerantNode" in p.read_text(encoding="utf-8") and p.name != "ros_node.py"
]
assert len(users) >= 10, f"用 TolerantNode 的节点太少了:{users}"
@@ -0,0 +1,149 @@
"""site.yaml 的**机器人级覆盖**必须登记在案。
site.yaml 有两层:顶层是站点配置,``robots[]`` 里每台臂可以覆盖若干**段**
(camera/paths/servo/…)。合并只发生在 ``apply_active_arm`` 的
``_ARM_SECTION_KEYS`` 上 —— 没登记进那张表的段,**覆盖会静默失效**:
文件里写着 ``source: truth``,合出来还是 yolo。
这个坑踩过两次(yolo、perception),两次的现象都是"配置明明改了,行为没变",
而且不报错。所以这里钉一条结构性的规矩:
robots[] 里任何**嵌套映射**(段)都必须出现在 _ARM_SECTION_KEYS 里。
标量键(sn/arm/label/ip/tool_name/tactile…)由代码逐个读,不参与合并,不受此限。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Iterable, List, Mapping
import pytest
import yaml
# 测试在 src/<包>/test/ 下 → 仓库根是 parents[3]([0]=test,[1]=包,[2]=src)
ROOT = Path(__file__).resolve().parents[3]
SCRIPTS_LIB = ROOT / "scripts" / "lib"
def robot_entries(doc: Mapping) -> List[Mapping]:
"""site.yaml 里的机器人列表(``robot.robots``)。
⚠️ 路径是 **robot.robots**,不是顶层的 robots。写成 ``doc.get("robots")``
会拿到 None → 循环一次都不跑 → 测试"永远通过"(这个假通过本文件就踩过一次)。
所以下面那个函数一律经过这里取,并且断言列表非空。
"""
entries = ((doc.get("robot") or {}).get("robots")) or []
assert entries, "site.yaml 里 robot.robots 是空的 —— 键路径写错了?"
return list(entries)
def unregistered_sections(robots: Iterable[Mapping], keys: Iterable[str]) -> List[str]:
"""哪些机器人级的"段"没登记进合并表(返回 ``SN.段名``)。"""
known = set(keys)
bad: List[str] = []
for robot in robots:
for key, val in dict(robot).items():
if isinstance(val, dict) and key not in known:
bad.append(f"{robot.get('sn')}.{key}")
return bad
@pytest.fixture(scope="module")
def arm_section_keys() -> tuple[str, ...]:
sys.path.insert(0, str(SCRIPTS_LIB))
import export_site_env # type: ignore
return tuple(export_site_env._ARM_SECTION_KEYS)
def test_every_robot_level_section_is_registered_for_merging(arm_section_keys):
doc = yaml.safe_load((ROOT / "config" / "site.yaml").read_text(encoding="utf-8"))
bad = unregistered_sections(robot_entries(doc), arm_section_keys)
assert not bad, (
"这些机器人级段没登记进 _ARM_SECTION_KEYS,覆盖会**静默失效**:"
f"{bad}\n"
" 修法:scripts/lib/export_site_env.py 的 _ARM_SECTION_KEYS 里加上它们。"
)
def test_robot_entries_refuses_a_wrong_key_path():
"""取错键路径必须**报错**,不能静默返回空列表(那会让自检永远通过)。"""
with pytest.raises(AssertionError):
robot_entries({"robots": [{"sn": "x"}]}) # 少了外层 robot:
def test_the_predicate_can_actually_fail(arm_section_keys):
"""判据本身要能报错:否则上面那条可能只是"永远通过"。"""
bad = unregistered_sections(
[{"sn": "x", "camera": {"name": "c"}, "brand_new_section": {"a": 1}}],
arm_section_keys,
)
assert bad == ["x.brand_new_section"]
def test_sim_robot_carries_the_truth_backend_override(arm_section_keys):
"""仿真那条 ``perception.source: truth`` 是真的会被合并进去的。
这条同时钉住"两份配置别漂移":site.yaml 里删掉了它,
仿真就会退回 yolo 去跑推理(然后因为缺依赖起不来)。
"""
doc = yaml.safe_load((ROOT / "config" / "site.yaml").read_text(encoding="utf-8"))
sim = [r for r in robot_entries(doc) if r.get("sn") == "sim"]
assert sim, "site.yaml 里没有 sim 机器人 —— 仿真流程会挑不到配置"
src = (sim[0].get("perception") or {}).get("source")
assert src == "truth", f"sim 的 perception.source 应为 truth,现在是 {src!r}"
def _flatten_for(sn: str) -> dict:
import export_site_env # type: ignore
previous = os.environ.get("ROBOT_SN")
os.environ["ROBOT_SN"] = sn
try:
return export_site_env.flatten(export_site_env.load_site())
finally:
if previous is None:
os.environ.pop("ROBOT_SN", None)
else:
os.environ["ROBOT_SN"] = previous
def test_live_left_gripper_does_not_auto_home(arm_section_keys):
"""左臂现场 auto_init 是 false。拉起驱动若把它弄成 true,会写 0xA5 回零。"""
flat = _flatten_for("89")
assert flat["SITE_GRIP_AUTO_INIT"] == "false"
assert flat["SITE_GRIP_PORT"] == "/dev/ttyUSB0"
def test_auto_init_override_is_not_ignored(arm_section_keys, tmp_path):
"""把现场改成 true 必须真的变成 true。否则上面那条是 exporter 写死的假通过。"""
import export_site_env # type: ignore
doc = yaml.safe_load((ROOT / "config" / "site.yaml").read_text(encoding="utf-8"))
changed = False
for robot in robot_entries(doc):
if str(robot.get("sn") or "").endswith("063989"):
robot["gripper"]["auto_init"] = True
changed = True
assert changed
cfg = tmp_path / "site.yaml"
cfg.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
previous_cfg = os.environ.get("SITE_CONFIG")
previous_sn = os.environ.get("ROBOT_SN")
os.environ["SITE_CONFIG"] = str(cfg)
os.environ["ROBOT_SN"] = "89"
try:
flat = export_site_env.flatten(export_site_env.load_site())
finally:
if previous_cfg is None:
os.environ.pop("SITE_CONFIG", None)
else:
os.environ["SITE_CONFIG"] = previous_cfg
if previous_sn is None:
os.environ.pop("ROBOT_SN", None)
else:
os.environ["ROBOT_SN"] = previous_sn
assert flat["SITE_GRIP_AUTO_INIT"] == "true"
@@ -0,0 +1,50 @@
"""Exercise Action failure handling with fake futures; no ROS nodes or hardware."""
from concurrent.futures import Future
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from trajectory_msgs.msg import JointTrajectory
from arm_application.stage2_locate_node import Stage2Locate
def done(value):
future=Future();future.set_result(value);return future
def fake_node(reply):
clock=[0.]
def spin(dt):clock[0]+=dt
logger=Mock()
node=SimpleNamespace(_cli=SimpleNamespace(send_goal_async=Mock(return_value=reply)),
_spin=spin,get_logger=lambda:logger)
return node,clock
@pytest.mark.parametrize('status,code,expected',[(4,0,True),(5,0,False),(6,0,False),(4,-1,False)])
def test_only_completed_success_is_success(status,code,expected):
handle=SimpleNamespace(accepted=True,get_result_async=lambda:done(
SimpleNamespace(status=status,result=SimpleNamespace(error_code=code,error_string='test'))))
node,clock=fake_node(done(handle))
with patch('arm_application.stage2_locate_node.time.monotonic',lambda:clock[0]):
assert Stage2Locate._send_action(node,JointTrajectory(),.2) is expected
def test_acceptance_timeout_never_replays_goal_and_cancels_late_acceptance():
pending=Future();node,clock=fake_node(pending)
with patch('arm_application.stage2_locate_node.time.monotonic',lambda:clock[0]):
with pytest.raises(RuntimeError,match='禁止重发'):
Stage2Locate._send_action(node,JointTrajectory(),.2)
assert node._cli.send_goal_async.call_count==1
handle=SimpleNamespace(accepted=True,cancel_goal_async=Mock())
pending.set_result(handle)
handle.cancel_goal_async.assert_called_once()
def test_execution_timeout_cancels_instead_of_continuing():
handle=SimpleNamespace(accepted=True,get_result_async=lambda:Future(),cancel_goal_async=Mock())
node,clock=fake_node(done(handle))
with patch('arm_application.stage2_locate_node.time.monotonic',lambda:clock[0]):
with pytest.raises(RuntimeError,match='中止当前流程'):
Stage2Locate._send_action(node,JointTrajectory(),.2)
handle.cancel_goal_async.assert_called_once()
@@ -0,0 +1,287 @@
"""粗居中的两条误检守卫:**框被画面切** + **目标点离谱/盒外**。
为什么单独立一条(2026-10-08 真机 arm 89,二阶段 exit=4)
--------------------------------------------------------
``/tmp/run_all3.log`` 原话(一阶段锁定 ✓、二阶段悬停到位 ✓,死在这一步)::
悬停到位: 法兰 Z=0.320m(目标 0.320,差 +0mm) 残余倾角 0.37deg
snap_coarse_0: 工件 bbox=(0, 142, 97, 222) 中心=(48,182)
base=(0.280,-0.747)[深度] 夹爪=(0.336,-0.543) 偏差=211.5mm
目标超出工作盒,已截断: (0.280,-0.747,0.320) → (0.280,-0.545,0.320)
plan coarse Δ=(-56,-2,-0)mm ← 把臂带离工件 56mm
snap_coarse_1: 工件 bbox=[0, 0, 847, 479](整幅画面)偏差=55.9mm
snap_fine_0..8: 两孔基座间距 105.8 / 210.0 / 213.2 / …mm
(期望 32–85mm)丢弃本拍
二阶段失败 exit=4,停在当前位姿
工件其实在 (0.5,-0.12) 一带。两个缺陷叠在一起:
1. ``bbox=(0,142,97,222)`` 的 ``x0=0`` —— 被画面**左边缘切掉**的半框。框中心
根本不在工件上,深度反投影因此给出盒外的 (0.280,-0.747);
2. 这个盒外目标**没有被判误检**,而是"截断到盒边"后照样移动 —— 越界只被夹到
盒边,臂还是走了。这就是规矩里禁的"宽容降级":把"没检查"伪装成"检查通过"。
一挪错,精定位就再也看不到 k1/k2,而流程把它报成"孔检测失败"(exit=4)。
修法:``_bbox_on_border`` 弃用贴边框、``_coarse_target_reject`` 在**移动前**
否掉盒外/离谱目标(判误检 ⇒ 原地不动,交给精定位);以及 YOLO 无可信框但精定位
已看到 k1/k2 时**真的不移动**(旧代码嘴上说"跳过居中"却仍然算偏差横移)。
这些用例全是**离线**的:两个被判的函数都是纯逻辑,用打桩对象即可,不建 ROS 实体
(与 ``test_stage2_descent_clamp`` 同一套做法)。
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, List, Optional
import numpy as np
import pytest
import yaml
from arm_application.stage2_locate_node import Stage2Locate
# 测试在 src/<包>/test/ 下 → 仓库根是 parents[3]
ROOT = Path(__file__).resolve().parents[3]
SITE = ROOT / "config" / "site.yaml"
# ---- 真机实测数字(见本文件头,故意写死:它们是被测事实,不是配置)----
FRAME_W, FRAME_H = 848, 480 # D405 彩色图尺寸(bbox [0,0,847,479] 可反推)
BAD_BBOX_CLIPPED = (0, 142, 97, 222) # 左边缘切掉的半框 —— 事故那一帧
DEGENERATE_BBOX = (0, 0, 847, 479) # 孔检测器兜底给的整幅画面
GOOD_BBOX = (300, 150, 520, 330) # 一个正常的、不贴边的框
BAD_TARGET_BASE = np.array([0.280, -0.747, 0.320]) # 那帧反投影出来的点(盒外)
GAUGE_XY = np.array([0.336, -0.543]) # 那帧的夹爪轴线 XY
BAD_ERR_M = 0.2115 # 日志里的"偏差=211.5mm"
CLAMPED_Y = -0.545 # 日志里的"截断"结果
# 工作盒(site.yaml: robots[].servo.workspace_xyz_*)
WS_MIN = np.array([0.08, -0.55, 0.18])
WS_MAX = np.array([0.85, 0.55, 0.65])
class _StubLogger:
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def _add(self, msg: str) -> None:
self._sink.append(str(msg))
info = _add
warn = _add
error = _add
class _Stub:
"""两个被判函数只用到这几个成员,其余全部打桩。"""
def __init__(
self,
*,
ws_min: Any = WS_MIN,
ws_max: Any = WS_MAX,
max_offset_m: float = 0.12,
border_px: int = 2,
) -> None:
self.logs: List[str] = []
self.ws_min = ws_min
self.ws_max = ws_max
self.coarse_max_offset_m = max_offset_m
self.coarse_border_px = border_px
def get_logger(self) -> Any:
return _StubLogger(self.logs)
def _reject(st: _Stub, p: Any, g: Any, max_offset_m: Optional[float] = None) -> Optional[str]:
return Stage2Locate._coarse_target_reject( # type: ignore[arg-type]
st, np.asarray(p, dtype=float), np.asarray(g, dtype=float), max_offset_m
)
def _on_border(st: _Stub, bbox: Any, w: int = FRAME_W, h: int = FRAME_H) -> bool:
return Stage2Locate._bbox_on_border(st, bbox, w, h) # type: ignore[arg-type]
# --------------------------------------------------------------------------- #
# 守卫一:被画面边缘切掉的框一律弃用
# --------------------------------------------------------------------------- #
def test_clipped_bbox_is_rejected():
"""负向基线:事故那一帧的框贴左边(x0=0),必须判"不可用"。"""
assert _on_border(_Stub(), BAD_BBOX_CLIPPED) is True
def test_degenerate_full_frame_bbox_is_rejected():
"""整幅画面当工件(四边全贴)也必须弃用 —— 它的"中心"就是画面中心,毫无信息。"""
assert _on_border(_Stub(), DEGENERATE_BBOX) is True
def test_normal_bbox_is_kept():
"""守卫不能过严:正常的、不贴边的框必须仍然可用,否则粗居中彻底废掉。"""
assert _on_border(_Stub(), GOOD_BBOX) is False
def test_bbox_touching_any_single_edge_is_rejected():
"""四条边各自贴边都要挡住(只挡左边就漏掉了真机 coarse_move 1 那种整幅图)。"""
for bbox in (
(0, 100, 300, 300),
(100, 0, 300, 300),
(100, 100, FRAME_W - 1, 300),
(100, 100, 300, FRAME_H - 1),
):
assert _on_border(_Stub(), bbox) is True, f"{bbox} 贴边却没被挡住"
def test_border_margin_is_config_driven_not_hardcoded():
"""余量必须来自配置:margin=0 时 3px 贴边的框算贴边,margin 大时更靠内也算贴边。"""
assert _on_border(_Stub(border_px=2), (1, 100, 300, 300)) is True
assert _on_border(_Stub(border_px=0), (3, 100, 300, 300)) is False
assert _on_border(_Stub(border_px=8), (6, 100, 300, 300)) is True
def test_none_bbox_is_rejected():
assert _on_border(_Stub(), None) is True
# --------------------------------------------------------------------------- #
# 守卫二:目标点在移动前就被否掉(而不是"截断到盒边照样走")
# --------------------------------------------------------------------------- #
def test_old_behaviour_clamped_the_target_and_therefore_relocated_the_arm():
"""负向基线:证明"截断"不是安全兜底,而是一条真实的横移指令。
这条是"这个检查能红"的前提证明 —— 如果哪天 ``_clamp_ws`` 改成直接拒绝,
下面的用例就不是在测同一件事了。
"""
st = _Stub()
clamped = Stage2Locate._clamp_ws(st, BAD_TARGET_BASE) # type: ignore[arg-type]
assert clamped[1] == pytest.approx(CLAMPED_Y, abs=1e-6), "夹具前提变了"
moved_mm = float(np.hypot(clamped[0] - BAD_TARGET_BASE[0],
clamped[1] - BAD_TARGET_BASE[1])) * 1000.0
assert moved_mm > 190.0, (
f"截断只把目标挪了 {moved_mm:.1f}mm —— 与真机日志不符,用例已失真"
)
def test_bad_target_is_rejected_outside_workspace():
"""真机那一帧的目标 (0.280,-0.747) 在盒外(y < -0.55),必须判误检。"""
why = _reject(_Stub(), BAD_TARGET_BASE, GAUGE_XY)
assert why is not None, "盒外目标没被挡住 —— 就是它把臂带走 56mm 的"
assert "工作盒" in why, f"拒绝原因要说清是盒外(实际:{why})"
def test_bad_target_is_rejected_even_if_inside_workspace():
"""把目标夹进盒内之后,211.5mm 的偏移本身也必须挡住 —— 两条判据解耦。
这样即使以后放宽工作盒,第二道判据仍然拦得住"坏深度反投影出来的点"。
"""
inside = np.array([0.206, CLAMPED_Y, 0.320]) # 盒内(x/y 都在界内),但偏移 130mm
err = float(np.hypot(inside[0] - GAUGE_XY[0], inside[1] - GAUGE_XY[1]))
assert err > 0.12, "夹具前提变了:这个点已经不是大偏移"
assert WS_MIN[0] < inside[0] < WS_MAX[0] and WS_MIN[1] < inside[1] < WS_MAX[1]
why = _reject(_Stub(), inside, GAUGE_XY)
assert why is not None, f"盒内但偏移 {err*1000:.1f}mm 的目标漏过去了"
assert "上限" in why, f"拒绝原因要说清是超偏移上限(实际:{why})"
def test_good_target_is_accepted():
"""守卫不能过严:一阶段刚锁定时"臂在工件上方、差几厘米"的正常量必须放行。"""
p = np.array([GAUGE_XY[0] + 0.030, GAUGE_XY[1] + 0.012, 0.320])
assert _reject(_Stub(), p, GAUGE_XY) is None
def test_offset_limit_is_config_driven():
"""上限是配置项:放开后同一个点必须放行,否则阈值是写死的、配置等于没接。"""
assert _reject(_Stub(max_offset_m=0.12), BAD_TARGET_BASE, GAUGE_XY) is not None
# 偏移上限放到 10m ⇒ 只剩"盒外"这条在拦;说明两条判据不是同一条的复述
why = _reject(_Stub(max_offset_m=10.0), BAD_TARGET_BASE, GAUGE_XY)
assert why is not None and "工作盒" in why
def test_offset_guard_alone_blocks_a_point_that_is_inside_the_box():
"""只放宽工作盒(盒内)时,偏移这条必须独立生效。"""
wide = _Stub(ws_min=np.array([-9.0, -9.0, 0.0]), ws_max=np.array([9.0, 9.0, 9.0]))
why = _reject(wide, BAD_TARGET_BASE, GAUGE_XY)
assert why is not None and "上限" in why
def test_nonfinite_target_is_rejected():
assert _reject(_Stub(), [np.nan, -0.5, 0.32], GAUGE_XY) is not None
assert _reject(_Stub(), [0.3, np.inf, 0.32], GAUGE_XY) is not None
# --------------------------------------------------------------------------- #
# 接线:配置 + 调用点(写了键却没接线 = 等于没检查)
# --------------------------------------------------------------------------- #
def _site() -> dict:
assert SITE.is_file(), f"找不到站点配置 {SITE}"
return yaml.safe_load(SITE.read_text(encoding="utf-8"))
def _stage2_src() -> str:
p = ROOT / "src" / "arm_application" / "arm_application" / "stage2_locate_node.py"
assert p.is_file(), f"找不到 {p}"
return p.read_text(encoding="utf-8")
@pytest.mark.skip(reason="远程整站配置/硬件实现未同步;本地有独立回归测试")
def test_site_yaml_has_the_two_guard_keys():
# 路径与源码同源:stage2_locate_node 读的是 site["hole_detect"]["stage2"]
stage2 = (_site().get("hole_detect") or {}).get("stage2") or {}
assert "coarse_max_offset_m" in stage2, "site.yaml 没接 coarse_max_offset_m"
assert "coarse_border_px" in stage2, "site.yaml 没接 coarse_border_px"
off = float(stage2["coarse_max_offset_m"])
# 必须小于真机那次出事的 211.5mm、又大于合理粗居中的厘米级位移。
# 还要挡住 2026-10-16 的 110mm 回撤(0.12 放行了)。
assert 0.03 < off < 0.10, (
f"coarse_max_offset_m={off} 必须夹在'合理粗居中'与'110mm 回撤'之间"
)
def test_d435_handoff_110mm_reverse_is_rejected():
"""2026-10-16:D405 把法兰拉到 (0.569,-0.375),工件仍在 (0.512,-0.469)。
110mm 必须挡住。旧上限 0.12 会放行 —— 这条是「改了阈值真能红」的负向。
"""
gauge = np.array([0.569, -0.375])
workpiece = np.array([0.512, -0.469, 0.320])
err = float(np.hypot(workpiece[0] - gauge[0], workpiece[1] - gauge[1]))
assert 0.10 < err < 0.12, f"夹具前提变了:偏差是 {err*1000:.1f}mm 不是 ~110mm"
why = _reject(_Stub(max_offset_m=0.08), workpiece, gauge)
assert why is not None, "110mm 回撤漏过去了"
assert "上限" in why
assert _reject(_Stub(max_offset_m=0.12), workpiece, gauge) is None, (
"旧上限 0.12 必须放行这条,否则负向基线失真"
)
def test_best_bbox_wires_the_border_guard():
src = _stage2_src()
assert "self._bbox_on_border(bbox, w, h)" in src, "贴边守卫没接到 _best_bbox"
assert "def _bbox_on_border(" in src
# 旧写法不许回来:贴边的框直接当检测结果用
assert "return tuple(int(v) for v in best.xyxy)" not in src, (
"又直接采信检测框了(没有过贴边守卫)"
)
def test_coarse_loop_rejects_before_moving():
src = _stage2_src()
assert "self._coarse_target_reject(p_base, gxy)" in src, "误检守卫没接在移动前"
assert "def _coarse_target_reject(" in src
# 旧写法不许回来:拿孔检测器的整幅 bbox 当粗居中目标,然后照样移动
assert 'bbox = holes.get("bbox")' not in src, (
"又在咬孔检测器的 bbox 当粗居中目标了(真机那帧是整幅画面)"
)
assert "skip_coarse = True" in src, "'跳过粗居中'必须真的不移动"
coarse = src[src.find("# ---- 3) 粗居中") : src.find("# ---- 4)")]
assert "hole_visual_only" in coarse
assert "pixel_on_base_plane" in coarse
assert "拒绝用深度图凑框心" in coarse
assert 'src = "像素交工件面"' in coarse
def test_guard_reason_is_logged_with_both_numbers():
"""判误检必须把"目标点 vs 夹爪"两个数都喊出来,否则现场查不出是谁离谱。"""
src = _stage2_src()
assert "粗定位目标判误检" in src
assert "**不移动**" in src, "日志要说清处置方式(不移动),别让人以为它动了"
@@ -0,0 +1,645 @@
"""轻降的下降量必须由**几何**决定,深度测量只许做交叉核对。
两段真机事故,方向相反,都指向同一个结论:那套"规杆外环形"的间隙读数不能用。
--------------------------------------------------------------------------
2026-09-29(**多降**,撞工件)::
轻降准备: 规尖-工件间隙 141.0mm → 目标 2.0mm,需下降 139.0mm
按几何真值(悬停 0.320 − 工具长 0.245 − 工件面 0.022)尖端只在面上 **53mm**,
它多降约 86mm,把通止规撞进孔,工件被推走 5mm(k1 x: 0.4907 → 0.4967)。
2026-10-08(**少降**,永远到不了 2mm)::
轻降被几何夹逼:测量出的间隙 144.0mm 与几何不符(…= 75.0mm)
…
已达安全下限间隙 -7.0mm,停止下降(不接触)
=== 轻降完成: 规尖悬在孔面上方 ≈18.0mm (目标 2.0mm,不接触)===
这次读数反着错:悬停时报 144mm(几何真值 75mm),只降 24mm 后又翻成 **−7mm**,
``SAFE_FLOOR`` 于是把轻降停在离面约 50mm 处 —— **永远到不了 2mm**。
结论:同一套读数在两个方向上各错一次、幅度 150mm 量级、还随位姿乱跳。现在
``_descend_above_hole`` **只**用几何算下降量(法兰 FK − 工具长 − 工件面 − final_gap),
深度读数只在偏差 >10mm 时喊一声,**不参与任何决策**。安全由三条物理/几何机制保证:
1. 几何预算上限 ``descent_budget_m``(与 ``gauge_fsm.hover_z_guard`` 同源);
2. 腕部 FT 接触检测 ⇒ 停 + 回退 1mm;
3. 任一步 ``_goto`` 没走到位 ⇒ 停,保持当前高度。
这些用例全是**离线**的:``_descend_above_hole`` 只用到少量成员,用打桩对象即可,
不建任何 ROS 实体(与 ``test_stage2_no_follow`` 同一套做法)。
"""
from __future__ import annotations
from typing import Any, List, Optional
import numpy as np
import pytest
from arm_application.overhead_oneshot import ONCE_APPROACH_STEP_M
from arm_application.stage2_locate_node import (
Stage2Locate,
contact_armed_by_gap,
)
# 与真机那次一致的一组数(site.yaml 现行值)
_HOVER_Z = 0.320
_TOOL_LEN = 0.245
_SURFACE_Z = 0.022
_FINAL_GAP = 0.002
# ⇒ 几何间隙 = 0.320 − 0.245 − 0.022 = 53mm;需下降 = 51mm
_GAP0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
_BUDGET = _GAP0 - _FINAL_GAP
# 真机实测的两个坏读数(工件面被量成 242mm 而不是 ~175mm,差 86mm)
_BAD_Z_WP = 0.242
_TIP_D = 0.101
_BAD_GAP0 = _BAD_Z_WP - _TIP_D # = 141mm
class _StubLogger:
"""把日志写进调用方给的 sink —— 必须与 ``_StubStage2.logs`` 是**同一个** list,
否则断言永远看不到日志(踩过一次:两个 list 各写各的)。"""
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def _add(self, msg: str) -> None:
self._sink.append(str(msg))
info = _add
warn = _add
error = _add
class _StubStage2:
"""``_descend_above_hole`` 用到的成员全部打桩。
``z_wp`` 固定不变 = 复现"环形深度读数一直错/乱跳"的场景。
"""
def __init__(
self,
*,
z_wp: Optional[float] = _BAD_Z_WP,
budget: Optional[float] = _BUDGET,
tool_len: float = _TOOL_LEN,
surface_z: float = _SURFACE_Z,
contact_after_steps: Optional[int] = None,
zwp_slope: Optional[float] = None,
ft_fz: Optional[float] = 3.0,
) -> None:
self.logs: List[str] = []
self._logger = _StubLogger(self.logs)
self.final_descent_enable = True
self.final_gap_m = _FINAL_GAP
self.observe_gap_m = 0.015
self.final_descent_speed = 0.005
self.vert_tol_rad = 0.01
self.hover_z = _HOVER_Z
self.tool_tip_below_flange_m = tool_len
self.surface_z_m = surface_z
self.descent_budget_m = budget
self.descent_final_gap_m: Optional[float] = None
self._z_wp = z_wp
# zwp_slope ≠ None 时复现 2026-10-08 实测那种"读数随下降乱掉":
# 真实工件面深度只随下降 1:1 减小,但那个环形读数掉得**快很多倍**
# (实测:降 24mm 就从 +144mm 间隙翻到 −7mm)。斜率 6 正是这个倍率。
self._zwp_slope = zwp_slope
self._cur_z = _HOVER_Z
self._ft_fz = ft_fz
self._contact_after = contact_after_steps
# 记录每一次实际下发的下降步长,用来核对累计行程
self.steps_mm: List[float] = []
self.goto_hold_xy: List[bool] = []
self.pins = 0
# --- 被测函数会调到的钩子 ---
def _state(self, _name: str) -> None:
return None
def _measure_gauge_state(self) -> Any:
if self._zwp_slope is not None and self._z_wp is not None:
z_wp = self._z_wp - self._zwp_slope * (_HOVER_Z - self._cur_z)
else:
z_wp = self._z_wp
return _TIP_D, np.ones((4, 4), dtype=bool), z_wp
def _contact_detected(self, _base_fz: float) -> bool:
# ``contact_after_steps`` 步之后"碰到工件"(复现 FT 兜底)
return self._contact_after is not None and len(self.steps_mm) >= self._contact_after
def _pin(self, _q: Any) -> None:
self.pins += 1
def _goto(self, _q: Any, Tee: Any, target: Any, **_kw: Any) -> Any:
Tee = np.array(Tee, dtype=float)
self.goto_hold_xy.append(bool(_kw.get("hold_xy")))
step_mm = (Tee[2, 3] - float(target[2])) * 1000.0
# 记录"下降步"(正数);回退步(负数)不计入下降行程
if step_mm > 0.0:
self.steps_mm.append(step_mm)
if not _kw.get("hold_xy"):
Tee[0, 3] = float(target[0])
Tee[1, 3] = float(target[1])
Tee[2, 3] = float(target[2])
self._cur_z = float(target[2])
return True, np.zeros(7), Tee
def get_logger(self) -> Any:
return self._logger
def _run(st: _StubStage2) -> Any:
# ``_descend_above_hole`` 循环开头有 ``rclpy.ok()`` 兜底(运行期 Ctrl-C 时退出),
# 而 ``rclpy.ok()`` 在没 init 过时恒为 False —— 不初始化的话函数会在第一行就返回。
import rclpy
if not rclpy.ok():
rclpy.init()
Tee0 = np.eye(4)
Tee0[2, 3] = _HOVER_Z
ok, _q, Tee = Stage2Locate._descend_above_hole( # type: ignore[arg-type]
st, np.zeros(7), Tee0
)
return ok, Tee, st
# --------------------------------------------------------------------------- #
# 核心回归:坏读数**不能**再决定下降量(两个方向都不行)
# --------------------------------------------------------------------------- #
def test_bad_reading_no_longer_shortens_the_descent():
"""2026-10-08 的回归:读数随下降乱掉时,也要**降满几何预算**到 2mm。
旧逻辑里 ``SAFE_FLOOR`` 会被这个读数触发 —— 实测它降 24mm 就从 +144mm 翻到
−7mm(``zwp_slope`` 复现这个倍率),于是轻降被停在离面 ~50mm 处,**永远到不了
2mm**。这就是本次要修的现象。
"""
ok, Tee, st = _run(_StubStage2(zwp_slope=6.0))
assert ok is True
total_mm = sum(st.steps_mm)
assert abs(total_mm - _BUDGET * 1000) < 1.0, (
f"应降满几何预算 {_BUDGET * 1000:.1f}mm 到 2mm 目标,实际只降了 {total_mm:.1f}mm "
"—— 又被那个乱跳的读数叫停了"
)
gap_final_mm = (Tee[2, 3] - _TOOL_LEN - _SURFACE_Z) * 1000
assert abs(gap_final_mm - _FINAL_GAP * 1000) < 1.0, (
f"最终几何间隙 {gap_final_mm:.1f}mm,应当到 {_FINAL_GAP * 1000:.1f}mm"
)
assert Tee[2, 3] >= _SURFACE_Z + _TOOL_LEN, "规尖已低于工件面 —— 撞了"
def test_bad_reading_cannot_drive_it_into_the_workpiece():
"""2026-09-29 的回归:读数往**大**错时,下降量也不许超过几何预算。
那次读数报 141mm ⇒ 想降 139mm;真值只有 53mm ⇒ 多降 86mm 撞进工件。
"""
# 读数报 141mm,同时把 budget 也按真值给(51mm)
ok, Tee, st = _run(_StubStage2(z_wp=_BAD_Z_WP))
assert ok is True
total_mm = sum(st.steps_mm)
assert total_mm <= _BUDGET * 1000 + 0.5, (
f"下降了 {total_mm:.1f}mm > 几何预算 {_BUDGET * 1000:.1f}mm —— "
"又是那个「多降 86mm 撞工件」的 bug"
)
assert Tee[2, 3] >= _SURFACE_Z + _TOOL_LEN - 1e-9, (
f"法兰停在 {Tee[2, 3]:.4f},规尖 {Tee[2, 3] - _TOOL_LEN:.4f} 低于工件面 {_SURFACE_Z}"
)
def test_disagreement_is_announced_with_both_numbers():
"""深度与几何不符时必须喊出来,并把两个数都打出来。
否则现场只看到"下降量变了",查不出是测量坏了还是配置写错了 —— 而"测距方法
待修"这件事必须留在日志里,不能被静默吞掉。
"""
_ok, _Tee, st = _run(_StubStage2())
hit = [m for m in st.logs if "深度测距与几何不符" in m]
assert hit, "读数与几何差 88mm 却一声不吭 —— 静默比报错难查得多"
text = " ".join(hit)
assert f"{_BAD_GAP0 * 1000:.1f}mm" in text, "要打出测量值,才看得出它离谱"
assert f"{_GAP0 * 1000:.1f}mm" in text, "要打出几何值,才能核对配置"
assert "按几何下降" in text, "要说清本次是按几何走的(读数不参与决策)"
def test_missing_gauge_visibility_still_descends_on_geometry():
"""规不在视野(测不到)时**仍按几何下降** —— 下降量本来就不依赖规是否可见。
旧逻辑在这里直接"跳过轻降、停在悬停高",等于轻降功能随机失效。
"""
st = _StubStage2(z_wp=None) # 测不到工件面
ok, Tee, st = _run(st)
assert ok is True
total_mm = sum(st.steps_mm)
assert abs(total_mm - _BUDGET * 1000) < 1.0, "测不到就该按几何降满,而不是跳过"
assert any("深度核对跳过" in m for m in st.logs), "跳过了核对也要讲一声"
# --------------------------------------------------------------------------- #
# 缺几何 ⇒ 不许降(绝不用测距去赌)
# --------------------------------------------------------------------------- #
def test_missing_geometry_refuses_to_descend():
"""工具长/工件面没配时**必须不下降**并报错,不能退回"按测量降"。
2026-09-29 撞工件就是"没有几何、只有测距"时发生的。旧测试甚至把这个危险
行为钉成了"legacy 行为"(按测量降 139mm)—— 那条是错的,已改。
"""
st = _StubStage2(budget=None)
ok, Tee, st = _run(st)
assert ok is False, "缺几何必须判失败(保持悬停),不能假装完成"
assert not st.steps_mm, "缺几何时一步都不许降"
assert abs(Tee[2, 3] - _HOVER_Z) < 1e-9, "臂必须停在原位"
assert any("轻降缺少几何" in m for m in st.logs), "缺几何要说清缺什么、怎么补"
def test_zero_tool_length_refuses_to_descend():
"""工具长为 0(没标定)同样不许降 —— 那是"拿 0 当尺子"。"""
st = _StubStage2(tool_len=0.0)
ok, _Tee, st = _run(st)
assert ok is False and not st.steps_mm
# --------------------------------------------------------------------------- #
# 真安全机制仍在:FT 接触、goto 失败
# --------------------------------------------------------------------------- #
def test_ft_contact_stops_and_backs_off():
"""腕部力突变必须立即停并回退 —— 这是与测距无关的**物理**兜底。
力从第 8 步(间隙约 21mm)就超门槛,但门控是 ``final_gap+4mm``(6mm)。
21mm / 10mm 这一档必须继续降;进到 6mm 才许停,且不许把 2mm 目标走完。
"""
st = _StubStage2(contact_after_steps=8)
ok, Tee, st = _run(st)
assert ok is True, "接触后停住即算安全收工"
assert any("腕部力突变" in m for m in st.logs), "碰到工件却没喊"
assert any("不当作碰到" in m for m in st.logs), "21mm 间隙时力超了也要明说不当成碰到"
assert sum(st.steps_mm) > 32.0, "力从 21mm 间隙就热,必须穿过 10mm 假接触区"
assert sum(st.steps_mm) < _BUDGET * 1000 - 1.0, "力突变后还在继续降到 2mm"
assert Tee[2, 3] > _HOVER_Z - _BUDGET, "回退没生效"
def test_contact_not_armed_while_still_high_above_the_part():
"""远场 138mm 和近场 10mm / ΔFz≈3N 都不算碰到;6mm 才武装。"""
assert contact_armed_by_gap(0.1375, 0.015) is False
assert contact_armed_by_gap(0.020, 0.015) is False
assert contact_armed_by_gap(0.010, 0.015) is False
assert contact_armed_by_gap(0.0095, 0.015) is False
assert contact_armed_by_gap(0.006, 0.015) is True
assert contact_armed_by_gap(0.005, 0.015) is True
def test_lock_xy_descend_holds_xy_and_stops_at_final_gap():
"""锁 XY 实验:从悬停纯 Z 收到 2mm,XY 不许动,步长仍 20mm。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _StubStage2()
Tee0 = np.eye(4)
Tee0[0, 3] = 0.512
Tee0[1, 3] = -0.401
Tee0[2, 3] = _HOVER_Z
ok, _q, Tee = Stage2Locate._descend_above_hole( # type: ignore[arg-type]
st, np.zeros(7), Tee0, target_gap_m=_FINAL_GAP, step_m=ONCE_APPROACH_STEP_M
)
assert ok is True
gap = float(Tee[2, 3]) - _TOOL_LEN - _SURFACE_Z
assert abs(gap - _FINAL_GAP) < 0.001, f"应停在贴面 2mm,实际 {gap * 1000:.1f}mm"
assert Tee[0, 3] == 0.512
assert Tee[1, 3] == -0.401
assert max(st.steps_mm) <= ONCE_APPROACH_STEP_M * 1000 + 1e-6
assert all(st.goto_hold_xy), "下臂每一步都必须 hold_xy"
def _look_neg_x_T() -> np.ndarray:
T = np.eye(4)
T[:3, :3] = np.array(
[[0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]],
dtype=float,
)
return T
def test_once_ff_zero_k_is_cartesian_lock_xy_movel():
"""k=0 必须下发相对 MoveL,且 ΔXY=0、只降 Z。不许停在悬停高装成功。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st.once_ff_look_per_m = 0.0
st._lookup = lambda *_a, **_k: np.eye(4) # type: ignore[method-assign]
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - _FINAL_GAP
np.testing.assert_allclose(got[0], [0.580, -0.460, 0.096 - descent], atol=1e-9)
assert any("笛卡尔锁XY只降Z" in m for m in st.logs)
def _ff_stub_ready() -> _StubStage2:
st = _StubStage2()
st.once_ff_look_per_m = 0.0
st._tip_off = np.array([-0.001, -0.001], dtype=float)
st.cam_frame = "cam"
st.base_frame = "base"
st._ws_clipped = False
st._lookup = lambda *_a, **_k: _look_neg_x_T() # type: ignore[method-assign]
st._clamp_ws = lambda *_a, **_k: None # type: ignore[method-assign]
st._spin = lambda *_a, **_k: None # type: ignore[method-assign]
st._wait_state = lambda *_a, **_k: (None, None) # type: ignore[method-assign]
st.robot_states_topic = "/Rizon4_063989/flexiv_robot_states"
return st
def test_once_ff_refuses_without_tcp_pose():
"""没有厂家 tcp_pose 不许退回 ROS 绝对坐标。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st._fresh_tcp_world = lambda: None # type: ignore[method-assign]
called: list = []
st._vendor_movel_tcp = lambda *_a, **_k: called.append(1) or True # type: ignore[method-assign]
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is False
assert called == []
assert Tee1[2, 3] == pytest.approx(_HOVER_Z)
assert any("没有厂家 tcp_pose" in m for m in st.logs)
def test_once_ff_movel_is_flexiv_tcp_plus_delta():
"""MoveL 必须是当前厂家 TCP + (ΔXY, −下降),不是 ROS 规尖绝对坐标。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - _FINAL_GAP
np.testing.assert_allclose(got[0], [0.580, -0.460, 0.096 - descent], atol=1e-9)
assert got[0][0] == pytest.approx(0.580)
assert not np.allclose(got[0][:2], [0.595 - 0.001, -0.467 - 0.001], atol=1e-4)
def test_once_ff_custom_final_gap_only_changes_z():
"""--final-gap 8mm:MoveL XY 仍为 0,下降量必须少 6mm,不许再走贴面 2mm。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
gap_custom = 0.008
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=gap_custom
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - gap_custom
descent_2mm = gap0 - _FINAL_GAP
np.testing.assert_allclose(got[0], [0.580, -0.460, 0.096 - descent], atol=1e-9)
assert got[0][0] == pytest.approx(0.580)
assert got[0][1] == pytest.approx(-0.460)
assert descent == pytest.approx(descent_2mm - 0.006)
assert any("笛卡尔锁XY只降Z" in m for m in st.logs)
def test_once_ff_k_018_still_adds_look_xy():
"""有人把 k 改回 0.018 时,MoveL 必须真的加 +k×下降,不许再吞成绝对坐标。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st.once_ff_look_per_m = 0.018
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - _FINAL_GAP
dxy = 0.018 * descent
np.testing.assert_allclose(got[0], [0.580 + dxy, -0.460, 0.096 - descent], atol=1e-9)
assert any("视线前馈斜线" in m for m in st.logs)
def test_oneshot_approach_stops_at_observe_gap_with_larger_steps():
"""单次精拍第一段只降到 15mm,步长 20mm(约 3 步),不许 4mm 切到 2mm。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _StubStage2()
Tee0 = np.eye(4)
Tee0[2, 3] = _HOVER_Z
ok, _q, Tee = Stage2Locate._descend_above_hole( # type: ignore[arg-type]
st, np.zeros(7), Tee0, target_gap_m=0.015, step_m=ONCE_APPROACH_STEP_M
)
assert ok is True
gap = float(Tee[2, 3]) - _TOOL_LEN - _SURFACE_Z
assert abs(gap - 0.015) < 0.001, f"应停在观察高 15mm,实际 {gap * 1000:.1f}mm"
assert max(st.steps_mm) <= ONCE_APPROACH_STEP_M * 1000 + 1e-6
assert len(st.steps_mm) <= 3
assert abs(sum(st.steps_mm) - (_GAP0 - 0.015) * 1000) < 1.5
def test_oneshot_approach_does_not_treat_force_as_contact():
"""观察高 15mm 仍在接触门控之外,力再热也不许当碰到。"""
class _AlwaysHot(_StubStage2):
def _contact_detected(self, _base_fz: float) -> bool:
return True
import rclpy
if not rclpy.ok():
rclpy.init()
st = _AlwaysHot()
Tee0 = np.eye(4)
Tee0[2, 3] = _HOVER_Z
ok, _q, Tee = Stage2Locate._descend_above_hole( # type: ignore[arg-type]
st, np.zeros(7), Tee0, target_gap_m=0.015, step_m=ONCE_APPROACH_STEP_M
)
assert ok is True
gap = float(Tee[2, 3]) - _TOOL_LEN - _SURFACE_Z
assert abs(gap - 0.015) < 0.001
assert not any("腕部力突变" in m for m in st.logs)
def test_ft_drift_far_from_surface_does_not_stop_on_the_first_step():
"""离面还远时力一直超门槛,也要先降到门控间隙附近才许停。"""
class _AlwaysHot(_StubStage2):
def _contact_detected(self, _base_fz: float) -> bool:
return True
ok, _Tee, st = _run(_AlwaysHot())
assert ok is True
assert sum(st.steps_mm) >= 40.0, "离面 53mm 时力超门槛也不该第一步就停"
assert any("不当作碰到" in m for m in st.logs)
assert any("腕部力突变" in m for m in st.logs)
def test_every_step_logs_the_ft_force():
"""每一步都要打出 FT 轴向力 —— 这是"停住是碰到还是没跟上"的唯一直接证据。
2026-10-08 排查"轻降在离面 ~4mm 处停住"时全靠这一行;没有它只能猜。
"""
_ok, _Tee, st = _run(_StubStage2())
ft_lines = [m for m in st.logs if "FT_fz=" in m]
assert ft_lines, "每步的 FT 读数没打进日志 —— 排查时无从判断是否接触"
assert any("基线" in m for m in ft_lines), "要给出基线与增量,才看得出力有没有变"
def test_missing_ft_baseline_is_announced():
"""轻降开始前没收到 FT 时要明说'接触兜底不生效'(不许静默少一道保险)。"""
_ok, _Tee, st = _run(_StubStage2(ft_fz=None))
assert any("接触兜底不生效" in m for m in st.logs), (
"FT 没数据却一声不吭 —— 会让人以为有接触兜底"
)
def test_goto_failure_stops_the_descent():
"""某一步走不到位(被挡/碰到东西)⇒ 停,保持当前高度,并判失败。"""
st = _StubStage2()
# 从第 3 步起 _goto 报失败
orig = st._goto
calls = {"n": 0}
def _goto(q, Tee, target, **kw):
calls["n"] += 1
if calls["n"] >= 3:
return False, q, Tee
return orig(q, Tee, target, **kw)
st._goto = _goto # type: ignore[assignment]
ok, Tee, st = _run(st)
assert ok is False, "走不到位必须判失败(安全优先)"
assert any("轻降中途未到位" in m for m in st.logs)
assert Tee[2, 3] > _SURFACE_Z + _TOOL_LEN, "走不到位时也不许低于工件面"
# --------------------------------------------------------------------------- #
# 不许回退到"把目标值当结果汇报"
# --------------------------------------------------------------------------- #
def test_no_descent_still_drops_to_the_observe_gap():
"""--no-descent 不是停在悬停高:要降到观测间隙(15mm),方便看规尖偏没偏。
不许降到贴面 2mm —— 对偏了再贴面会把规顶在孔沿上。
"""
st = _StubStage2()
st.final_descent_enable = False
st.observe_gap_m = 0.015
ok, Tee, st = _run(st)
assert ok is True
gap_mm = (Tee[2, 3] - _TOOL_LEN - _SURFACE_Z) * 1000
assert abs(gap_mm - 15.0) < 1.0, f"观测下降应停在离面 15mm,实际 {gap_mm:.1f}mm"
total_mm = sum(st.steps_mm)
assert total_mm < _BUDGET * 1000 - 5.0, "观测下降走到了贴面预算,等于把 --no-descent 吃掉了"
assert any("观测下降完成" in m for m in st.logs), "日志要写明这是观测下降,不是贴面轻降"
def test_wired_final_gap_is_reported_from_geometry_not_target():
"""收尾要汇报**几何实算**的间隙,不能把目标值(2.0mm)念一遍。
旧日志就出现过"停在离面 18mm"却同时打印"悬在孔面上方 ~2.0mm",现场会以为
轻降做到了(R1.1:不许把没做到说成做到)。
"""
_ok, _Tee, st = _run(_StubStage2())
assert st.descent_final_gap_m is not None, "没记录最终间隙"
assert abs(st.descent_final_gap_m * 1000 - _FINAL_GAP * 1000) < 1.0
assert any("轻降完成" in m and "几何" in m for m in st.logs), (
"收尾日志要标明这是几何量,且给出可核对的分量"
)
def test_source_no_longer_uses_depth_to_stop_the_descent():
"""守卫测试:源码里不许再出现"用深度间隙叫停轻降"的逻辑。
这是**反向**证据 —— 只要有人把 SAFE_FLOOR / _measure_z_wp 叫停逻辑加回来,
这条立刻红(比"单元测试碰巧还能过"更直接)。用语法树判据(不是文本匹配),
这样解释这件事的注释/文档字符串不会把它误判成"逻辑又回来了"。
"""
import ast
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
/ "arm_application"
/ "stage2_locate_node.py"
).read_text(encoding="utf-8")
tree = ast.parse(src)
fn = next(
n
for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name == "_descend_above_hole"
)
names = {n.id for n in ast.walk(fn) if isinstance(n, ast.Name)}
attrs = {n.attr for n in ast.walk(fn) if isinstance(n, ast.Attribute)}
assert "SAFE_FLOOR" not in names, "深度安全下限又回来了 —— 它就是那个误停的元凶"
assert "_measure_z_wp" not in attrs, "又在循环里重测深度当停止条件了"
# 交叉核对必须仍在(不是把测量悄悄删掉了)
assert "_measure_gauge_state" in attrs, "连交叉核对都没了 —— 那就没法发现测距坏了"
@@ -0,0 +1,168 @@
"""臂没跟随指令时,报错必须指向**连接**,不能甩锅给几何。
为什么单独钉住(2026-09-28 真机实测)
--------------------------------------
真机跑到 S05 精拍定位时反复失败,报的是
``code=2(垂直化或悬停失败:工具轴没竖直、或悬停高度到不了)``,
看着像标定/几何/悬停高度的问题。实际日志里是:
plan hover Δ=(+0,+0,-80)mm tilt=0.12deg
send hover: 114 pts T=4.37s ... max|Δq|=0.17378rad
hover hop1 实际 max|Δq|=0.00000rad Δz=+0.00mm
hover hop2 实际 max|Δq|=0.00000rad Δz=+0.00mm
指令要求把法兰挪 0.17378 rad,实测**一动不动**。真因是 RDK 与机器人的连接在
14:53:44 就断了(``Disconnected from the robot, waiting for reconnection``),
此后 ``robot_->states()`` 只会返回最后一帧缓存、``robot_timestamp`` 推进量恒为 0
(joint_states 因此永久冻结),而 ``rizon_arm_controller`` 是**本地插值**、
不校验硬件反馈,所以照样打印 ``Goal reached, success!``。
最坑的一点:整条链路**没有一个环节报错** —— 断连不报、写命令不报、控制器报成功。
只有"实测位移为 0"这一个信号是真的。所以这个信号必须自己喊出来,并且喊对方向
(去查网络/连接),否则下一次仍然会被"垂直化或悬停失败"带去调标定。
顺带一提:``_goto`` 里那个 ``stall`` 逻辑本来就会因"连续 2 hop 无进展"而放弃,
所以**不会**无限转下去;缺的只是**说清原因**。
"""
from __future__ import annotations
from typing import Any, List
import numpy as np
from arm_application.stage2_locate_node import Stage2Locate
_NO_FOLLOW_HINT = "臂没有跟随指令"
_NO_FOLLOW_SIGN = "关节状态**毫无变化**"
# 与真机那次同量级:悬停要往下 197mm,容差 0.7mm。
_EE_Z = 0.444
_TARGET_Z = 0.247
_TOL_M = 0.0007
_TOL_TILT = 0.01
class _StubLogger:
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def info(self, msg: str) -> None:
self._sink.append(msg)
def warn(self, msg: str) -> None:
self._sink.append(msg)
def error(self, msg: str) -> None:
self._sink.append(msg)
class _StubStage2:
"""``_goto`` 只用这几样东西;测试里不建任何 ROS 实体。
``follows=False``:模拟"指令发了、关节状态冻住" —— ``_wait_state`` 每次都
回同一份 (q, Tee),就像真机上读到的最后一帧缓存。
``follows=True``:正常跟随,用于确认不会误报。
"""
def __init__(self, follows: bool) -> None:
self.logs: List[str] = []
self.sends = 0
self._follows = bool(follows)
self._calls = 0
def _clamp_ws(self, target: Any) -> np.ndarray:
return np.array(target, dtype=float)
def _spin(self, _dt: float = 0.05) -> None:
return None
def _plan_and_send(self, *_a: Any, **_kw: Any) -> bool:
self.sends += 1
return True
def _wait_state(self, _timeout: float) -> Any:
self._calls += 1
Tee = np.eye(4)
Tee[0, 3] = 0.0
Tee[1, 3] = 0.0
q = np.zeros(7)
if self._follows:
# 每次调用都比上一次更靠近目标:模拟"真的在走"。
# 注意必须**逐次递进** —— 若每次都回同一份"动过"的状态,第二次相对
# 第一次就没变化了,会被判成没跟随(这正是打桩要避免的假象)。
Tee[2, 3] = _EE_Z - 0.03 * self._calls
q[1] = 0.05 * self._calls
else:
# 冻结在最后一帧缓存:每次都一样,就像真机断连后读到的那样。
Tee[2, 3] = _EE_Z
return q, Tee
def get_logger(self) -> Any:
return _StubLogger(self.logs)
def _run(follows: bool) -> Any:
st = _StubStage2(follows)
Tee0 = np.eye(4)
Tee0[2, 3] = _EE_Z
ok, _q, _Tee = Stage2Locate._goto( # type: ignore[arg-type]
st,
np.zeros(7),
Tee0,
np.array([0.0, 0.0, _TARGET_Z]),
False, # verticalize:本用例不碰姿态,只看"臂动没动"
_TOL_M,
_TOL_TILT,
"hover",
0.022,
max_hops=2,
settle_s=0.0,
min_dur=0.0,
)
return ok, st
def test_frozen_joint_state_is_reported_as_no_follow():
"""实测位移为 0 → 必须出一条指向"连接/关节状态"的错,而不是只说容差没到。"""
ok, st = _run(follows=False)
assert st.sends == 2, "无进展会连着 hop 两次才放弃(stall 判据)"
assert ok is False, "臂没跟上就不该报成功"
assert any(_NO_FOLLOW_SIGN in m for m in st.logs), (
"要有一条明确说'关节状态毫无变化'的日志,否则查日志时会以为臂动过"
)
assert any(_NO_FOLLOW_HINT in m for m in st.logs), (
"错因必须指向'臂没有跟随指令',不能只停在'悬停未达容差'"
)
hint = " ".join(m for m in st.logs if _NO_FOLLOW_HINT in m)
for kw in ("RDK", "断连", "ping", "MAC"):
assert kw in hint, f"提示里要给出可执行的排查方向(缺 {kw})"
def test_normal_follow_does_not_emit_the_no_follow_error():
"""正常跟随时不能误报 —— 这条错一旦刷出来就会把人带去查网络。"""
_ok, st = _run(follows=True)
assert st.sends >= 1, "至少得真的发过轨迹"
assert not any(_NO_FOLLOW_HINT in m for m in st.logs), (
"臂动了还报'没跟随'就是误报,比不报更糟"
)
def test_eps_is_small_enough_to_not_swallow_real_motion():
"""阈值必须小于"真动了一下"的量级,否则真实的小位移会被误判成没动。"""
from arm_application.stage2_locate_node import _NO_FOLLOW_EPS_RAD
assert _NO_FOLLOW_EPS_RAD > 0.0
# 真机一次 hop 计划 0.17378rad;即使只走到 1%,也远大于阈值。
assert _NO_FOLLOW_EPS_RAD < 0.17378 * 0.01, (
"阈值取大了会把'动了一点点'误判成'完全没动'"
)
def test_hint_mentions_the_controller_still_reports_success():
"""要点破"控制器仍报 success"这个反直觉点,否则下一轮还会被日志骗。"""
_ok, st = _run(follows=False)
hint = " ".join(m for m in st.logs if _NO_FOLLOW_HINT in m)
assert "success" in hint, "真机现象就是'控制器报 success 但臂没动',得写清楚"
@@ -0,0 +1,742 @@
"""精对准的「工具位置」必须取自**视觉量到的规尖**,不能再用法兰轴线。
为什么(2026-10-08 真机 arm 89)
--------------------------------
规是**夹在爪里**的零件,它相对法兰轴线可以整体偏出约 10mm。实测(同一帧、同一
条手眼链,n=8):
* 规杆轴线相对法兰 = **(+10.7, −0.1) mm**(σ≈0.5mm);最深像素法 (+8.8, −1.8) mm
* 而 ``hover_offset_ee_xy_m`` 写的是 ``[0, 0]``("平行爪同轴")⇒ 精对准把**法兰**
对到孔上 ⇒ 规尖实际离孔心 ~10mm ⇒ 轻降时 φ6 规尖骑在 φ7 孔的**孔沿**上,
腕力从 −1N 蹦到 −31N→−63N 卡死。现场看到的是"轻降在离面 4mm 停住"。
而孔检测本身重复性极好(k1 基座坐标 σ=(0.17,0.04)mm)—— 精度不是瓶颈,
"对的是谁"才是。所以改成在同一帧里同时解「规尖」与「孔」,两者共用手眼链。
真机复跑还暴露了第二件事:**工具长读数会跳**(192~223mm)——规尖细、反光,掠射角
下深度容易丢,丢了以后"最深有效像素"往上跑、工具长只会变短。逐拍重算偏移会把
"规杆略有倾斜"引入的 ±0.2~0.4mm 抖动带进残差,残差在 0.44~0.85mm 之间来回荡、
收不进 0.25mm。偏移是**刚体量**,所以**量准一份就冻结**,并且只取"端头看得全"
(工具长最长)那一簇。
这些用例全是**离线**的:只用到少量成员,用打桩对象即可。
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
import numpy as np
import pytest
from arm_application.stage2_locate_node import Stage2Locate
_INTR = {"fx": 400.0, "fy": 400.0, "cx": 400.0, "cy": 240.0}
_TEE = np.eye(4)
_TEE[0, 3] = 0.50
_TEE[1, 3] = -0.10
_TEE[2, 3] = 0.55
# 相机朝下、相对竖直前倾约 30°(≈真机那个 29.7° 的视角)。
# 用旋转而不是纯平移——纯平移会让"更深"在基座里变成**更长**的工具长,与真机相反,
# 那样这条链就测不出"端头丢像素 ⇒ 工具长变短"这件事。
_R_CB = np.array([[1.0, 0.0, 0.0], [0.0, -0.866, -0.5], [0.0, 0.5, -0.866]])
_T_CB = np.array([0.50, -0.10, 0.45])
def _rod_frame(v1: int = 300, u0: int = 400, v0: int = 100, near: float = 0.06,
far: float = 0.12, half: int = 8, w: int = 800, h: int = 480) -> np.ndarray:
"""造一帧含"规杆"的深度图:中央竖带,深度随 v 线性变深(越靠下越深)。
深度只随 ``v`` 的**绝对位置**变(用固定的 200px 量程),所以 ``v1`` 截短 = 规尖
那几个像素丢了 —— 正好复现真机那个"最深有效像素往上跑、工具长变短"。
"""
depth = np.full((h, w), 0.50, np.float32)
for v in range(v0, v1 + 1):
f = min(1.0, (v - v0) / 200.0)
depth[v, u0 - half : u0 + half] = near + f * (far - near)
return depth
class _StubLogger:
def __init__(self) -> None:
self.msgs: list = []
def _add(self, msg: str) -> None:
self.msgs.append(str(msg))
info = _add
warn = _add
error = _add
class _Stub:
"""被测方法用到的成员。"""
tip_min_m = 0.04
tip_max_m = 0.60
tip_near_m = 0.15
tip_band_frac = 0.25
tip_min_px = 150
tip_len_min = 0.15
tip_len_max = 0.32
tip_off_tol = 0.003
tip_calib_n = 4
tool_tip_below_flange_m = 0.19
def __init__(self, t_cb: Tuple[float, float, float] = tuple(_T_CB)) -> None:
self._t_cb = np.array(t_cb, float)
self._tip_off: Optional[np.ndarray] = None
self._tip_samp: list = []
self._tip_len_m: Optional[float] = None
self._logger = _StubLogger()
def get_logger(self) -> Any:
return self._logger
# 真实实现(纯数学)——换掉就等于没测到那条链
def _px_to_cam(self, u: float, v: float, z: float, intr: Dict[str, float]) -> np.ndarray:
return Stage2Locate._px_to_cam(self, u, v, z, intr) # type: ignore[arg-type]
def _cam_to_base(self, p_cam: np.ndarray) -> np.ndarray:
return _R_CB @ np.asarray(p_cam, float) + self._t_cb
def _call(st: _Stub, depth: np.ndarray, z_wp_cam: Optional[float] = None):
return Stage2Locate._tip_offset_from_depth( # type: ignore[arg-type]
st, depth, _INTR, _TEE, z_wp_cam=z_wp_cam
)
def _feed(st: _Stub, depth: np.ndarray) -> Tuple[Optional[np.ndarray], Optional[float]]:
"""走一遍"量一帧 → 攒样本 → 试冻结"的完整流程。"""
off, tlen, _why = _call(st, depth)
if off is not None and tlen is not None:
st._tip_samp.append((off, tlen))
Stage2Locate._refresh_tip_offset(st) # type: ignore[arg-type]
return off, tlen
# --------------------------------------------------------------------------- #
# 正常路径
# --------------------------------------------------------------------------- #
def test_measures_the_rod_tip_offset():
"""规杆在中央、深度合理时,要真的量出「规尖相对法兰」的基座 XY 偏移 + 工具长。"""
off, tlen, why = _call(_Stub(), _rod_frame())
assert off is not None and tlen is not None, f"没量到规尖:{why}"
# 规杆在 u=400=cx 的正中央 ⇒ 基座 X 偏移应≈0
assert abs(float(off[0])) < 0.001, f"规杆居中,X 偏移应≈0,实际 {off[0]*1000:+.2f}mm"
assert abs(float(off[1])) < 0.15, f"Y 偏移 {off[1]*1000:.1f}mm 离谱"
assert 0.15 <= tlen <= 0.32, f"工具长 {tlen*1000:.0f}mm 不在合法区间"
assert "工具长" in why, "说明里要给出反投影出的工具长(可核对)"
def test_offset_is_stable_frame_to_frame():
"""同一姿态连拍:偏移是刚体量,重复帧之间必须几乎不动。"""
st = _Stub()
off1, _t1, _ = _call(st, _rod_frame())
assert off1 is not None
off2, _t2, why = _call(st, _rod_frame())
assert off2 is not None, f"重复帧被误判:{why}"
assert float(np.linalg.norm(off2 - off1)) < 1e-6
# --------------------------------------------------------------------------- #
# 判据各自都要能真的失败(负向用例)
# --------------------------------------------------------------------------- #
def _gripper_and_rod_frame() -> np.ndarray:
"""爪子块头大且更近 + 规杆更细更深:复现 2026-10-09 真机『按面积选爪子』。"""
depth = np.full((480, 800), 0.50, np.float32)
depth[20:90, 330:470] = 0.07 # 爪子:宽、浅、画面上方
for v in range(90, 280):
f = min(1.0, (v - 90) / 200.0)
depth[v, 392:408] = 0.08 + f * 0.05 # 规杆:细、伸向工件
return depth
def test_picks_the_lower_tip_not_the_config_length_blob():
"""画面上方、工具长刚好贴着配置的那一块不是规尖。
2026-10-09 真机:最接近配置 223mm 的点工具长 225mm、偏移约 0,冻住的是
法兰轴线。残差 0.14mm 达标,下降后规尖仍偏出约 10mm。规尖在画面更靠下。
"""
depth = np.full((480, 800), 0.50, np.float32)
depth[40:100, 370:430] = 0.080 # 靠上、居中,工具长贴着配置
for v in range(200, 301):
f = min(1.0, (v - 200) / 200.0)
depth[v, 442:458] = 0.10 + f * 0.02 # 靠下、偏出中心
st = _Stub()
st.tool_tip_below_flange_m = 0.185
off, tlen, why = _call(st, depth)
assert off is not None and tlen is not None, f"有规尖却没量到:{why}"
v = float(why.split("px=(")[1].split(")")[0].split(",")[1])
assert v > 180, f"采到的是贴着配置工具长的上方点,不是规尖:{why}"
assert abs(float(off[0])) > 0.005, (
f"规尖偏出中心,X 偏移不该≈0(那是法兰轴):{why} off={off}"
)
def test_picks_rod_not_larger_gripper():
"""中央竖带里爪子面积更大时,必须仍选**更深的规杆**,不能按连通域面积。"""
depth = _gripper_and_rod_frame()
off, tlen, why = _call(_Stub(), depth)
assert off is not None and tlen is not None, f"有规杆却没量到:{why}"
assert "px=" in why
# 规尖在杆下端 v≈270,爪子在 v≈20–90;采到爪子 v 会 <100
v = float(why.split("px=(")[1].split(")")[0].split(",")[1])
assert v > 150, f"采到的是爪子不是规尖:{why}"
def test_workpiece_ceiling_keeps_the_real_tip():
"""同一帧工件面深度必须抬高近端上限,否则 150mm 会切掉真规尖。
2026-10-09 真机:孔 camera-Z ≈168mm,固定 tip_near=150mm 把尖切掉,
像素在 v=80(杆上半)和 v=188(尖偶尔漏进来)两档跳,6 拍冻不住。
上限要留在工件面前方(8mm):贴着面的像素是工件,不是规。
再往前砍 15mm 会把只比面近十来毫米的真尖切掉。
"""
depth = np.full((480, 800), 0.50, np.float32)
for v in range(80, 200):
depth[v, 392:408] = 0.08 + (v - 80) / 120.0 * 0.06 # →0.14,在 150mm 内
for v in range(200, 280):
depth[v, 392:408] = 0.14 + (v - 200) / 80.0 * 0.024 # →0.164,会被 150mm 切掉
depth[300:400, 200:600] = 0.172 # 工件面;近端上限 = 面 − 15mm
cut, _t0, why0 = _call(_Stub(), depth) # 无 z_wp → 固定 150mm
full, tlen, why = _call(_Stub(), depth, z_wp_cam=0.172)
assert full is not None and tlen is not None, f"给了工件面仍没量到尖:{why}"
v_full = float(why.split("px=(")[1].split(")")[0].split(",")[1])
assert v_full > 200, f"有工件面上限仍没采到杆下端:{why}"
if cut is not None:
v_cut = float(why0.split("px=(")[1].split(")")[0].split(",")[1])
assert v_full > v_cut + 20, (
f"工件面上限没有比固定 150mm 采到更靠下的尖:cut={why0} full={why}"
)
def test_keeps_the_tip_when_it_is_only_slightly_in_front_of_the_surface():
"""规尖只比工件面近十来毫米时,必须采尖,不能退回更近的夹爪。
2026-10-09 观测高度实测:工件面相机深度 127mm,规尖 115mm,夹爪/杆身 80mm。
近端砍到「面 − 15mm」会把尖切掉,冻住的是杆身(偏移约 2mm、工具长贴着
配置 223mm)。残差 0.2mm 达标,针却偏在孔沿上。
"""
depth = np.full((480, 800), 0.50, np.float32)
depth[140:190, 360:430] = 0.080 # 夹爪:更近、更靠上
depth[210:236, 430:470] = 0.118 # 规尖:只比面近 12mm,画面更靠下
depth[250:420, 250:600] = 0.130 # 工件面
off, tlen, why = _call(_Stub(), depth, z_wp_cam=0.130)
assert off is not None and tlen is not None, f"只比面近 12mm 的尖被切掉了:{why}"
v = float(why.split("px=(")[1].split(")")[0].split(",")[1])
assert v > 200, f"采到的是夹爪不是规尖:{why}"
assert abs(float(off[0])) > 0.005, (
f"尖偏出中心,不该量成法兰轴:{why} off={off}"
)
def test_rejects_pixels_stuck_to_the_workpiece():
"""贴着工件面的近端块必须拒绝 —— 那是面,不是规尖。
2026-10-09 重跑:上限只让 3mm,噪声把工件漏进来,工具长测成 296–315mm
(规实际约 190mm),样本互斥,随后两孔中点一跳就被当成工件被推走。
"""
depth = np.full((480, 800), 0.50, np.float32)
depth[200:420, 360:440] = 0.170 # 大块、深度贴着 0.172 的工件面
off, tlen, why = _call(_Stub(), depth, z_wp_cam=0.172)
assert off is None and tlen is None, f"贴着工件面的块被当成规尖:{why}"
assert "工件" in why
def test_rejects_tip_that_lands_on_the_workpiece_surface():
"""反投影落在工件面上的点必须丢掉,哪怕相机深度还在近端上限之内。
2026-10-09:相机深度留了 15mm,工具长仍测成 298mm(配置 223mm),
基座 z 贴在工件面上。偏移冻不住 → exit=8 → 观测下降根本不会执行。
"""
st = _Stub()
st.surface_z_m = 1.0 # 高于任何反投影点
off, tlen, why = _call(st, _rod_frame())
assert off is None and tlen is None, f"贴在工件面上的点被当成规尖:{why}"
assert "工件" in why
def test_keeps_tip_that_clears_the_workpiece_surface():
"""规尖明显高于工件面时,表面守卫不许把真规尖一起丢掉。"""
st = _Stub()
st.surface_z_m = 0.05
off, tlen, why = _call(st, _rod_frame())
assert off is not None and tlen is not None, f"离面足够却被拒:{why}"
def test_rejects_when_nothing_is_near():
"""近端什么都没有(规不在视野)⇒ 必须判失败,不能返回一个假偏移。"""
depth = np.full((480, 800), 0.50, np.float32) # 全在远端
off, tlen, why = _call(_Stub(), depth)
assert off is None and tlen is None, "近端没物体却量出了偏移 —— 那是拿台面当规"
assert "近端" in why
def test_rejects_when_rod_is_outside_the_center_band():
"""物体在画面边上(不是夹在爪里的规)⇒ 必须拒绝。"""
off, _tlen, why = _call(_Stub(), _rod_frame(u0=60))
assert off is None, "画面边上的东西不是规,不许采信"
assert "近端" in why
def test_rejects_implausible_tool_length():
"""反投影出的工具长荒谬(量到台面/工件)⇒ 必须拒绝。
把相机系平移的 z 抬到 0.75 ⇒ 反投影的基座 z 高于法兰 ⇒ 工具长为负。
"""
st = _Stub(t_cb=(0.50, -0.10, 0.75))
off, _tlen, why = _call(st, _rod_frame())
assert off is None, "工具长为负也敢采信 —— 会把臂往工件里带"
assert "工具长" in why and "不是规" in why
# --------------------------------------------------------------------------- #
# 冻结:偏移是刚体量,量准一次就定住
# --------------------------------------------------------------------------- #
def test_freezes_offset_after_consistent_samples():
"""攒够一致样本后必须**冻结**,且冻结后不再被后续样本改动。
为什么要冻结:逐拍重算会把"规杆略有倾斜"引入的 ±0.2~0.4mm 抖动带进残差
(2026-10-08 真机残差 0.44~0.85mm 来回荡、收不进 0.25mm)。
"""
st = _Stub()
for _ in range(st.tip_calib_n):
_feed(st, _rod_frame())
assert st._tip_off is not None, "样本一致却没冻结"
frozen = st._tip_off.copy()
assert any("已冻结" in m for m in st._logger.msgs), "冻结了要讲一声(否则现场不知道基准何时定的)"
for _ in range(3): # 再喂几帧:冻结后不许改
_feed(st, _rod_frame(u0=402))
assert np.allclose(st._tip_off, frozen), "冻结后又被改了 —— 那冻结就没意义"
def test_does_not_freeze_when_samples_disagree_without_a_majority():
"""样本分成**势均力敌的两派**时绝不许冻结 —— 宁可继续用退路 + 报失败。
(若有一派能凑够 ``tip_calib_n`` 拍一致,那按多数派冻结是对的:外点就是靠这个
剔掉的 —— 见 test_freezes_offset_after_consistent_samples。)
这是 2026-10-08 那次真机"残差 0.15mm 的假达标"的守卫:只要偏移量不准,
对准结果就必须判不可信(见 test_success_is_refused_without_tip_offset)。
"""
st = _Stub()
good = _rod_frame() # 偏移 ≈ x=0
far = _rod_frame(u0=470) # 挪到别的 u ⇒ x 偏移差 ~21mm
for i in range(st.tip_calib_n): # 各一半,谁都不够多数派
_feed(st, good if i % 2 == 0 else far)
assert st._tip_off is None, "两派势均力敌(各 2 拍)竟然冻住了 —— 定了个假基准"
def test_reports_tool_length_from_the_consistent_samples():
"""冻结时要并记工具长,并在与 site.yaml 差 >3mm 时告警(夹持变动的唯一线索)。"""
st = _Stub()
st.tool_tip_below_flange_m = 0.30 # 故意与量到的 ~0.19m 差很多
for _ in range(st.tip_calib_n):
_feed(st, _rod_frame())
assert st._tip_off is not None
assert st._tip_len_m is not None, "冻结了却没记工具长"
warn = [m for m in st._logger.msgs if "site.yaml 的工具长" in m]
assert warn, "量的工具长与配置差一大截却一声不吭 —— 夹持变动的线索就这么丢了"
# --------------------------------------------------------------------------- #
# 守卫测试:精对准必须真的用规尖,且保留退路
# --------------------------------------------------------------------------- #
def test_align_loop_uses_visual_tip_with_flange_fallback():
"""源码守卫(语法树判据):精对准循环里
· 必须调用 ``_tip_offset_from_depth``(视觉规尖);
· 必须调用 ``_refresh_tip_offset``(量准一次就冻结);
· 必须仍能用 ``_gauge_xy`` 兜底(规尖量不到时不至于完全没方向);
· 结果里要标出这一拍用的是哪个(``gauge_source``),否则现场分不清。
"""
import ast
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
/ "arm_application"
/ "stage2_locate_node.py"
).read_text(encoding="utf-8")
tree = ast.parse(src)
attrs = {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
assert "_tip_offset_from_depth" in attrs, "精对准没用视觉规尖 —— 又回到拿法兰对孔了"
assert "_refresh_tip_offset" in attrs, "偏移没走『量准一次就冻结』的路子 —— 残差会被抖动拖住"
assert "_gauge_xy" in attrs, "连退路都没了:规尖一时量不到就完全没方向"
consts = {n.value for n in ast.walk(tree) if isinstance(n, ast.Constant)}
assert "gauge_source" in consts, "结果里没标这一拍用的是规尖还是法兰,现场没法核对"
assert "align_trusted" in consts, "结果里没标这次对准可不可信,现场没法核对"
def test_success_is_refused_without_tip_offset():
"""**残差达标但规尖偏移没量到**时,必须判失败 —— 不许把没做到说成做到。
2026-10-08 真机复跑就栽在这:``tip_offset_m=None``、残差 0.15mm 看着漂亮,
其实那一拍用的是**法兰轴线**(退路),法兰压在 k1 上、规尖偏出 ~11mm。
没有这条守卫,日志会报"二阶段 OK 残差 0.15mm",而规根本没对上孔。
判据走**语法树**而不是文本匹配:必须存在一个"残差达标"的分支,其(含嵌套的)
分支体里同时写了 ``align_trusted`` 与 ``fail_reason``。文本匹配会被方法里
别处的 `_tip_off is None` 蒙混过去(试过,真的会漏)。
"""
import ast
from pathlib import Path
src = (
Path(__file__).resolve().parents[1]
/ "arm_application"
/ "stage2_locate_node.py"
).read_text(encoding="utf-8")
fn = next(
n
for n in ast.walk(ast.parse(src))
if isinstance(n, ast.FunctionDef) and n.name == "_run_locate"
)
def _consts(node: ast.AST) -> set:
return {x.value for x in ast.walk(node) if isinstance(x, ast.Constant)}
tol_branches = [
n for n in ast.walk(fn) if isinstance(n, ast.If) and "align_tol" in ast.unparse(n.test)
]
assert tol_branches, "没找到『残差达标』那个分支 —— 结构变了,这条守卫要跟着改"
guarded = [
n for n in tol_branches if {"align_trusted", "fail_reason"} <= _consts(n)
]
assert guarded, (
"『残差达标』分支里没有『规尖没量到 ⇒ align_trusted=False + fail_reason』—— "
"假达标(对的是法兰)又会蒙混成成功"
)
def test_default_is_tip_vision_on_in_site():
"""site.yaml 必须默认开视觉规尖,且容差收到 0.25mm。
容差是关键:0.7mm 会让"规偏 10mm"这种系统偏差在容差里被放过去
(原来残差 0.69mm 看着"通过",其实是拿法兰对的孔)。
"""
from pathlib import Path
import yaml
site = yaml.safe_load(
(Path(__file__).resolve().parents[3] / "config" / "site.yaml").read_text(encoding="utf-8")
)
st2 = ((site.get("hole_detect") or {}).get("stage2") or {})
robots = ((site.get("robot") or {}).get("robots") or [])
arm89 = next(r for r in robots if str(r.get("sn")) == "Rizon4-063989")
servo = arm89.get("servo") or {}
assert float(servo.get("ee_z_m")) == pytest.approx(
float(servo.get("hole_hover_z_m"))
), "左臂跟踪锁高必须等于孔悬停高:对孔完成前不许改高度"
assert st2.get("align_tip_vision") is True, "默认必须用视觉规尖"
assert float(st2.get("align_tol_m")) <= 0.00025 + 1e-9, (
f"align_tol_m={st2.get('align_tol_m')} 太松:会放过系统性的对孔偏差"
)
assert int(st2.get("align_max_iters")) >= 12, "收紧容差后迭代上限要跟上"
class _TrimStub:
"""下降后 XY 修正用到的成员。孔在基座里不动,规尖偏移是常数。"""
tip_vision = True
hole_keypoint_once = False
k1_label = "left"
target_hole = "k1"
align_tol = 0.00025
align_speed = 0.02
vert_tol_rad = 0.02
base_frame = "world"
ee_frame = "flange"
cam_frame = "cam"
image_topic = "/cam/color"
_tip_off = np.array([0.01, 0.0])
_tip_samp: list = []
_tip_len_m = 0.22
_ws_clipped = False
final_descent_enable = False
tool_tip_below_flange_m = 0.223
surface_z_m = 0.022
final_gap_m = 0.002
final_descent_speed = 0.005
def __init__(self, err_xy=(0.0026, -0.0004), tip=None) -> None:
self.hole = np.array([0.50 + err_xy[0], -0.10 + err_xy[1], 0.022])
self.tip = None if tip is False else np.array([0.0015, -0.0012])
self.moves: list = []
self.tip_calls = 0
self._logger = _StubLogger()
self.pair_dy = -0.060
self.hole_after_move = None
self.pair_dy_after_move = None
def get_logger(self):
return self._logger
def _lookup(self, *_a):
return None
def _pin(self, _q) -> None:
return None
def _snap_holes(self, *, skip_center_gate: bool):
hole = self.hole
pair_dy = self.pair_dy
if self.moves:
if self.hole_after_move is not None:
hole = self.hole_after_move
if self.pair_dy_after_move is not None:
pair_dy = self.pair_dy_after_move
res = {
"status": True,
"hole_base": [hole.tolist(), (hole + np.array([0.0, pair_dy, 0.0])).tolist()],
"z_wp": 0.127,
}
return None, None, {}, res
def _tip_offset_from_depth(self, *_a, **_kw):
self.tip_calls += 1
if self.tip is None:
return None, None, "没看到规"
off = self.tip.copy()
if self.hole_keypoint_once:
off = off + np.array([0.018 * (self.tip_calls % 3), 0.0])
return off, 0.222, "px=(447,226)"
def _stamp_bias(self, rec, *, tgt_b, gxy, tag):
rec = dict(rec)
rec["tag"] = tag
rec["err_xy_m"] = [
round(float(tgt_b[0]) - float(gxy[0]), 5),
round(float(tgt_b[1]) - float(gxy[1]), 5),
]
rec["along_look_mm"] = None
rec["along_right_mm"] = None
return rec
def _publish_result(self, _payload) -> None:
return None
def _goto(self, _q, Tee, target, **_kw):
Tee = np.array(Tee, float)
self.moves.append(np.array(target, float))
Tee[0, 3] = float(target[0])
Tee[1, 3] = float(target[1])
Tee[2, 3] = float(target[2])
return True, _q, Tee
def _trim(st: _TrimStub, z: float = 0.260):
import rclpy
if not rclpy.ok():
rclpy.init()
Tee = np.eye(4)
Tee[0, 3] = 0.50
Tee[1, 3] = -0.10
Tee[2, 3] = z
final = {"ok": True, "residual_mm": 0.22, "gauge_source": "视觉规尖(已冻结)"}
return Stage2Locate._trim_xy_after_descent(st, np.zeros(7), Tee, final) # type: ignore[arg-type]
def test_trim_after_descent_corrects_xy_and_holds_z():
"""下降后孔位漂了 2mm 级,要在当前高度补上,不许改高度。
2026-10-09:悬停残差 0.22mm,降到观测高度后同一孔的基座 XY 差约 2.6mm。
"""
st = _TrimStub()
_q, Tee, final = _trim(st)
assert abs(float(Tee[2, 3]) - 0.260) < 1e-9, "下降后修正不许改高度"
tip = np.array([Tee[0, 3] + st.tip[0], Tee[1, 3] + st.tip[1]])
err = float(np.hypot(tip[0] - st.hole[0], tip[1] - st.hole[1]))
assert err <= 0.00025 + 1e-6, f"补完针还偏 {err*1000:.2f}mm"
assert final["residual_mm"] <= 0.25
assert "下降后" in final["gauge_source"]
def test_trim_refuses_a_centimeter_scale_correction():
"""要补的量到了厘米,就是采到夹爪了,必须停在原地。"""
st = _TrimStub(err_xy=(0.014, 0.0))
_q, Tee, final = _trim(st)
assert not st.moves, "厘米级修正被执行了"
assert abs(float(Tee[0, 3]) - 0.50) < 1e-9
assert final["residual_mm"] == 0.22, "拒绝修正时不能把悬停结果改写成没补上的残差"
assert any("不像针" in m for m in st._logger.msgs)
def test_trim_does_not_move_when_the_tip_is_missing():
"""下降后没量到规尖,保持悬停对准的位置。"""
st = _TrimStub(tip=False)
_q, Tee, final = _trim(st)
assert not st.moves
assert abs(float(Tee[0, 3]) - 0.50) < 1e-9
assert final["gauge_source"] == "视觉规尖(已冻结)"
def test_once_trim_keeps_hover_tip_when_depth_jumps():
"""单次精拍:沿用悬停规尖,只跳一次、步长 ≤3mm,剩下的不许再追。
2026-10-13:两跳合计 4.3mm 把近距检测带崩。5mm 交平面漂只许吃掉 3mm。
"""
frozen = np.array([0.0001, -0.0003])
st = _TrimStub(err_xy=(0.0044, -0.0024))
st.hole_keypoint_once = True
st._tip_off = frozen.copy()
st.align_tol = 0.00025
_q, Tee, final = _trim(st)
assert st.tip_calls == 0, "单次精拍下降后不许再走深度重测规尖"
assert abs(float(Tee[2, 3]) - 0.260) < 1e-9, "下降后修正不许改高度"
assert len(st.moves) == 1, f"只许一跳,实际 {len(st.moves)} 跳"
hop = float(np.hypot(float(st.moves[0][0]) - 0.50, float(st.moves[0][1]) + 0.10))
assert hop <= 0.003 + 1e-9, f"一跳 {hop*1000:.2f}mm 超过 3mm"
gxy = np.array([float(Tee[0, 3]) + frozen[0], float(Tee[1, 3]) + frozen[1]])
err = float(np.hypot(gxy[0] - st.hole[0], gxy[1] - st.hole[1]))
assert 0.0015 <= err <= 0.0025, f"3mm 一跳后应剩约 2mm,实际 {err*1000:.2f}mm"
assert final["residual_mm"] == pytest.approx(err * 1000.0, abs=0.02)
assert "悬停冻结" in final["gauge_source"]
assert final["residual_remeasured"] is True
assert final.get("trim_reverted") is False
def test_once_trim_refuses_without_frozen_tip():
"""没有悬停冻住的规尖,不许用法兰或乱跳的深度尖去补。"""
st = _TrimStub(err_xy=(0.0044, 0.0))
st.hole_keypoint_once = True
st._tip_off = None
_q, Tee, final = _trim(st)
assert not st.moves
assert st.tip_calls == 0
assert abs(float(Tee[0, 3]) - 0.50) < 1e-9
assert any("冻住的规尖" in m for m in st._logger.msgs)
def test_once_trim_refuses_a_centimeter_scale_hole_shift():
"""孔 XY 要补到厘米,不是交平面那 4mm,必须停。"""
st = _TrimStub(err_xy=(0.014, 0.0))
st.hole_keypoint_once = True
st._tip_off = np.array([0.0001, -0.0003])
_q, Tee, final = _trim(st)
assert not st.moves
assert final["residual_mm"] == 0.22
assert any("不像交平面" in m for m in st._logger.msgs)
def test_once_trim_reverts_when_pair_breaks():
"""一跳后两孔间距从 60mm 掉到 45mm:检测漂了,必须撤回。"""
frozen = np.array([0.0001, -0.0003])
st = _TrimStub(err_xy=(0.0026, 0.0))
st.hole_keypoint_once = True
st._tip_off = frozen.copy()
st.pair_dy_after_move = -0.045
_q, Tee, final = _trim(st)
assert len(st.moves) == 2, "补完变差必须再走一跳撤回"
assert abs(float(Tee[0, 3]) - 0.50) < 1e-9
assert abs(float(Tee[1, 3]) + 0.10) < 1e-9
assert final.get("trim_reverted") is True
assert any("撤回" in m for m in st._logger.msgs)
gxy = np.array([float(Tee[0, 3]) + frozen[0], float(Tee[1, 3]) + frozen[1]])
err = float(np.hypot(gxy[0] - st.hole[0], gxy[1] - st.hole[1]))
assert abs(err - 0.0025) < 2e-4, "撤回后残差应回到走前那一档"
assert final["residual_mm"] == pytest.approx(err * 1000.0, abs=0.05)
def test_once_trim_reverts_when_residual_worsens():
"""一跳后残差变大:停并撤回,终值不许还是走前那份假装没动。"""
frozen = np.array([0.0001, -0.0003])
st = _TrimStub(err_xy=(0.0026, 0.0))
st.hole_keypoint_once = True
st._tip_off = frozen.copy()
st.hole_after_move = np.array([0.50 + 0.015, -0.10, 0.022])
_q, Tee, final = _trim(st)
assert len(st.moves) == 2
assert abs(float(Tee[0, 3]) - 0.50) < 1e-9
assert final.get("trim_reverted") is True
assert any("撤回" in m for m in st._logger.msgs)
assert final["residual_remeasured"] is True
assert float(final["residual_mm"]) < 5.0, "撤回后不许把 15mm 漂检测当终值"
def _enable_blend(st: _TrimStub) -> None:
"""观察高 15mm(法兰 0.260)斜线收到贴面 2mm(法兰 0.247)。"""
st.final_descent_enable = True
st.tool_tip_below_flange_m = 0.223
st.surface_z_m = 0.022
st.final_gap_m = 0.002
st.final_descent_speed = 0.005
def test_once_trim_blend_drops_z_and_xy():
"""观察高测到的残差和剩余 Z 合成一跳,不许再切 4mm 贴面。"""
frozen = np.array([0.0001, -0.0003])
st = _TrimStub(err_xy=(0.0044, -0.0024))
st.hole_keypoint_once = True
st._tip_off = frozen.copy()
st.align_tol = 0.00025
_enable_blend(st)
_q, Tee, final = _trim(st)
assert len(st.moves) == 1, f"斜线只许一跳,实际 {len(st.moves)}"
assert abs(float(Tee[2, 3]) - 0.247) < 1e-9, "必须收到贴面法兰高"
hop_xy = float(np.hypot(float(st.moves[0][0]) - 0.50, float(st.moves[0][1]) + 0.10))
assert hop_xy <= 0.003 + 1e-9
gxy = np.array([float(Tee[0, 3]) + frozen[0], float(Tee[1, 3]) + frozen[1]])
err = float(np.hypot(gxy[0] - st.hole[0], gxy[1] - st.hole[1]))
assert 0.0015 <= err <= 0.0025
assert final.get("trim_reverted") is False
def test_once_trim_blend_z_only_when_xy_already_in_tol():
"""观察高 XY 已在容差,斜线仍要收 Z,不许停在 15mm。"""
frozen = np.array([0.0001, -0.0003])
st = _TrimStub(err_xy=(0.0001, -0.0003))
st.hole_keypoint_once = True
st._tip_off = frozen.copy()
_enable_blend(st)
_q, Tee, final = _trim(st)
assert len(st.moves) == 1
assert abs(float(Tee[0, 3]) - 0.50) < 1e-9
assert abs(float(Tee[2, 3]) - 0.247) < 1e-9
assert final.get("trim_reverted") is False
def test_once_trim_blend_refuses_pair_does_not_drop_z():
"""观察高间距已经坏了,不许斜线贴面。"""
st = _TrimStub(err_xy=(0.004, 0.0))
st.hole_keypoint_once = True
st._tip_off = np.array([0.0001, -0.0003])
st.pair_dy = -0.045
_enable_blend(st)
_q, Tee, final = _trim(st)
assert not st.moves
assert abs(float(Tee[2, 3]) - 0.260) < 1e-9
assert final["residual_mm"] == 0.22
def test_once_trim_blend_keeps_pose_when_close_snap_breaks():
"""贴面复拍间距漂了:近距不可信,观察高算的斜线位姿必须留下。"""
frozen = np.array([0.0001, -0.0003])
st = _TrimStub(err_xy=(0.0026, 0.0))
st.hole_keypoint_once = True
st._tip_off = frozen.copy()
st.pair_dy_after_move = -0.045
_enable_blend(st)
_q, Tee, final = _trim(st)
assert len(st.moves) == 1, "贴面复拍漂了不许撤回观察高"
assert abs(float(Tee[2, 3]) - 0.247) < 1e-9
assert abs(float(Tee[0, 3]) - 0.50) > 1e-4, "XY 补偿被撤回了"
assert final.get("trim_reverted") is not True
assert final.get("close_snap_untrusted") is True
def test_once_trim_refuses_when_pair_already_broken():
"""第一拍间距已经 45mm,检测没立住,不许再动。"""
st = _TrimStub(err_xy=(0.004, 0.0))
st.hole_keypoint_once = True
st._tip_off = np.array([0.0001, -0.0003])
st.pair_dy = -0.045
_q, Tee, final = _trim(st)
assert not st.moves
assert abs(float(Tee[0, 3]) - 0.50) < 1e-9
assert final["residual_mm"] == 0.22
assert any("检测漂了" in m for m in st._logger.msgs)
@@ -0,0 +1,87 @@
"""触觉"有没有数据"的判据:为什么不能拿力的大小当传感器活着的代理。
背景(真实踩到):S07 一进柔顺下压就报
ERROR S07 触觉断流,无法判断接触:触觉话题断流 >3.0s(左右指都无新鲜数据)
而同一时刻 ``ros2 topic hz /tactile/left/status`` 是 19Hz、``connected: true``、
``fnormal ≈ 1.3e-05``。旧判据是 ``|fnormal| > 1.0`` —— 把"力小"当成了"没数据",
于是**空手/未接触/形变任意单位**这几种正常情形全都判成断流。
契约(``sim_tactile_adapter`` 里写明、真机 ``tactile_node`` 同款):
没数据 → 发零 **且** connected=false
零 力 → connected=true + fnormal≈0
所以判据只能是"够新 + connected",与幅值无关。
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from arm_application.stage3_search_node import (
TACTILE_MAX_AGE_S,
live_tactile,
should_lift_air_baseline,
tactile_timeout_reason,
)
@dataclass
class _Msg:
fnormal: float = 0.0
connected: bool = True
def test_zero_force_but_connected_is_live_data():
"""静止读数≈0 是**有效数据**,不是断流。"""
now = time.monotonic()
live = live_tactile(now, [(now, _Msg(fnormal=1.3e-05, connected=True)), None])
assert len(live) == 1
assert live[0].fnormal == 1.3e-05
def test_disconnected_frames_are_not_live_even_if_they_keep_arriving():
"""帧一直在到、但 connected=false(设备没连上)→ 不算可用。"""
now = time.monotonic()
assert live_tactile(now, [(now, _Msg(connected=False)), (now, _Msg(connected=False))]) == []
def test_stale_frames_are_not_live():
now = time.monotonic()
old = now - TACTILE_MAX_AGE_S - 0.01
assert live_tactile(now, [(old, _Msg(connected=True))]) == []
def test_both_sides_live_keeps_both():
now = time.monotonic()
live = live_tactile(now, [(now, _Msg(fnormal=3.0)), (now, _Msg(fnormal=5.0))])
assert [m.fnormal for m in live] == [3.0, 5.0]
def test_timeout_reason_separates_device_problem_from_no_publisher():
"""两种"没数据"必须报**不同**的话:一个查驱动/设备,一个查谁在发话题。"""
now = time.monotonic()
dev = tactile_timeout_reason([(now, _Msg(connected=False)), None], 3.0)
assert "connected=false" in dev
assert "run_tactile.sh" in dev # 给出下一步做什么
assert "断流" not in dev # 不能把人往"话题没起来"上带
none = tactile_timeout_reason([None, None], 3.0)
assert "断流" in none and "3.0" in none
def test_timeout_reason_still_reports_stall_when_only_stale_frames_exist():
"""帧过期(驱动卡住)仍然算"断流",不该被说成设备没连上。"""
old = time.monotonic() - 10.0
msg = tactile_timeout_reason([(old, _Msg(connected=True)), None], 3.0)
assert "断流" in msg
def test_no_tactile_does_not_lift_to_air_baseline():
"""触觉没数据时不许先抬到 hover+50mm 再报断流。"""
assert should_lift_air_baseline(0.385, 0.470, tactile_live=False) is False
assert should_lift_air_baseline(0.249, 0.470, tactile_live=True) is True
assert should_lift_air_baseline(0.468, 0.470, tactile_live=True) is False
@@ -0,0 +1,247 @@
traj_coord_type: ""
traj_coord_ref: ""
pattern_params {
pattern_name: "WPT0"
pattern_type: "Scatter"
scatterpoint_pattern {
pose_params {
x: "0.26072"
y: "-0.14604"
z: "0.85707"
rx: "6.58"
ry: "165.63"
rz: "-6.63"
coord_type: "WORLD"
coord_ref: "WORLD_ORIGIN"
use_quaternion: false
preferJntPos {
joint1: "-6.97"
joint2: "29.95"
joint3: "1.72"
joint4: "92.29"
joint5: "-6.48"
joint6: "-12.54"
joint7: "-0.37"
}
externalAxes {
axis1: "0"
axis2: "0"
axis3: "0"
axis4: "0"
axis5: "0"
axis6: "0"
}
}
force_params {
fx: "0"
fy: "0"
fz: "0"
mx: "0"
my: "0"
mz: "0"
}
path_profile: "line"
blend_zone: "Z10"
velocity: "0.2"
acceleration: "1"
vel_var_name: ""
wrench_var_name: ""
}
}
pattern_params {
pattern_name: "WPT1"
pattern_type: "Scatter"
scatterpoint_pattern {
pose_params {
x: "0.50244"
y: "-0.05458"
z: "0.62368"
rx: "3.30681"
ry: "173.28739"
rz: "12.76833"
coord_type: "WORLD"
coord_ref: "WORLD_ORIGIN"
use_quaternion: false
preferJntPos {
joint1: "-0.39471"
joint2: "2.89069"
joint3: "7.36936"
joint4: "104.4854"
joint5: "-2.56594"
joint6: "19.41153"
joint7: "-5.21738"
}
externalAxes {
axis1: "0"
axis2: "0"
axis3: "0"
axis4: "0"
axis5: "0"
axis6: "0"
}
}
force_params {
fx: "0"
fy: "0"
fz: "0"
mx: "0"
my: "0"
mz: "0"
}
path_profile: "line"
blend_zone: "Z10"
velocity: "0.2"
acceleration: "1"
vel_var_name: ""
wrench_var_name: ""
}
}
pattern_params {
pattern_name: "WPT2"
pattern_type: "Scatter"
scatterpoint_pattern {
pose_params {
x: "0.38216"
y: "-0.07015"
z: "0.2772"
rx: "-0.76189"
ry: "178.83063"
rz: "13.77851"
coord_type: "WORLD"
coord_ref: "WORLD_ORIGIN"
use_quaternion: false
preferJntPos {
joint1: "-0.51679"
joint2: "-17.82083"
joint3: "6.09068"
joint4: "145.99388"
joint5: "-3.83264"
joint6: "75.52854"
joint7: "-5.23384"
}
externalAxes {
axis1: "0"
axis2: "0"
axis3: "0"
axis4: "0"
axis5: "0"
axis6: "0"
}
}
force_params {
fx: "0"
fy: "0"
fz: "0"
mx: "0"
my: "0"
mz: "0"
}
path_profile: "line"
blend_zone: "Z10"
velocity: "0.2"
acceleration: "1"
vel_var_name: ""
wrench_var_name: ""
}
}
pattern_params {
pattern_name: "WPT3"
pattern_type: "Scatter"
scatterpoint_pattern {
pose_params {
x: "0.42395"
y: "-0.04059"
z: "0.135"
rx: "-4.09647"
ry: "175.59924"
rz: "22.59976"
coord_type: "WORLD"
coord_ref: "WORLD_ORIGIN"
use_quaternion: false
preferJntPos {
joint1: "0.80285"
joint2: "-48.57114"
joint3: "4.29418"
joint4: "137.232"
joint5: "-14.43138"
joint6: "99.59405"
joint7: "-5.25622"
}
externalAxes {
axis1: "0"
axis2: "0"
axis3: "0"
axis4: "0"
axis5: "0"
axis6: "0"
}
}
force_params {
fx: "0"
fy: "0"
fz: "0"
mx: "0"
my: "0"
mz: "0"
}
path_profile: "line"
blend_zone: "Z10"
velocity: "0.2"
acceleration: "1"
vel_var_name: ""
wrench_var_name: ""
}
}
pattern_params {
pattern_name: "WPT4"
pattern_type: "Scatter"
scatterpoint_pattern {
pose_params {
x: "0.2971"
y: "0.06042"
z: "0.76959"
rx: "4.64468"
ry: "173.46657"
rz: "27.06819"
coord_type: "WORLD"
coord_ref: "WORLD_ORIGIN"
use_quaternion: false
preferJntPos {
joint1: "5.13584"
joint2: "28.76765"
joint3: "17.15688"
joint4: "100.21538"
joint5: "4.05433"
joint6: "-9.64651"
joint7: "-5.26232"
}
externalAxes {
axis1: "0"
axis2: "0"
axis3: "0"
axis4: "0"
axis5: "0"
axis6: "0"
}
}
force_params {
fx: "0"
fy: "0"
fz: "0"
mx: "0"
my: "0"
mz: "0"
}
path_profile: "line"
blend_zone: "Z10"
velocity: "0.2"
acceleration: "1"
vel_var_name: ""
wrench_var_name: ""
}
}
group_params {
group_name: "RecordGroup"
pattern_start_idx: 0
pattern_end_idx: 4
}
+164
View File
@@ -0,0 +1,164 @@
{
"source": "http://git.orisys-tech.cn/orisys/ros_contorl.git",
"branch": "exp/hole-keypoint-once",
"commit": "561d7da21f20d3e63030f81510ae2f29eed01d46",
"date": "2026-10-20",
"scope": "ROS nodes and supporting libraries/launch/config/tests only",
"upstream_identical": [
"arm_application/box_tracker.py",
"arm_application/demo_named_poses.py",
"arm_application/dh_official.py",
"arm_application/dh_pgea.py",
"arm_application/dual_gripper.py",
"arm_application/dual_gripper_cli.py",
"arm_application/dual_gripper_node.py",
"arm_application/feature_guard.py",
"arm_application/feature_jump_guard.py",
"arm_application/gauge_assembly_node.py",
"arm_application/gauge_boot.py",
"arm_application/gauge_config.py",
"arm_application/gauge_fsm.py",
"arm_application/gauge_gripper.py",
"arm_application/gauge_kin.py",
"arm_application/geom_ik.py",
"arm_application/goto_ee_plane.py",
"arm_application/handeye_calibrate.py",
"arm_application/handeye_lib.py",
"arm_application/handeye_realsense.py",
"arm_application/handeye_teach.py",
"arm_application/handeye_waypoints.py",
"arm_application/hole_detect_hough.py",
"arm_application/hole_detect_node.py",
"arm_application/hole_detector.py",
"arm_application/image_utils.py",
"arm_application/kalman_filter.py",
"arm_application/model_labels.py",
"arm_application/move_to_pose_client.py",
"arm_application/onnx_workpiece.py",
"arm_application/orisys_tactile.py",
"arm_application/overhead_oneshot.py",
"arm_application/path_profile.py",
"arm_application/perception_node.py",
"arm_application/pid_controller.py",
"arm_application/plane_feature.py",
"arm_application/pose_detector.py",
"arm_application/realsense_sdk_view.py",
"arm_application/ros_node.py",
"arm_application/run_joint_trajectory.py",
"arm_application/servo_node.py",
"arm_application/site_io.py",
"arm_application/stage3_search_node.py",
"arm_application/tactile_cli.py",
"arm_application/tactile_interface.py",
"arm_application/tactile_node.py",
"arm_application/yolo_detector.py",
"config/demo.yaml",
"config/dual_grippers.yaml",
"config/hole_detect.yaml",
"config/hole_insert.yaml",
"config/perception.yaml",
"config/servo.yaml",
"config/xunhuan_trajectory.yaml",
"launch/demo.launch.py",
"launch/dh_pgea.launch.py",
"launch/dual_gripper.launch.py",
"launch/gauge_assembly.launch.py",
"launch/hole_detect.launch.py",
"launch/stage2_locate.launch.py",
"launch/stage3_search.launch.py",
"launch/visual_servo.launch.py",
"package.xml",
"resource/arm_application",
"setup.cfg",
"test/test_feature_jump_guard.py",
"test/test_ft_source_guard.py",
"test/test_gauge_boot.py",
"test/test_gauge_config.py",
"test/test_gauge_exit_lift.py",
"test/test_gauge_fsm.py",
"test/test_gauge_gripper.py",
"test/test_gauge_gripper_client.py",
"test/test_gauge_kin.py",
"test/test_gauge_step_events.py",
"test/test_handeye_lib.py",
"test/test_handeye_lib_roundtrip.py",
"test/test_hole_detector.py",
"test/test_image_utils.py",
"test/test_model_labels.py",
"test/test_node_handler_collisions.py",
"test/test_onnx_workpiece_guard.py",
"test/test_orisys_vector.py",
"test/test_perception_acquire_gate.py",
"test/test_pose_detector.py",
"test/test_ros_node.py",
"test/test_site_arm_sections.py",
"test/test_stage2_descent_clamp.py",
"test/test_stage2_no_follow.py",
"test/test_stage2_tip_align.py",
"test/test_stage3_tactile_live.py",
"trajectories/xunhuan.traj"
],
"locally_adapted": [
"README.md",
"arm_application/__init__.py",
"arm_application/dh_pgea_node.py",
"arm_application/pipeline_node.py",
"arm_application/realsense_color_pub.py",
"arm_application/stage2_locate_node.py",
"launch/pipeline.launch.py",
"launch/tactile.launch.py",
"setup.py",
"test/test_dh_pgea_latch.py",
"test/test_feature_guard.py",
"test/test_handeye_mode.py",
"test/test_listed_arms.py",
"test/test_overhead_oneshot.py",
"test/test_overhead_track.py",
"test/test_pipeline_stage1_diag.py",
"test/test_stage2_coarse_guard.py"
],
"excluded_out_of_scope": [
"arm_application/operator_app.py",
"arm_application/operator_arm_mesh.py",
"arm_application/operator_arm_state.py",
"arm_application/operator_camera_view.py",
"arm_application/operator_console.html",
"arm_application/operator_console.py",
"arm_application/operator_console_logo.png",
"arm_application/operator_gripper_mesh.py",
"arm_application/operator_gripper_state.py",
"arm_application/operator_handeye_board.py",
"arm_application/operator_tactile_view.py",
"arm_application/operator_teach.py",
"arm_application/realsense_view.py",
"arm_application/weights_layout.py",
"test/test_camera_preview.py",
"test/test_command_lanes.py",
"test/test_gauge_result_gate.py",
"test/test_handeye_preview.py",
"test/test_merge_handeye_poses.py",
"test/test_operator_arm_mesh.py",
"test/test_operator_arm_state.py",
"test/test_operator_gripper_mesh.py",
"test/test_operator_gripper_state.py",
"test/test_real_geometry_check.py",
"test/test_real_preflight.py",
"test/test_ros_domain.py",
"test/test_usb_health.py",
"test/test_v4l_health.py",
"test/test_weights_layout.py"
],
"external_compatibility": [
"scripts/lib/flexiv_movel_ipc.py",
"src/flexiv_ros2/flexiv_hardware/src/flexiv_hardware_interface.cpp",
"station/src/station/control_service.py",
"station/src/station/ros_gateway.py"
],
"station_stage_integration": {
"node": "arm_application.a7_rotation_node",
"alignment": "updated stage2 with configured final descent; hole_entry_mm=1.0",
"rotation": "explicit upstream A7 cycle with local typed GripperExecute; no shell",
"full_flow": "tracking -> alignment -> A7; failure/cancel prevents next stage",
"hardware_motion_tested": false
}
}
@@ -34,8 +34,8 @@ rizon_arm_controller:
allow_partial_joints_goal: false
interpolation_method: splines
allow_nonzero_velocity_at_trajectory_end: true
# Stream from last command (no snap-back to encoder each preempt).
open_loop_control: true
# New trajectories start at measured joints, including after a native MoveL.
open_loop_control: false
constraints:
stopped_velocity_tolerance: 0.25
goal_time: 0.0
@@ -19,6 +19,14 @@ find_package(hardware_interface REQUIRED)
find_package(pluginlib REQUIRED)
find_package(flexiv_rdk REQUIRED)
find_package(flexiv_drdk QUIET)
# RDK is a static archive built against flexiv_install's spdlog 1.9 / fmt v8.
# A generic find_package(spdlog) in ros_flexiv resolves conda's 1.14 + fmt 11,
# and ros2_control_node then dies with undefined fmt::v8 symbols.
set(_flexiv_spdlog_dir "$ENV{HOME}/flexiv_install/lib/cmake/spdlog")
if(NOT EXISTS "${_flexiv_spdlog_dir}/spdlogConfig.cmake")
message(FATAL_ERROR "flexiv_hardware needs spdlog from ~/flexiv_install (RDK fmt v8). Missing ${_flexiv_spdlog_dir}")
endif()
find_package(spdlog REQUIRED PATHS "${_flexiv_spdlog_dir}" NO_DEFAULT_PATH)
add_library(
${PROJECT_NAME}
@@ -28,6 +28,7 @@
#include <hardware_interface/types/hardware_interface_return_values.hpp>
#include "flexiv_hardware/visibility_control.h"
#include "flexiv_hardware/position_command_fence.hpp"
// Flexiv
#include "flexiv/rdk/robot.hpp"
@@ -144,6 +145,9 @@ private:
// D435 粗定位的 MoveL 走已经连上的这份 RDK。write() 里一步一步推进,
// 不另开线程,也不在这段里往关节接口灌点。请求文件是 /tmp/flexiv_movel_req。
enum class VendorMovelPhase { kIdle, kRunning };
MotionExecutionLatch motion_execution_latch_;
PositionCommandFence position_command_fence_;
bool reset_nrt_gate_ = true;
VendorMovelPhase vendor_movel_phase_ = VendorMovelPhase::kIdle;
long vendor_movel_seq_ = -1;
long vendor_movel_done_seq_ = -1;
@@ -0,0 +1,36 @@
#pragma once
#include <cmath>
#include <vector>
namespace flexiv_hardware {
// A released E-stop / restored Enable is not permission to resume an old task.
// Only explicit hardware lifecycle activation can reset this latch.
class MotionExecutionLatch {
public:
void activate() { interrupted_ = false; }
bool observe(bool connected, bool operational, bool fault) {
if (!connected || !operational || fault) interrupted_ = true;
return !interrupted_;
}
private:
bool interrupted_ = true;
};
// After activation/mode changes, a command stream must start at measured joints.
// An old held trajectory target must never pull the arm back to its previous pose.
class PositionCommandFence {
public:
void reset() { waiting_ = true; }
bool accept(const std::vector<double>& command, const std::vector<double>& state) {
if (command.empty() || command.size() != state.size()) return false;
for (size_t i = 0; i < command.size(); ++i) {
if (!std::isfinite(command[i]) || !std::isfinite(state[i])) return false;
if (waiting_ && std::abs(command[i] - state[i]) > 0.01) return false;
}
waiting_ = false;
return true;
}
private:
bool waiting_ = true;
};
} // namespace flexiv_hardware
@@ -632,6 +632,9 @@ hardware_interface::CallbackReturn FlexivHardwareInterface::on_activate(
return hardware_interface::CallbackReturn::ERROR;
}
// Clear any primitive/trajectory still retained on the robot server BEFORE
// Enable. If Stop cannot be confirmed, fail activation rather than resume.
robot_->Stop();
// Enable the robot (retries while waiting for operational)
RCLCPP_INFO(getLogger(), "Enabling robot ...");
robot_->Enable();
@@ -650,6 +653,9 @@ hardware_interface::CallbackReturn FlexivHardwareInterface::on_activate(
RCLCPP_INFO(getLogger(), "System successfully started!");
// 启动前磁盘上的旧请求不许再执行一遍。新请求用更晚的序号。
motion_execution_latch_.activate();
position_command_fence_.reset();
reset_nrt_gate_ = true;
vendor_movel_done_seq_ = read_movel_seq();
vendor_movel_phase_ = VendorMovelPhase::kIdle;
@@ -688,6 +694,7 @@ hardware_interface::return_type FlexivHardwareInterface::read(
// 必须放在 operational() **之前**判 —— 断连后 operational() 会一直保持 true,
// 只看它会把"已断连"误判成"正常",这正是当初臂不动却一路报 success 的原因。
if (!robot_->connected()) {
motion_execution_latch_.observe(false, false, false);
RCLCPP_ERROR_THROTTLE(getLogger(), steady_clock, 2000,
"RDK 与机器人的连接已断开 —— 关节状态只是最后一帧缓存,下发的命令不会生效。"
"检查网络后重启本栈(两台臂出厂 MAC 相同时会在同一 L2 段抢转发,"
@@ -695,6 +702,21 @@ hardware_interface::return_type FlexivHardwareInterface::read(
return hardware_interface::return_type::ERROR;
}
// Loss of operational state includes physical E-stop/disable. Stop the ROS
// execution too: a paused JTC must not keep interpolating and resume on Enable.
if (!motion_execution_latch_.observe(true, robot_->operational(), robot_->fault())) {
position_command_fence_.reset();
reset_nrt_gate_ = true;
if (vendor_movel_phase_ == VendorMovelPhase::kRunning) {
finish_vendor_movel(vendor_movel_seq_, false, "robot interrupted; request discarded");
}
vendor_movel_done_seq_ = read_movel_seq();
RCLCPP_ERROR_THROTTLE(getLogger(), steady_clock, 2000,
"Robot interrupted: abort hardware/controllers; explicit lifecycle activation required. "
"Old motion will not resume after Enable.");
return hardware_interface::return_type::ERROR;
}
if (robot_->operational()) {
hw_flexiv_robot_states_ = robot_->states();
@@ -713,6 +735,7 @@ hardware_interface::return_type FlexivHardwareInterface::read(
last_state_change_ns_ = now_ns;
} else if (last_state_change_ns_ != 0
&& now_ns - last_state_change_ns_ > kStateStaleTimeoutNs) {
motion_execution_latch_.observe(false, false, false);
RCLCPP_ERROR_THROTTLE(getLogger(), steady_clock, 2000,
"机器人状态帧已停滞 %.1f s —— 关节状态是陈旧值,下发命令不会生效。"
"检查网络/RDK 连接后重启本栈。",
@@ -754,6 +777,8 @@ void FlexivHardwareInterface::finish_vendor_movel(
} else {
write_movel_line(kMovelDone, std::to_string(seq) + " fail " + clean);
}
position_command_fence_.reset();
reset_nrt_gate_ = true;
vendor_movel_done_seq_ = seq;
vendor_movel_phase_ = VendorMovelPhase::kIdle;
}
@@ -805,6 +830,15 @@ bool FlexivHardwareInterface::poll_vendor_movel()
return false;
}
std::string zone;
int toler = -1;
if (in >> zone) {
if (!(in >> toler) || zone != "ZFine" || toler != 1) {
finish_vendor_movel(seq, false, "微升降只接受过渡区 ZFine 和到位等级 1");
return true;
}
}
// 先应答,伺服才能在 1.5s 内分清「新接口没编进去」和「臂还在走」。
write_movel_line(kMovelAck, std::to_string(seq));
try {
@@ -840,8 +874,14 @@ bool FlexivHardwareInterface::poll_vendor_movel()
std::array<double, 3> {x, y, z},
euler,
std::array<std::string, 2> {"WORLD", "WORLD_ORIGIN"});
robot_->ExecutePrimitive(
"MoveL", {{"target", target}, {"vel", vel}}, true);
std::map<std::string, flexiv::rdk::FlexivDataTypes> movel_args;
movel_args.emplace("target", target);
movel_args.emplace("vel", vel);
if (!zone.empty()) {
movel_args.emplace("zoneRadius", zone);
movel_args.emplace("targetTolerLevel", toler);
}
robot_->ExecutePrimitive("MoveL", movel_args, true);
vendor_movel_seq_ = seq;
vendor_movel_phase_ = VendorMovelPhase::kRunning;
vendor_movel_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(90);
@@ -861,6 +901,14 @@ bool FlexivHardwareInterface::poll_vendor_movel()
hardware_interface::return_type FlexivHardwareInterface::write(
const rclcpp::Time& /*time*/, const rclcpp::Duration& /*period*/)
{
// Check before polling pending native requests or switching control mode.
if (!motion_execution_latch_.observe(robot_->connected(), robot_->operational(), robot_->fault())) {
position_command_fence_.reset();
reset_nrt_gate_ = true;
vendor_movel_done_seq_ = read_movel_seq();
return hardware_interface::return_type::ERROR;
}
// MoveL 期间不能回到关节模式,也不能把旧关节目标再发出去。
if (poll_vendor_movel()) {
return hardware_interface::return_type::OK;
@@ -917,20 +965,9 @@ hardware_interface::return_type FlexivHardwareInterface::write(
"Robot not operational (status=%s). Commands not sent. "
"Release E-stop, AUTO Remote, Enable.",
operational_status_name(robot_->operational_status()));
static std::chrono::steady_clock::time_point last_en {};
const auto now = std::chrono::steady_clock::now();
if (now - last_en > std::chrono::seconds(2)) {
last_en = now;
try {
if (robot_->fault()) {
robot_->ClearFault(3);
}
robot_->Enable();
} catch (const std::exception& e) {
RCLCPP_ERROR_THROTTLE(getLogger(), steady_clock, 2000,
"Enable failed: %s", e.what());
}
}
// Re-enabling must be explicit; never resume a cached trajectory here.
position_command_fence_.reset();
reset_nrt_gate_ = true;
} else if (robot_->mode() == rdk_control_mode_) {
for (size_t rdk_idx = 0; rdk_idx < robot_->info().DoF; ++rdk_idx) {
size_t ros_idx = rdk_to_ros_map_[rdk_idx];
@@ -943,6 +980,17 @@ hardware_interface::return_type FlexivHardwareInterface::write(
std::fabs(target_pos[rdk_idx] - hw_states_joint_positions_[ros_idx]));
}
static NrtSendGate nrt_gate;
if (reset_nrt_gate_) {
nrt_gate = NrtSendGate {};
reset_nrt_gate_ = false;
}
if (!position_command_fence_.accept(
hw_commands_joint_positions_, hw_states_joint_positions_)) {
RCLCPP_WARN_THROTTLE(getLogger(), steady_clock, 2000,
"Suppressed stale joint target after activation/MoveL; "
"waiting for a new trajectory starting at measured joints");
return hardware_interface::return_type::OK;
}
RCLCPP_INFO_THROTTLE(getLogger(), steady_clock, 2000,
"HW write running=%d nan=%d mode=%d want=%d op=%d busy=%d d_state=%.4f",
static_cast<int>(position_controller_running_), static_cast<int>(is_pos_nan),
@@ -1149,6 +1197,8 @@ hardware_interface::return_type FlexivHardwareInterface::perform_command_mode_sw
torque_controller_running_ = false;
// Hold joints before user commands arrives
position_command_fence_.reset();
reset_nrt_gate_ = true;
std::fill(hw_commands_joint_positions_.begin(), hw_commands_joint_positions_.end(),
std::numeric_limits<double>::quiet_NaN());
+207 -4
View File
@@ -19,7 +19,9 @@
## 首页动作接口
- 检测 → `stage_track`,运行现有第一阶段跟踪:D405 优先、丢失目标后 D435 补位;会移动机械臂,不进入对孔或旋入。
- 对孔 → `stage_locate`,只运行第二阶段,明确关闭最终下压和后续旋入。
- 对孔 → `stage_locate`,运行新版第二阶段:精对孔并下降至配置的进孔高度;不自动转 A7。当前 hole_entry_mm=1.0。
- 旋转 → 独立 `a7_rotation` ROS 节点,复用上游首轮 A7 正反转和四轮松爪、微抬、空回、降回、合爪旋转;不重新跟踪/对孔。夹爪通过本站 GripperExecute 服务控制,不调用 shell。起点高度/姿态、夹爪或限位不满足时中止。
- 开始全部流程 → 跟踪、对孔、A7 旋转依次执行,失败/取消后不进入下一段。
- 回零 → 所选臂命名点文件中的 `servo.home_pose_name`(默认 `home`),经原生轨迹 Action 返回初始姿态,不是关节全零。
- 停止 → 取消本站任务、通过控制器管理服务暂停对应臂的运动控制器(硬件接口调用 `Robot::Stop`),停止本站流程,再等待新鲜关节反馈持续稳定。失败时明确报告停止未确认,并阻止继续运动。仅下一次显式动作恢复本站暂停的控制器。
@@ -54,11 +56,11 @@ PGEA 的常驻驱动提供:
## 图像链路
RealSense 采集节点只在显式请求时启动。已有发布者会被复用,工位不会按进程名终止它。
RealSense 采集节点只在显式请求时启动。外部发布者不会被接管,工位也不会按进程名终止它。只有本站进程记录与当前唯一发布者身份同时匹配,才允许预览和启动后续算法。
采集配置继续来自 `site.yaml`,算法使用的彩色/深度独立于界面预览。
`cam_bridge.py` 负责 ROS 图像接收与共享内存输出。当前 Qt 界面只请求真机彩色流;
没有查看需求的图像与检测订阅会撤销。预览处理最高 15 fps、彩色宽度上限 848,
没有查看需求的图像与检测订阅会撤销。彩色预览不再额外限到 15 fps,保留最高 1920×1080 的显示图像,
算法输入不受影响,采集端现有原图话题的发布速率不会因此降低。
图像或桥进程超过 2 秒未更新时清除旧显示。共享内存由生产者释放,读取者只解除映射。
深度、仿真及标定流的后端能力保留给独立工具,不再提供首页切换入口。
@@ -74,7 +76,7 @@ RealSense 采集节点只在显式请求时启动。已有发布者会被复用
默认不自动拉额外查看器、红外或触觉流。上电的网络/工具准备与手眼标定保留原维护脚本,
由后端作为明确的系统/标定工具运行。`operator_console.py` 的旧 HTTP/脚本控制路径已经退役。
`processes.py` 只登记并停止自己启动的进程组,不扫描和终止外部进程。Linux 子进程设置
`processes.py` 只登记并停止自己启动的进程树,不扫描和终止外部进程。Linux 子进程设置
父进程退出信号,后端也监测界面退出,避免留下预览或控制孤儿进程。
“停止动作”取消工位任务,保持采集和驱动;窗口关闭时取消任务并退出自己持有的进程。
软件流程停止不能证明控制器已停稳;轨迹、夹爪停止未确认时会明确报告结果未知。
@@ -95,3 +97,204 @@ RealSense 采集节点只在显式请求时启动。已有发布者会被复用
每次后端启动会生成一份私有站点配置快照,所有子进程使用同一份设备身份与参数。
现场 `site.yaml` 发生变化后拒绝新操作并提示重启,已执行任务继续使用原快照,避免运行中换臂或换相机。
## 第一批节点治理
后端按设备驱动、运动控制、算法任务、界面辅助分类展示 ROS 图,并保留完整命名空间及同名实例数。
分类仅表示职责:发现一个节点不等于确认它健康,更不代表工位拥有它。
监控页另列出本站实际启动的进程 PID、启动时间和退出状态,以及当前任务占用的资源、臂号和取消请求。
后端断联时这些缓存统一降为未知。
Linux 启动器作为 subreaper 持续监督每条启动命令,主命令结束后先终止并回收其遗留子进程,
包括自行创建 session 的节点,再允许任务返回。控制栈、相机、算法继续分进程运行;不按节点名字杀外部进程。
日志保留历史,但任务结果仅截取本次启动写入的部分,避免成功任务显示上次错误。
使能显示同时要求本臂关节反馈新鲜、原生驱动 RobotStates 中的机器人时间戳持续推进;
仅 ROS header 更新或重复缓存帧不能确认使能。依据是本项目原生驱动只在 connected/operational 时更新该结构。
这属于应用层运行状态判断,不替代机器人自身安全状态或安全控制器。
本批尚未迁移全局运动接口到双臂命名空间,也未替换旧命令行维护/标定脚本。
这些入口仍需后续逐条迁移;目前继续使用全局运动资源互斥,禁止双臂同时下发。
相机未连接时不更改采集分辨率、USB 模式或宣称实测帧率。预览改动使用模拟图像验证。
项目发现配置统一在 `config/ros_runtime.env`:默认 domain 42、仅本机发现,不再继承 shell 里
不明来源的 domain 0 或仿真域。显式部署覆盖使用 `REAL_ROS_DOMAIN_ID` 和
`REAL_ROS_LOCALHOST_ONLY`,所有子启动脚本继承同一值。CLI 观察节点前需先 source 本项目的
`scripts/env.sh`。ROS 域用于防止误混入,不属于访问控制或机器人安全功能。
运行中的旧工位仍保持原域,代码修改不会热切换它。迁移需要在任务结束后完整关闭旧工位,
再重新启动;旧域外部控制栈必须由原入口退出,不能同时对同一硬件启动第二份驱动。
## 操作并发与收尾
资源策略集中在 `command_policy.py`:Qt 只合并同一臂的同一按钮重复请求,其他请求交由后端
按实际写入设备判断。机械臂使能/跟踪/对孔/回零、相机刷新、夹爪控制和传感器刷新/底图重置
可跨模块并行;读取图像和力反馈不等于独占采集模块。A7 与全流程实际控制夹爪,所以执行期间
保留该夹爪的互锁。仍共用控制器的机械臂运动保持互斥。同一相机初始化由相机适配器内部串行,
只有断流或配置不匹配才重启;机械臂使能不阻止相机重连。触觉预览不占用触觉采集资源。
机械臂反馈中断仅记录在 backend.device_errors.arms,不提升为全后端错误,不禁用其他模块。
冲突提示显示动作名称、臂号和执行/收尾/停止阶段。子进程启动器通过私有阶段文件报告收尾,
直到整个所属进程树退出才释放资源。工位 pipeline 的结果展示停留改为 0.3 秒;手工 CLI 默认仍为
15 秒。此停留仅发布结果/预览,不更改轨迹、定位精度或控制器保持行为。
停止 RPC 不经过任务队列;明确停止机械臂时只取消运动控制任务,保留独立的相机连接和只读任务。
停止未确认仍锁住后续机械臂控制入口,不能靠并发改动绕过停止确认。
## 高清相机配置
当前请求 D405 彩色/原生深度 1280×720@15,D435 彩色 1920×1080@15、原生深度
1280×720@15,由 SDK 对齐到彩色像素坐标。采集器只尝试本台设备实际枚举支持的配置,
不再把 1080p 一律过滤,也不强迫 D435 彩色和原生深度使用同一分辨率。
Qt 预览通过本站采集节点的 `color/preview/compressed` 接收 JPEG 95 图像,只有订阅者存在
时才编码;算法继续订阅完整 `color/image_raw`,无算法订阅者时不序列化这份大图。
预览桥支持到 1920×1080,原生分辨率在该范围内保持不缩小。压缩仅用于显示,不用于定位计算。
两台相机共用 5Gbps Hub 的现场并发测试出现断流,单台可接近 30fps;部署应使用独立 USB3
连接并重新核对两路同时采集的实测值,不能仅凭配置数字认为帧率已达标。
“刷新相机”核对本站采集所有权及新鲜 CameraInfo:正常且分辨率匹配的流保持运行,
断流或配置不匹配才重启本站对应进程,并等待 DDS 旧发现记录退出、收到实际新帧后报告成功。
相机刷新与机械臂模块独立,不接管外部节点。SDK 留出四个帧缓冲供对齐持有,
应用层仍只保留最新一帧;每轮处理后释放 SDK 引用,避免等待新帧时占住旧缓冲。
### 使能与轨迹恢复
使能的连接检查不恢复本站暂停的轨迹控制器;只有明确的新动作才恢复控制器。
关节控制循环不再自动 ClearFault/Enable。JTC 使用实际关节反馈作为新轨迹起点
(open_loop_control=false)。硬件激活、控制器恢复和厂家 MoveL 结束后,
旧目标被拦截,直到指令流从实测关节附近重新开始;同时清空 NRT 前馈缓存。
更换驱动库使用原子替换,运行中的驱动保持原版本,重启工位后加载更新。
离线回归包括暂停后使能不恢复控制器,以及旧目标持续重放不能通过硬件保护。
### 实体急停后的任务取消
原生驱动在 connected/operational 丢失、故障或硬件时间戳停滞时锁存中断,
read/write 返回 ERROR,要求控制器停止;write 在处理厂家 MoveL 请求前检查锁存。
仅恢复 Enable 或清故障不会解除锁存,需要显式重新激活硬件生命周期
(当前工位可通过重启控制栈重新连接)。重新激活会丢弃旧 MoveL 请求序号。
后端在运动期间监控原生反馈,故障或连续 0.75 秒没有推进时取消机器人控制任务,
走既有停止与子进程清理流程;取消后的迟到成功也按 cancelled 记录。
显式重新连接时,驱动先调用 Stop 清除机器人端残留动作,再请求 Enable;Stop 失败则不使能。
此软件逻辑不替代实体急停。Qt 顶部急停图标仍未接硬件接口,保持禁用。
验证使用隔离 ROS 域和纯 C++ 状态锁存测试,没有真实机械臂动作测试。
### 底部操作日志
底部日志记录按钮点击、重复点击拦截,以及后端收到请求、拒绝、任务开始、阶段变化、
取消和最终结果。时间精确到毫秒,显示臂号和任务编号;短任务的状态变化也保留为事件,
不依赖界面轮询碰巧看到 running。任务返回的 detail 和完整进程日志路径也会显示。
界面保留最近 3000 行,翻看历史时不强制滚到底部;后端缓存最近 512 条事件,
完整操作事件追加保存到 log/station/operations.jsonl,进程输出保留原独立日志。
### 重写的触觉与平行夹爪模块
参考 `ctag2f-90d-sdk/gripper/panel.py` 的数据处理方式,自行实现本站节点与界面,
不导入、不运行它的 panel、GripperClient、GripperWorker 或自动抓取流程。
低层触觉算法使用 `/home/cheng/orisys-sdk/src` 中的 Orisys SDK。
- `arm_application.tactile_flow_node`:left/right 各自独立 ROS2 节点和进程,
每路独占一个触觉相机;CPU 目标 30 Hz。读取 SDK 的 VRAW 矢量场绘制箭头,
不重复解算光流;没有画面订阅者时不编码 JPEG。不强制 USB reset、不停止别人的进程。
- `/tactile/{left,right}/status`:TactileStatus,含在线、帧率、接触、三向原始力和错误。
这里保留 RAW 通道量纲以兼容现有装配阈值,不把未经校准的值标为牛顿。
- `/tactile/{left,right}/flow/compressed`:CompressedImage/JPEG。预览与状态使用独立通道,
不被 Qt 的低频状态刷新限制。队列只留最新样本,断流显示离线并尝试本侧重连。
- `arm_application.gripper_node`:一个串口一个节点,使用现有经过验证的 DH Modbus 协议层,
仅暴露 `/station/grippers/arm_<id>/execute` 类型化服务与 `status` 状态话题。
无旧 Float32 数组控制入口;连接/探测绝不初始化或移动。当前支持 PGE/PGEA 平行夹爪,
右臂 RGI 旋转夹爪仍须独立匹配驱动,不能用 PGE 寄存器替代。
- 界面:光流区标题右侧提供“刷新传感器”,显示 left/right 帧率和三向力;
夹爪区标题右侧提供“连接 / 探测”,开合使用任务操作按钮和后端默认力/速度。
连接与刷新不下发运动,闭合、张开才写位置,并等待实际到位反馈。
主入口仍是 `bash station/run_station.sh`。重启后在首页分别点“刷新传感器”和夹爪“连接 / 探测”。
本站会自管相应节点,不要同时从其他入口重复启动。ROS 独立启动入口为
`tactile.launch.py`(左右触觉)与 `station_gripper.launch.py arm:=89`(平行爪)。
旧 `dh_pgea.launch.py` / `tactile` executable 保留为兼容入口,主界面不再启动它们。
站点的 `tactile.arm_id` 明确绑定机械臂(当前 89),`sdk_python_path` 为低层 SDK 的 src 路径;
配置 JSON 已复制进本仓库 `src/arm_application/config/tactile/ctag2f90d.json`。
`left_video/right_video: auto` 只选择 USB vendor 1bcf 的触觉 UVC 采集接口,排除 Intel RealSense、
笔记本摄像头与重复接口。首次插入后需确认物理左右;建议改成各自稳定的 `/dev/v4l/by-path/...`,
避免 USB 插口调整导致左右顺序变化。两侧不能指向同一设备,跨进程锁会拒绝重复占用。
额外 CPU 依赖见 `station/requirements-tactile.txt`,已装入现有 ros_flexiv;未另建 conda 环境。
验证为假串口、合成光流、隔离 ROS 域与 Qt 离线回归;未做真实开合或机械臂动作测试。
### RGI STEP 三维模型
主界面机械臂与夹爪均使用 Qt QOpenGLWidget 内嵌渲染,不是 RViz;ROS 提供设备状态。
`run_real.sh` 默认 `use_rviz:=false`,RViz 可作为独立调试工具按需打开。
右臂 RGI 模型来自用户提供的 `RGI-100-30-O-S_简易模型_V3.0_20241010.STEP`(SolidWorks AP203,毫米)。
原件保存在 `station/src/station/assets/models/rgi-100-30/source.step`,离线通过
`scripts/convert_step_mesh.py` 生成 `mesh.npz` 与含原件 SHA256、单位和边界的 `mesh.json`。
当前缓存 35148 个三角面,加载后按米渲染,运行时不依赖 CAD 内核,不在 UI 中转换 STEP。
显示颜色做了提亮处理以便观察边缘,几何保持原始装配位姿。
选择右臂显示真实 RGI CAD 外形,可拖拽旋转观察、双击复位;目前是静态装配模型,
未猜测各零件的运动轴,也未把整个夹爪当作手指错误旋转。待建立零件/关节映射并有
旋转爪实时反馈后再加入开合与旋转动画。左臂仍显示标注为近似的 PGEA 尺寸模型,
不会把 RGI 外形套到左臂。本次没有更改机械臂控制或发出设备命令。
首页夹爪区已精简:标题右侧只留“连接 / 探测”,移除力、速度、目标位置控件;
开合继续使用任务操作按钮及后端默认参数。传感器标题右侧只留“刷新传感器”,
负责重启本站拥有的左右触觉采集节点,重新检测设备并恢复预览,不操作机械臂或夹爪。
两个入口尺寸均为 92×28,与“刷新相机”一致。
### 标定力显示与光流布局
界面 Fx、Fy 置于每张光流的左、右两侧,保留方向正负号;下方显示法向 Fn,均标注 N。
列宽和下方行高固定,数值更新不会推动布局。
`tactile_flow_node` 在保留 `/tactile/<side>/status` 原始值的同时,新增标准
`/tactile/<side>/wrench`(geometry_msgs/WrenchStamped):force.x=Fx、y=Fy、z=Fn,单位牛顿。
装配算法仍接收原始 status,判断阈值不变。没有新鲜标定力话题时界面显示横线,
不会把原始数值直接标成 N。
换算曲线来自用户给定的 CTAG2F-90D 示例项目 `config/gripper.json`,已作为纯数据复制到
`src/arm_application/config/tactile/force_calibration.json`。使用分段线性插值、配置的外推和
零下限,切向分量按绝对值换算后恢复正负号。这是参考项目的现有曲线,并非本次
对左右两只设备逐只做出的实测标定。可在 tactile 配置中设置
`left_force_calibration_file` / `right_force_calibration_file` 覆盖各自数据,
或用 `force_calibration_file` 指定共用曲线;不导入或运行示例面板代码。
### 光流旁的切向力曲线与底图重置
每张光流左右是 X/Y 切向力折线图,显示最近 10 秒(N);四张小图使用相同时间窗
与对称量程,零力在中线。所有图表与画面顶底对齐,下方仍显示 Fn 标定法向力。
采样来自标定 wrench 话题的有界历史,不使用原始力替代。
“重置底图”在“刷新传感器”前。它通过 `/tactile/<side>/reset_reference`(std_srvs/Trigger)
请求本站节点在采集线程里、获取新画面后执行 SDK Sensor.reset(),不重启采集、不操作夹爪。
成功后清空该侧的光流缓存和力历史;离线侧返回未重置,连接恢复后不自动补执行旧请求。
超时明确报告结果未知,不自动重试。建议传感器无受力时操作。
跟踪/对孔/旋入流程与底图重置互斥,避免执行中更换测量参考。
触觉区最新布局:每侧 X 力图、光流、Y 力图为三个等大的正方形画布,随可用宽高适配。
切向力图纵轴固定 −10~10 N,显示数值刻度,超出范围的曲线裁剪;不再自动缩放。
横轴固定滚动 60 秒(−60、−30、0s)。后端标定力历史容量扩至 2100 点,
按实际时间截取最近一分钟,满足 30 Hz 采样;重置底图仍清空对应历史。
触觉标题采用 `left(30fps) 当前法向力 5N` 形式,fps 小字、不使用圆点分隔符,
下方不再重复显示法向力。曲线末端的圆点标记也已去除。
曲线横轴改为最近 1800 个采样点的序号(0/900/1800),纵轴仍固定 ±10 N;
不再由墙钟时间推动横轴或剔除数据。没有新样本时曲线保持不动。
新增轻量 `tactile_preview` 增量通道,携带 JPEG 与自上次序号之后的标定力样本,
最高 30 Hz;Qt 绘图计时器直接消费该缓存,避免低频状态刷新导致曲线跳动。
每次重置/刷新会变更参考版本,使客户端清空旧曲线;重复返回的采样序号不会重复追加。
### 远端相机预览
`config/site.yaml` 的 `remote_camera_preview` 显式选择远端相机。当前读取
192.168.2.10(域 0)的 d405_left / d435 原始图像,通过本机有线地址
192.168.2.50 发现,避免组播走 VPN 虚拟网卡。远端未发布 JPEG 预览时使用 raw。
只有 `station.cam_bridge` 子进程改用远端 ROS 域和网卡;机械臂后端、夹爪与
传感器的环境不变。“刷新相机”重建只读预览订阅并等待画面,不启动/停止
本机或远端物理相机驱动。首页远端订阅不再要求该相机由本机后端持有。
远端模式仅用于显示,不把网络图像自动接入本机机械臂跟踪或标定运动。
关闭 `remote_camera_preview.enabled` 并重启工位即可恢复本站采集模式。

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