Files
ros_contorl/scripts/check_sim_gripper.py
T
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

412 lines
20 KiB
Python
Raw 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 python3
"""仿真夹爪的验收:**几何**行程对不对,不是"服务有返回"。
为什么需要它
------------
夹爪出问题的方式几乎全是静默的:
* 耦合链只套了一层 multiplier —— 夹爪会动、角度也是那个量级,就是夹不准;
* 命令数组顺序与控制器不一致 —— 角度发给了别的关节,夹爪"动得很奇怪";
* 毫米与米混用(差 1000 倍) —— 夹爪开到别的地方;
* 夹爪适配器根本没起来 —— 服务不存在,但**话题照旧**、控制器照旧 active。
前三条都能通过"调服务有 success=true、status 里有数"这种验收。
所以这里分三层,只有第二、三层算数:
第 1 层 /gripper/* 服务在、/gripper/status 有数据 (不算验收)
第 2 层 请求开口 mm == 关节反馈换回来的开口 mm (证明下发链路)
第 3 层 两个指尖在 world 系里的**实际间距**随请求变化,
且"合拢→张开"的行程 == 请求的开口 (证明耦合与几何)
第 3 层是关键:第 2 层只说明"关节值到位了"。如果 mimic 的 multiplier 写错,
手指根本不会按预期张开,而第 2 层完全看不出来。也正因为这一层是**独立**的,
它才逼出了下面两个真实故障 —— 都是"验收脚本自己错",比被测对象错更常见:
⚠️ 三个踩过的坑(都让本脚本先给出过错误结论)
--------------------------------------------
1. **米 / 毫米**。TF 查出来的坐标是米,而本脚本的阈值与期望值全是毫米。
漏掉 ×1000 时,127 mm 会显示成 0.13 mm —— 恰好"看起来像夹爪没张开",
于是跑去查耦合公式,而真正的问题就在本文件里。(判据全部走 _dist_mm。)
2. **残留进程**。旧会话没停干净时,会有两个 sim_gripper_adapter 同时订阅
/joint_states、同时发布 /gripper/status,服务调用还会被随机分给其中一个;
残留的 static_transform_publisher 更阴 —— 它发 latched 的 /tf_static,
会把**旧相机外参**按在新仿真的 TF 树上。症状一样是"读数全乱"。
→ 所以本脚本第一步查重名节点(`:meth:`GripperCheck._duplicate_nodes`),
启动侧也加了闸门(scripts/run_sim_gz.sh 的预检 + setsid 进程组)。
3. **判据要与机构无关**。两侧"对称"不能用"两指中点漂移"来判:tip frame 是
刚性固连在**会转动**的指垫上的(指垫既平移又转,指面才保持平行),所以
frame 走的是弧线,中点必然位移 16 mm 左右。真正与方向无关的判据是
"两侧各自走了多远"(实测 52.56 / 52.56 mm)。
⚠️ 指尖坐标系原点的固有间距
实测两个 `*_finger_tip` frame 在完全合拢时仍然相距 27.09 mm(tip frame 不在
夹持面上,而且会随手指转动)。所以**不要**拿"指尖间距"直接当开口看;
本脚本用它的**变化量**(行程)判据 —— 那个变化量与开口是 1:1 的
(实测:请求 8 / 20 / 50 mm → 间距增加 8.00 / 20.00 / 50.00 mm)。
用法
----
source scripts/sim_env.sh
python3 scripts/check_sim_gripper.py # 需要 Gazebo 仿真已在跑
python3 scripts/check_sim_gripper.py --verbose # 另打印每档的指尖原始坐标
退出码:0 = 通过;1 = 未通过;2 = 环境不可用(服务不在 / 有重名节点)。
"""
from __future__ import annotations
import argparse
import math
import sys
import time
from typing import Dict, List, Optional, Tuple
import rclpy
from rclpy.node import Node
from tf2_ros import Buffer, TransformListener
try:
from arm_msgs.msg import GripperStatus
from arm_msgs.srv import GripperExecute
except ModuleNotFoundError as exc: # pragma: no cover - 只在环境没 source 时触发
# 直接让 Python 抛"找不到模块"很难定位:真正的原因通常是**忘了 source 环境**,
# 而不是 arm_msgs 没编译。所以这里把原因和修法一次说清。
sys.exit(
f"找不到 {exc.name}:请先 source 仿真环境(它把 install_sim/ 加进 PYTHONPATH):\n"
f" source scripts/sim_env.sh\n"
f"再执行本脚本。若仍失败,说明 install_sim/ 还没构建:\n"
f" bash scripts/build_sim.sh"
)
#: 指尖坐标系的**固有**间距不影响行程判据,但打印出来省得有人误读。
TIP_LEFT = "sim_left_finger_tip"
TIP_RIGHT = "sim_right_finger_tip"
WORLD = "world"
#: 每个开口请求之后等多久再量(位置控制器 + 物理稳定)。
SETTLE_S = 2.5
#: 第 2 层容差(毫米):请求开口 vs 关节反馈换回的开口。
POSITION_TOL_MM = 0.5
#: 第 3 层容差(毫米):指尖行程 vs 期望行程。
TRAVEL_TOL_MM = 1.5
#: 左右对称性容差(毫米):两侧位移量之差。
SYMMETRY_TOL_MM = 1.5
def _spin(node: Node, seconds: float) -> None:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
rclpy.spin_once(node, timeout_sec=0.05)
def _tip_positions(buf: Buffer, node: Node, attempts: int = 40):
"""取两个指尖在 world 系里的位置;查不到返回 None。"""
for _ in range(attempts):
try:
left = buf.lookup_transform(WORLD, TIP_LEFT, rclpy.time.Time())
right = buf.lookup_transform(WORLD, TIP_RIGHT, rclpy.time.Time())
return (
(left.transform.translation.x, left.transform.translation.y, left.transform.translation.z),
(right.transform.translation.x, right.transform.translation.y, right.transform.translation.z),
)
except Exception:
_spin(node, 0.2)
return None
def _dist_pts(a, b) -> float:
"""两点距离,**米**(原始量纲,调用方自己决定怎么用)。"""
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
def _dist_mm(a, b) -> float:
"""两个位置之间的距离,**毫米**。
⚠️ 这个换算漏过一次,症状很典型:TF 查出来的坐标是**米**,而本脚本里所有
阈值/期望值都是**毫米**(POSITION_TOL_MM、TRAVEL_TOL_MM、请求开口…)。
漏掉 *1000 的话,127 mm 会显示成 0.13 —— 恰好"看起来像夹爪没张开",
于是跑去看耦合公式,而真正的问题只在本文件这一行。
(实测:同一份数据下,指尖坐标是对的,只是判据全被缩小了 1000 倍。)
"""
return 1000.0 * _dist_pts(a, b)
# ⚠️ 属性名都要避开 rclpy.Node 自己的内部属性:`self._clients` 是 Node 的私有
# 成员(一个 list),覆盖它会直接在 create_client 里崩(实测踩过)。
# 所以这里统一用 `_grip_clients` / `_last_status` / `_tf_buf`。
class GripperCheck(Node):
def __init__(self) -> None:
super().__init__("sim_gripper_check")
self._tf_buf = Buffer()
TransformListener(self._tf_buf, self)
self._last_status: Optional[GripperStatus] = None
self.create_subscription(GripperStatus, "/gripper/status", self._on_status, 10)
self._grip_clients: Dict[str, object] = {}
for verb in ("open", "close", "move"):
self._grip_clients[verb] = self.create_client(GripperExecute, f"/gripper/{verb}")
def _on_status(self, msg: GripperStatus) -> None:
self._last_status = msg
# --- 基础设施 ---------------------------------------------------------
def wait_ready(self, timeout: float = 30.0) -> List[str]:
"""等服务和状态都就绪。返回缺失项(空列表=就绪)。"""
missing = []
for verb, cli in self._grip_clients.items():
if not cli.wait_for_service(timeout_sec=timeout): # type: ignore[attr-defined]
missing.append(f"/gripper/{verb}")
if self._last_status is None:
_spin(self, 3.0)
if self._last_status is None:
missing.append("/gripper/status(话题无数据)")
return missing
def call(self, verb: str, position: float = 0.0) -> Tuple[bool, str]:
cli = self._grip_clients[verb]
req = GripperExecute.Request()
req.command.command = verb
req.command.position = float(position)
fut = cli.call_async(req) # type: ignore[attr-defined]
while not fut.done():
_spin(self, 0.05)
res = fut.result()
if res is None:
return False, "服务没有返回"
return bool(res.success), str(res.message)
def movement(self, verb: str, position: float = 0.0):
"""下发一条命令,等稳定,返回 (请求说明, 状态开口, 两指尖, 是否成功)。"""
ok, msg = self.call(verb, position)
_spin(self, SETTLE_S)
tips = _tip_positions(self._tf_buf, self)
aperture = float(self._last_status.position) if self._last_status else None
return ok, msg, aperture, tips
# --- 三层验收 ---------------------------------------------------------
def _duplicate_nodes(self) -> List[str]:
"""查出"同名节点有两个"的情况。
⚠️ 这是踩出来的:残留的旧会话与新会话同时活着时,两个
``sim_gripper_adapter`` 会同时订阅 /joint_states、同时发布
/gripper/status,服务调用还会被随机分给其中一个 ——
验收脚本读到的开口恒为 0 mm、指尖间距 0.03 mm(真实值 127 mm),
看起来完全像夹爪换算写错了。
所以验收第一步就是确认"图里只有一个我"。启动侧的那道闸门在
scripts/run_sim_gz.sh 的预检里(拒绝起第二个仿真),这里是第二道。
"""
names = self.get_node_names()
dupes = []
for known in (
"sim_gripper_adapter",
"sim_camera_adapter",
"sim_tactile_adapter",
"robot_state_publisher",
"joint_state_broadcaster",
"gripper_controller",
):
n = sum(1 for name in names if name.strip("/") == known)
if n > 1:
dupes.append(f"{known} × {n}")
return dupes
def check(self, verbose: bool) -> int:
dupes = self._duplicate_nodes()
if dupes:
print("环境不干净:仿真节点有重名(说明有残留的旧会话在跑):", file=sys.stderr)
for d in dupes:
print(f" - {d}", file=sys.stderr)
print(
"\n两个同名节点会抢话题/服务/TF,读数全是乱的。先停干净:\n"
" bash scripts/stop_sim_gz.sh\n"
"再重启(run_sim_gz.sh 自己也会拒绝在残留状态下启动)。",
file=sys.stderr,
)
return 2
missing = self.wait_ready()
if missing:
print("环境不可用,缺:", file=sys.stderr)
for m in missing:
print(f" - {m}", file=sys.stderr)
print(
"\n先确认仿真在跑(bash scripts/run_sim_gz.sh),"
"且夹爪适配器起来了(ros2 node list | grep sim_gripper_adapter)。",
file=sys.stderr,
)
return 2
print("第 1 层:服务与状态")
print(f" /gripper/{{open,close,move}} 可用;/gripper/status 有数据(开口 {self._last_status.position:.2f} mm)")
# --- 第 2、3 层:走一遍开口序列 -----------------------------------
requests: List[Tuple[str, float]] = [
("close", 0.0),
("move", 8.0),
("move", 20.0),
("move", 50.0),
("open", 0.0),
]
rows = []
for verb, pos in requests:
ok, msg, aperture, tips = self.movement(verb, pos)
label = "全开" if verb == "open" else ("全闭合" if verb == "close" else f"{pos:.0f} mm")
rows.append((label, float(pos) if verb == "move" else None, ok, msg, aperture, tips))
print("\n第 2 层:请求开口 vs 关节反馈换回的开口(下发链路)")
print(f" {'命令':<10}{'期望开口':>10}{'状态开口':>10}{'差(mm)':>10} ")
l2_fail = []
# "期望开口"对 open 来说是仿真行程,从状态里推出来(open 时的读数)。
open_row = rows[-1]
stroke = open_row[4]
for label, want, ok, msg, aperture, tips in rows:
expect = stroke if label == "全开" else (0.0 if label == "全闭合" else want)
if aperture is None:
l2_fail.append(f"{label}: 拿不到状态开口")
print(f" {label:<10}{expect:>10.2f}{'--':>10}{'--':>10} 状态缺失")
continue
diff = aperture - (expect or 0.0)
bad = abs(diff) > POSITION_TOL_MM
if bad:
l2_fail.append(f"{label}: 状态开口 {aperture:.2f} != 期望 {expect:.2f}")
if not ok:
l2_fail.append(f"{label}: 服务返回失败({msg})")
print(f" {label:<10}{expect:>10.2f}{aperture:>10.2f}{diff:>10.2f} {'✗' if bad else '✓'} {msg}")
print("\n第 3 层:指尖的**实际**位移(真几何,这一层才证明耦合是对的)")
tips_ok = all(r[5] is not None for r in rows)
if not tips_ok:
print(" 查不到指尖 TF,无法验证几何 —— 这不算通过。", file=sys.stderr)
return 1
# ⚠️ 这一层**故意不用 status 的开口读数**当基准:开口反馈取自从动关节,
# 而第 2 层已经在验那条链路。这里只认"我请求了多少毫米",
# 几何摆在那里,跟任何软件读数无关 —— 这才叫独立验证。
closed_tips = rows[0][5]
open_tips = rows[-1][5]
span_closed = _dist_mm(*closed_tips)
span_open = _dist_mm(*open_tips)
spans = [_dist_mm(*r[5]) for r in rows]
print(f" 合拢:指尖间距 {span_closed:7.2f} mm(tip frame 固有间距,不是开口)")
print(f" 全开:指尖间距 {span_open:7.2f} mm")
# 逐档对比"请求开口"与"实测间距增量":仿真里 1 mm 开口 ≙ 1 mm 间距,
# 所以两者必须相等(探针实测 27.02→127.02 mm,正好 1.000)。
travel_bad = []
for label, want, ok, msg, aperture, tips in rows:
if want is None:
continue # close / open 的 request 不是毫米数,单独判
actual = _dist_mm(*tips) - span_closed
err = actual - want
bad = abs(err) > TRAVEL_TOL_MM
if bad:
travel_bad.append(
f"请求 {want:.0f} mm,指尖实际张开 {actual:.2f} mm(差 {err:+.2f})"
)
print(
f" 请求 {want:5.1f} mm:指尖张开 {actual:7.2f} mm "
f"差 {err:+6.2f} mm {'✗' if bad else '✓'}"
)
# 全开那一档用**几何自洽**来判:必须比 50 mm 那档还大,且与第 2 层的
# 开口读数一致(两者都错成一样是不可能的)。
if stroke:
err = (span_open - span_closed) - stroke
if abs(err) > TRAVEL_TOL_MM:
travel_bad.append(
f"全开行程 {span_open - span_closed:.2f} mm != 开口读数 {stroke:.2f} mm"
)
print(
f" 全开行程 {span_open - span_closed:7.2f} mm 期望 {stroke:.2f} mm "
f"差 {err:+.2f} mm {'✓' if abs(err) <= TRAVEL_TOL_MM else '✗'}"
)
# 单调性:请求越大,间距必须越大(方向错了会在这里露出来)。
monotonic = all(b > a for a, b in zip(spans, spans[1:]))
print(f" 单调递增:{'✓' if monotonic else '✗ ' + ' → '.join(f'{s:.2f}' for s in spans)}")
# 对称性:两侧**行程相等**才算这条耦合链是对的。
#
# ⚠️ 这里换过两次判据,两次都是"判据本身错了",值得记下来:
# 1) 最初把两侧位移投影到"左指的张开方向"再比大小 → 一个完全
# 对称的夹爪被判成不对称(左 +52.6 / 右 -42.5)。因为左指的
# 位移方向自己带着 x 分量,拿它当基准去投影右侧必然偏。
# 2) 改成"两指中点漂移" → 又误判(漂移 16.29 mm,而两侧各走
# 52.56 / 52.55 mm,好得不能再好)。原因:tip frame 是刚性固连
# 在**会旋转**的指垫上的(指垫既平移又转动,指面才保持平行),
# 所以 frame 走的是弧线 —— 两侧弧线的"共同分量"让中点发生位移,
# 这跟"左右不对称"是两回事。
# 真正与方向无关、又能反映耦合好坏的量只有一个:**两侧各自走了多远**。
left_move = 1000.0 * _dist_pts(closed_tips[0], open_tips[0])
right_move = 1000.0 * _dist_pts(closed_tips[1], open_tips[1])
asym = abs(left_move - right_move)
asym_bad = asym > SYMMETRY_TOL_MM
mid_closed = tuple((a + b) / 2.0 for a, b in zip(*closed_tips))
mid_open = tuple((a + b) / 2.0 for a, b in zip(*open_tips))
mid_drift = 1000.0 * _dist_pts(mid_closed, mid_open)
print(
f" 对称性:左右各走 {left_move:.2f} / {right_move:.2f} mm,"
f"差 {asym:.2f} mm {'✗' if asym_bad else '✓'}"
)
print(
f" (参考:两指中点整体位移 {mid_drift:.2f} mm。tip frame 固连在"
f"会转动的指垫上,会画弧,所以这个数不为 0 是正常的,不参与判据。)"
)
if verbose:
print("\n明细:")
for label, _want, ok, msg, aperture, tips in rows:
d = "--" if tips is None else f"{_dist_mm(*tips):.2f}"
print(f" {label:<10} ok={ok} 开口={aperture} 指尖间距={d} {msg}")
# 原始坐标也打出来:上面那一列数字是"间距",看着对/错时都需要
# 回头核对它到底取到了哪两个点(历史上这里出过取错帧的假象)。
print("\n 指尖坐标(world 系,米):")
for label, _want, _ok, _msg, _ap, tips in rows:
if tips is None:
print(f" {label:<10} --")
continue
lx, rx = tips
print(
f" {label:<10} 左=({lx[0]:+.4f},{lx[1]:+.4f},{lx[2]:+.4f}) "
f"右=({rx[0]:+.4f},{rx[1]:+.4f},{rx[2]:+.4f})"
)
problems = list(l2_fail)
problems.extend(travel_bad)
if not monotonic:
problems.append("开口不是单调递增 —— 方向或顺序反了")
if asym_bad:
problems.append(
f"左右不对称(左右行程差 {asym:.2f} mm)—— 某一侧耦合没生效"
)
print()
if problems:
print("结论:不通过", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 1
print("结论:通过(下发链路 + 耦合几何 + 方向 + 对称性)")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--verbose", action="store_true", help="打印每一档的明细")
args = ap.parse_args()
rclpy.init()
node = GripperCheck()
try:
return node.check(args.verbose)
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
raise SystemExit(main())