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

为什么
------
应用层只认 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
+53 -74
View File
@@ -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