仿真夹爪适配器:服务接口对齐真机,开口按正运动学换算成真毫米

为什么
------
应用层只认 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:
arm_dev
2026-09-24 09:46:09 +08:00
parent f5ed44a987
commit 6922b18187
14 changed files with 3118 additions and 92 deletions
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# =============================================================================
# scripts/lib/sim_procs.sh —— 仿真进程扫描(被 stop_sim_gz.sh / run_sim_gz.sh 共用)
#
# 为什么单独抽一层
# ----------------
# "哪些进程算仿真栈"这件事以前只写在 stop_sim_gz.sh 里。新增一个仿真节点
# (sim_gripper_adapter)时**忘了同步那张模式表**,于是:
#
# 脚本说"仿真已停止" → 其实残留了一个夹爪适配器
# → 下一次启动后**两个适配器同时订阅 /joint_states、同时发布 /gripper/status**
# → 服务调用被负载均衡到任意一个;TF 也有两份来源
# → 验收脚本读到的开口/指尖间距全是乱的(实测:开口恒为 0 mm,
# 指尖间距 0.03 mm,而实际是 127 mm)
#
# 这个坑的可怕之处在于**它看起来像夹爪代码写错了**。所以这里做三件事:
#
# 1. 两份脚本共用同一个扫描实现 —— 不再各写一份会漂移的模式表;
# 2. 停止的主通道改成**进程组**:仿真整栈用 setsid 起在自己的会话里,
# PGID 记在 log_sim/sim.pgid,停止时 kill 整组,不依赖模式匹配;
# 3. 模式扫描当兜底(对付 ign 自己 fork 的进程与历史孤儿)。扫描本身是
# "点名表 + install_sim/ 兜底":凡命令行含 ``<仓库>/install_sim/``
# 的进程都算仿真进程,所以**以后再加新节点,不改这个文件也能被扫到**。
# 点名表只用于少数"不在 install_sim 里"的进程(Gazebo 本体、桥接、
# robot_state_publisher、static_transform_publisher)。
#
# 用法(在 `set -uo pipefail` 之后 source):
# source "$(dirname "$0")/lib/sim_procs.sh"
# mapfile -t targets < <(sim_proc_targets) # 只输出 PID(已排除自身/祖先)
# sim_proc_scan # 输出 "PID<TAB>命令行"
# sim_pgid_read / sim_pgid_leader_is_ours / sim_pgid_stop
# =============================================================================
# 兜底模式:仿真安装空间。用绝对路径前缀,避免误伤 install/(真机那边)。
# ⚠️ 路径要按 awk 正则转义:路径里可能有 . + - 之类字符,不转义的话
# "." 会匹配任意字符(匹配面变大 = 误杀无关进程)。
_sim_install_dir() {
printf '%s/install_sim/' "$(_sim_repo_root)"
}
_sim_regex_escape() {
printf '%s' "$1" | sed -e 's/[][\.^$*+?(){}|\\]/\\&/g'
}
# 点名表:列**不在 install_sim/ 下**、但属于仿真栈的进程。
# ign gazebo / gz sim Gazebo 服务端与 GUI(conda Fortress 里可执行名是 ign gazebo)
# parameter_bridge 相机/触觉/clock 的 ros_gz_bridge(在 conda env 的 lib 下)
# robot_state_publisher 发 TF 与 /robot_description(同上,不在 install_sim)
# static_transform_publisher 相机的静态外参(同上是 conda env 里的可执行文件)
# controller_manager.*spawner / gz_ros2_control 控制器与它的 spawner
#
# ⚠️ 这几个必须**点名**:它们的可执行文件来自 conda 环境或 ROS 发行版,
# 命令行里没有 install_sim/ 字样,兜底规则扫不到。
# 实测教训:只留兜底规则时 robot_state_publisher 会漏,而它的症状是
# "脚本说已停止,它还在发上一份 TF 与 /robot_description"。
# 更阴的是 static_transform_publisher:它发的是**latched**的 /tf_static,
# 残留进程会一直发**旧的外参**,表现成"改了相机安装位置却不生效"。
#
# 有意**不**包含 rviz2:headless 模式不起 RViz,用户可能开着 RViz 看别的东西,
# 写进来会误杀无关进程 —— 误杀比漏杀更糟。
SIM_PROC_NAMED='ign gazebo|gz sim|parameter_bridge|ros_gz_bridge|robot_state_publisher|static_transform_publisher|controller_manager.*spawner|gz_ros2_control'
# ---------------------------------------------------------------------------
# 进程组:精确停止的主通道
# ---------------------------------------------------------------------------
# 模式匹配永远是"猜",上面那张表就漏过好几次。更可靠的做法是让**整个仿真栈
# 待在自己的进程组里**:启动时用 setsid 新开一个会话,PID 即 PGID,写进
# log_sim/sim.pgid;停止时 `kill -TERM -<PGID>` 一次性带走整组,
# 不依赖任何模式串。模式扫描退化成兜底(对付 ign 自己 fork 出去的进程、
# 以及历史遗留的孤儿)。
#
# ⚠️ 文件里的 PGID 可能是**过时的**(那次仿真的进程早退出了,PID 被别人复用)。
# 所以用之前必须确认那个进程确实是我们的仿真栈 —— 见 sim_pgid_leader_is_ours。
sim_pgid_file() {
printf '%s/log_sim/sim.pgid' "$(_sim_repo_root)"
}
_sim_repo_root() {
cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd
}
# 读 pgid 文件里的 PGID;没有/格式不对 → 输出空。
sim_pgid_read() {
local f pgid
f="$(sim_pgid_file)"
[ -f "$f" ] || return 0
read -r pgid <"$f" 2>/dev/null || return 0
case "${pgid:-}" in
''|*[!0-9]*) return 0 ;; # 不是纯数字 → 当作无效
esac
printf '%s' "$pgid"
}
# 该 PGID 的组长进程是否确实是仿真栈(防止 PID 复用导致误杀无关进程组)。
sim_pgid_leader_is_ours() {
local pgid="$1" args
args="$(ps -o args= -p "$pgid" 2>/dev/null || true)"
[ -n "$args" ] || return 1
printf '%s' "$args" | grep -Eq 'arm_sim_gazebo|gazebo_sim|ign gazebo|gz sim|ros2 launch|ros2 run'
}
# 组是否还活着(任一组员存活即可)。
sim_pgid_alive() {
local pgid="$1"
kill -0 -- "-$pgid" 2>/dev/null
}
# 停止整组:先 TERM 等一下,仍在就 KILL。成功输出 "stopped"。
sim_pgid_stop() {
local pgid="$1" i
if ! sim_pgid_alive "$pgid"; then
rm -f "$(sim_pgid_file)"
return 0
fi
kill -TERM -- "-$pgid" 2>/dev/null
for i in $(seq 1 20); do
sim_pgid_alive "$pgid" || break
sleep 0.5
done
if sim_pgid_alive "$pgid"; then
echo "进程组 $pgid 没响应 TERM,用 KILL" >&2
kill -KILL -- "-$pgid" 2>/dev/null
sleep 0.5
fi
rm -f "$(sim_pgid_file)"
}
sim_proc_patterns() {
printf '%s|%s' "$SIM_PROC_NAMED" "$(_sim_regex_escape "$(_sim_install_dir)")"
}
# 扫描:输出 "PID<TAB>命令行"。排除本进程/祖先/扫描用的 awk 自身。
#
# ⚠️ 两个必须踩过的坑(原 stop_sim_gz.sh 的注释,这里保留):
# 1. `pkill -f <模式>` 会匹配到自己所在的 shell(命令行里含那个模式),
# 表现成"命令立刻返回、什么都没杀"。
# 2. 模式串用**环境变量**传给 awk,不用 `-v pat=...`:用了 -v 的话 awk 自己的
# 命令行里就含整串模式,于是它匹配到自己,复查时多报一个幽灵 PID。
sim_proc_scan() {
export PAT="$(sim_proc_patterns)"
export ME="${SIM_PROC_SELF:-$$}"
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)
if (args ~ /sim_procs\.sh/) next # 本库自己(含 grep/sed 的读数)
if (args ~ /stop_sim_gz\.sh/) next
if (args ~ /run_sim_gz\.sh/) next
if (args ~ /^\s*awk/) next
print pid "\t" args
}'
}
# 排除自己与全部祖先,只输出 PID。
sim_proc_targets() {
local exclude=() p=$$ pid _rest e skip
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
while read -r pid _rest; do
[ -z "${pid:-}" ] && continue
skip=0
for e in "${exclude[@]}"; do
[ "$pid" = "$e" ] && skip=1 && break
done
[ "$skip" = "1" ] && continue
echo "$pid"
done < <(sim_proc_scan)
}