仿真夹爪适配器:服务接口对齐真机,开口按正运动学换算成真毫米
为什么
------
应用层只认 arm_msgs/srv/GripperExecute(开口毫米),真机与仿真必须同一套接口。
但 GN01 的 URDF 里 <mimic> 是**线性近似**:直接按线性系数下发 finger_width_joint,
"命令开 20 mm"在 Gazebo 里量出来是 21.75 mm —— 抓不住工件,而且看起来像算法问题。
实测(2026-09-24,Gazebo 内量两指尖间距):
线性下发 20 mm → 实际 21.74 mm(偏差 1.74 mm,超 2 mm 抓取容差的前半步)
走 FK 反解 → 实际 20.00 mm(≤0.5 mm)
怎么改
------
- 新增 sim_gripper_kin.py:解析 URDF 正运动学,算出"主关节值 ↔ 真实开口 mm"的
单调曲线,反解用二分。曲线非单调/零行程时**主动报错**,不静默退化成线性。
- sim_gripper.py:开口换算全部走该曲线;容差统一改成毫米。
- sim_gripper_adapter.py:反馈改取**从动关节**(见下);is_gripped 按毫米判定。
- 新增 check_sim_gripper.py:三层验收(服务/开口一致/指尖真实几何行程 + 对称性)。
踩到的坑
--------
- finger_width_joint 挂在无质量的 unused_finger_width_link 上,与手指**没有机械
连接**:命令到 0.100,它的 /joint_states 恒为 1.4e-08,6 个从动关节才准到
0.7854。反馈若取它,/gripper/status 会永远报"开口 0 mm"。
- 残留进程会伪造出"代码写错了"的现场:旧 sim_gripper_adapter 与新实例抢话题,
验收读到"指尖间距 0.03 mm"(真实 127 mm);孤儿 static_transform_publisher
用 latched /tf_static 把旧相机外参按在新仿真上。
→ 新增 scripts/lib/sim_procs.sh;run_sim_gz.sh 用 setsid 独立进程组,
PGID 记进 log_sim/sim.pgid,stop 时整组 kill;启动前预检拒绝带病启动。
- 验收脚本自己也错过两次(把 TF 的米当毫米比 → 127 mm 显示成 0.13 mm;
用会转动指垫的中点漂移判对称 → 必然位移 16 mm)。判据改成"两侧各走多远",
单位只在 _dist_mm 里换算一次。
验证
----
python3 -m pytest src/arm_sim_gazebo/test/ -q → 307 passed
python3 scripts/check_sim_gripper.py → 三层全过
第 2 层:请求/反馈开口最大偏差 < 0.5 mm
第 3 层:期望行程 vs 实际行程、左右行程差,均 < 1.5 mm
bash scripts/stop_sim_gz.sh --list → 整组列出(含 static tf)
文档
----
- 仿真手册:新增 §4.4(开口 mm 怎么换算)、坑 12(宽度关节不动、
反馈必须取从动关节);停止一节改写成进程组语义。
- FRAMEWORK_RULES:新增 R4.11(残留进程会伪造现场)、R4.12(验收判据要挑
与实现无关的物理量);R4.9 表补夹爪;清单补 13/14 两条。
- HANDBOOK:新增 §3.4「在仿真里开合夹爪」,补 §4.4 的两条新教训。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+53
-74
@@ -22,83 +22,61 @@ set -uo pipefail
|
||||
LIST_ONLY=0
|
||||
[ "${1:-}" = "--list" ] && LIST_ONLY=1
|
||||
|
||||
# 这些模式覆盖仿真栈的全部进程:
|
||||
# gz sim / ign gazebo 服务端与 GUI(conda 的 Fortress 里可执行名是 ign gazebo)
|
||||
# gazebo_sim.launch.py ros2 launch 的壳
|
||||
# gazebo_sim_launcher 入口可执行文件(展开描述后转交 launch)
|
||||
# parameter_bridge 相机/触觉/clock 的桥接
|
||||
# sim_camera_adapter / sim_tactile_adapter 两个适配器
|
||||
# robot_state_publisher 发 TF 与 /robot_description
|
||||
# controller_manager.*spawner / gz_ros2_control 控制器
|
||||
# \"哪些进程算仿真栈\"这张表**不在本文件里**,在 scripts/lib/sim_procs.sh:
|
||||
# 它同时被 run_sim_gz.sh(启动前的预检)使用,两处共用一份实现,不会漂移。
|
||||
#
|
||||
# ⚠️ 这里漏过一个(robot_state_publisher),症状是"脚本说已停止,但它还在跑":
|
||||
# 下一次启动时它继续发上一份 /robot_description 与 TF,而两个进程的
|
||||
# 参数可能不同 —— 表现成"改了描述却不生效",极难往"有个残留进程"上想。
|
||||
# 所以脚本末尾加了一次**复查**:杀完再扫一遍,有残留就报出来并非零退出。
|
||||
# 加进新进程时,务必确认复查也能扫到它。
|
||||
# 历史教训(都真实发生过,所以规则才写这么细):
|
||||
# - 漏过 robot_state_publisher:脚本说"已停止",它还在跑;下一次启动时
|
||||
# 继续发上一份 /robot_description 与 TF,表现成"改了描述却不生效"。
|
||||
# - 漏过 static_transform_publisher:更阴 —— 它发的是 latched 的 /tf_static,
|
||||
# 残留进程会一直把**旧相机外参**按在新仿真的 TF 树上。
|
||||
# - 漏过 sim_gripper_adapter:残留的适配器与新适配器**同时**订阅 /joint_states、
|
||||
# 同时发布 /gripper/status,服务调用还会被随机分给其中一个。
|
||||
# 验收脚本读到的开口恒为 0 mm、指尖间距 0.03 mm(实际 127 mm),
|
||||
# 看起来就像夹爪代码算错了 —— 这是最难查的一类残留。
|
||||
#
|
||||
# 有意**不**包含 rviz2:本仿真栈的 headless 模式不起 RViz,而用户可能同时
|
||||
# 开着 RViz 看别的东西。把它写进来会误杀无关进程,比漏杀更糟。
|
||||
PATTERNS='ign gazebo|gz sim|gazebo_sim\.launch\.py|gazebo_sim_launcher|parameter_bridge|sim_camera_adapter|sim_tactile_adapter|robot_state_publisher|controller_manager.*spawner|gz_ros2_control'
|
||||
# 所以停止分两步:
|
||||
# ① 主通道:run_sim_gz.sh 用 setsid 起仿真,PGID 记在 log_sim/sim.pgid,
|
||||
# `kill -TERM -PGID` 一次带走整组(**不依赖任何模式串**);
|
||||
# ② 兜底:模式扫描(点名表 + install_sim/ 兜底),收拾历史孤儿与
|
||||
# ign 自己 fork 出去的进程;杀完再扫一遍,有残留就报出来并非零退出。
|
||||
# shellcheck source=lib/sim_procs.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib/sim_procs.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 扫描:输出 "PID<TAB>命令行",供两次调用(收集 / 复查)共用。
|
||||
#
|
||||
# 两处必须共用同一个实现,否则"复查用的模式"和"杀的时候用的模式"会各自漂移,
|
||||
# 而漂移的表现就是"说已停止、其实有残留"——正是这个脚本要防的事。
|
||||
#
|
||||
# ⚠️ 模式串用**环境变量**传给 awk,不用 `-v pat=...`:
|
||||
# 用了 -v 的话,awk 自己的命令行里就含整串模式,于是它匹配到自己。
|
||||
# 这是 pkill -f 那个坑的同款,只是换了个位置 —— 实测表现为每次复查都会
|
||||
# 多报出一个"没有命令行"的幽灵 PID(awk 已经退出,ps 查不到它了)。
|
||||
#
|
||||
# 另外用 ps 而不是 pgrep -f:ps 的输出里可以用 awk 精确控制字段与排除规则。
|
||||
scan_pids() {
|
||||
# ⚠️ 必须 export:`VAR=x ps ... | awk` 只会把 VAR 给 ps,awk 看不到。
|
||||
export PAT="$PATTERNS"
|
||||
ps -eo pid=,ppid=,args= | awk '
|
||||
BEGIN { pat = ENVIRON["PAT"]; me = ENVIRON["ME"] }
|
||||
$0 ~ pat {
|
||||
if (NF < 3) next # 没有 args 字段(僵尸/内核线程):跳过
|
||||
pid = $1; ppid = $2
|
||||
args = substr($0, index($0, $3))
|
||||
if (ppid == me) next # 本脚本的直接子进程(进程替换 fork 出的子 shell)
|
||||
if (args ~ /stop_sim_gz\.sh/) next # 本脚本自己 / 调用它的壳的文本
|
||||
if (args ~ /^\s*awk/) next # 扫描用的 awk 本身
|
||||
print pid "\t" args
|
||||
}'
|
||||
}
|
||||
export ME=$$
|
||||
PGID="$(sim_pgid_read)"
|
||||
if [ -n "$PGID" ] && sim_pgid_alive "$PGID"; then
|
||||
if ! sim_pgid_leader_is_ours "$PGID"; then
|
||||
echo "⚠ log_sim/sim.pgid 里的进程组 $PGID 看起来不是仿真栈(PID 可能被复用),忽略它。"
|
||||
PGID=""
|
||||
fi
|
||||
else
|
||||
PGID=""
|
||||
fi
|
||||
|
||||
# 排除自己与全部祖先 —— 这是本脚本存在的核心理由(见文件头)。
|
||||
exclude=()
|
||||
p=$$
|
||||
while [ "$p" -gt 1 ] 2>/dev/null; do
|
||||
exclude+=("$p")
|
||||
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')
|
||||
[ -z "$p" ] && break
|
||||
done
|
||||
if [ "$LIST_ONLY" = "1" ]; then
|
||||
if [ -n "$PGID" ]; then
|
||||
echo "仿真的进程组:$PGID(kill -TERM -$PGID 可整组停掉)"
|
||||
ps -o pid=,args= -g "$PGID" 2>/dev/null | cut -c1-150
|
||||
fi
|
||||
fi
|
||||
|
||||
# 从扫描结果里剔掉自己/祖先,只输出 PID。
|
||||
# 用 `cut -f1` 而不是 read 到数组再拼接,避免命令行里再出现长的进程描述。
|
||||
filter_targets() {
|
||||
local pid
|
||||
while read -r pid _rest; do
|
||||
[ -z "$pid" ] && continue
|
||||
local skip=0
|
||||
for e in "${exclude[@]}"; do
|
||||
[ "$pid" = "$e" ] && skip=1 && break
|
||||
done
|
||||
[ "$skip" = "1" ] && continue
|
||||
echo "$pid"
|
||||
done
|
||||
}
|
||||
# 模式扫描的候选(--list 时先打印,再决定动不动手)。
|
||||
mapfile -t targets < <(sim_proc_targets)
|
||||
|
||||
mapfile -t SCAN < <(scan_pids)
|
||||
mapfile -t targets < <(printf '%s\n' "${SCAN[@]:-}" | filter_targets)
|
||||
if [ "${#targets[@]}" = "0" ] && [ -z "$PGID" ]; then
|
||||
echo "没有正在运行的仿真进程(已排除本脚本自身与调用它的 shell)。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$PGID" ] && [ "$LIST_ONLY" != "1" ]; then
|
||||
echo "停止仿真进程组 $PGID(整组,含不在 install_sim/ 下的节点)…"
|
||||
sim_pgid_stop "$PGID"
|
||||
# 组信号发完后重新扫一遍:可能还有孤儿。
|
||||
mapfile -t targets < <(sim_proc_targets)
|
||||
fi
|
||||
|
||||
if [ "${#targets[@]}" = "0" ]; then
|
||||
echo "没有正在运行的仿真进程(已排除本脚本自身与调用它的 shell)。"
|
||||
echo "仿真已停止。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -136,15 +114,16 @@ done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 复查:杀完再扫一遍。
|
||||
# 这一步是为了让"模式漏了某个进程"变成**看得见的告警**,而不是
|
||||
# "脚本说已停止、实际有残留"。robot_state_publisher 就这么漏过一次。
|
||||
# 这一步是为了让"表漏了某个进程"变成**看得见的告警**,而不是
|
||||
# "脚本说已停止、实际有残留"。robot_state_publisher 与 sim_gripper_adapter
|
||||
# 都这么漏过,而后者导致的假象是"夹爪算错了"。
|
||||
# ---------------------------------------------------------------------------
|
||||
sleep 0.5
|
||||
mapfile -t SCAN2 < <(scan_pids)
|
||||
mapfile -t leftover < <(printf '%s\n' "${SCAN2[@]:-}" | filter_targets)
|
||||
mapfile -t leftover < <(sim_proc_targets)
|
||||
|
||||
if [ "${#leftover[@]}" != "0" ]; then
|
||||
echo "⚠ 仍有仿真相关进程存活(说明上面的模式没覆盖它,请把它加进 PATTERNS):"
|
||||
echo "⚠ 仍有仿真相关进程存活(说明它既不在 lib/sim_procs.sh 的点名表里,"
|
||||
echo " 也不在 install_sim/ 下 —— 请把它加进点名表,或让它从 install_sim/ 启动):"
|
||||
for pid in "${leftover[@]}"; do
|
||||
printf ' %-7s %s\n' "$pid" "$(ps -o args= -p "$pid" 2>/dev/null | cut -c1-140)"
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user