Files
arm_dev 6922b18187 仿真夹爪适配器:服务接口对齐真机,开口按正运动学换算成真毫米
为什么
------
应用层只认 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>
2026-09-24 09:46:09 +08:00

175 lines
8.0 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
}