真机落地前的仿真补齐:夹持/腕力/真值位姿,让仿真能验证 S07–S09

这批是 09-24 那轮仿真工作的收尾,一直没提交。内容按目的分四块:

1) 仿真真能「夹住」工件
   sim_grasp_lock:给规与夹爪之间加接触锁(默认关,run_gauge_assembly --sim 才开)。
   没有它时仿真里规只靠摩擦,S08 棘轮一转就滑出指间,于是把「仿真不真」误读成
   「力判据不对」。新增 test_sim_grasp_lock.py。

2) 腕部力传感器进仿真
   sim_wrist_ft(+ config/wrist_ft.yaml):S07 的首选判据是腕部 FT 的轴向突变,
   而仿真里以前根本没有腕部力,S07 只能退到触觉兜底,两套判据从没在同一场景下比过。
   现在两条通道都在,接触判定可对照。

3) 真值位姿可查(不再靠猜)
   sim_live_pose / sim_pose_service:把 Gazebo 里刚体的实测位姿发布出来,
   stage2 的真值路径按**实测**位姿算孔位,而不是按场景文件里的标称位姿。
   顺带把真值孔位的推导(truth_holes)独立出来并补测。

4) 工具几何成为可配置项
   config/sim_tool_geometry.yaml + stage2 的 tool_tip_below_flange_m 启动参数:
   仿真里规会在指间下滑,工具长每次装夹都不同(实测见过 243/254mm),
   悬停高度与 S07 下探预算必须按实测值算,不能再写死。

应用层同时跟进:gauge_fsm / gauge_config 把「悬停时工具尖端入没入工件」的判据
(hover_z_guard)与拖件判定(drag_verdict)收成纯函数并补测;stage3 搜孔、
site 导出、run_gauge_assembly 与若干文档同步。
新增 test_gauge_exit_lift / test_gauge_gripper_client / test_node_handler_collisions。

测试:全仓库 1085 passed。
This commit is contained in:
arm_dev
2026-09-28 10:17:06 +08:00
parent 1b4d6168d9
commit c1cd862eea
41 changed files with 5327 additions and 115 deletions
+140 -2
View File
@@ -329,6 +329,134 @@ def check_d435_visibility(scene, root) -> tuple[list[str], str]:
return probs, info
def check_table_slot(scene, root) -> tuple[list[str], str]:
"""台面通槽 vs 工件:既要"规的通端过得去",又要"工件掉不下去"。
两个方向都会出事,而且都是**静默**的(2026-09-24 各栽过一次):
a) 槽开得不够 **窄**(< 规的通端):规下压到 12mm 就顶在台面上,
S07 一路压到上限都等不到台肩落面 → 报"没触发接触判据",
看起来像传感器/阈值坏了。
b) 槽开得**太宽**:工件跨在槽上时承压面太小,一有扰动就翻进槽里、
落到地面 —— 于是 `perception.source=truth` 开始撒谎:孔位按名义位姿
投到像素上,而孔位正下方已经不是工件(是槽底/地面),
深度反投影把 58mm 孔距算成 169mm,S05 连丢 9 拍超时。
所以这里同时查两件事:孔位必须落在**开口**内,承压宽度必须**够**。
"""
import xml.etree.ElementTree as ET
from arm_sim_gazebo.sim_scene import rings_local, world_boxes
from arm_sim_gazebo.sim_site import resolve_world
world_path, _ = resolve_world("", root)
try:
tree = ET.parse(world_path)
except (OSError, ET.ParseError) as exc:
return [f"读不出 world({world_path}):{exc}"], ""
table = None
for m in tree.getroot().iter("model"):
if m.get("name") == "work_table":
table = m
break
if table is None:
return [f"{world_path} 里没有 <model name=\"work_table\"> —— 台面被改名了?"], ""
def _pose_xyz(node) -> tuple[float, float, float]:
p = node.find("pose")
if p is None or not (p.text or "").strip():
return (0.0, 0.0, 0.0)
v = [float(x) for x in p.text.split()[:3]]
while len(v) < 3:
v.append(0.0)
return (v[0], v[1], v[2])
my = _pose_xyz(table)[1]
spans: list[tuple[float, float]] = []
for link in table.findall("link"):
ly = my + _pose_xyz(link)[1]
for col in link.findall("collision"):
box = col.find("geometry/box/size")
if box is None or not (box.text or "").strip():
continue
cy = ly + _pose_xyz(col)[1]
half = float(box.text.split()[1]) / 2.0
spans.append((cy - half, cy + half))
if not spans:
return ["work_table 里读不到任何 box 碰撞体 —— 台面几何解析不出来"], ""
spans.sort()
b = scene.table_bounds
# 开口 = 台面范围内的"没被碰撞体盖住"的 y 区间。
gaps: list[tuple[float, float]] = []
cur = b.y_min
for lo, hi in spans:
if lo > cur + 1e-9:
gaps.append((cur, min(lo, b.y_max)))
cur = max(cur, hi)
if cur < b.y_max - 1e-9:
gaps.append((cur, b.y_max))
gaps = [(lo, hi) for lo, hi in gaps if hi - lo > 1e-6]
part = next((o for o in scene.objects if o.name == scene.operating.part_object), None)
if part is None:
return [f"场景里没有 {scene.operating.part_object!r}"], ""
boxes = world_boxes(scene, part, root)
if not boxes:
return ["读不出工件的碰撞体"], ""
wy0 = min(float(v) for bx in boxes for v in (bx[2], bx[3]))
wy1 = max(float(v) for bx in boxes for v in (bx[2], bx[3]))
# 承压宽度 = 工件在 y 方向上被台面碰撞体盖住的长度(区间求交后累加)。
supported = 0.0
for lo, hi in spans:
supported += max(0.0, min(hi, wy1) - max(lo, wy0))
rings = rings_local(scene, part, root)
hole_ys: list[float] = []
for r in rings:
c, s = math.cos(float(part.yaw)), math.sin(float(part.yaw))
hole_ys.append(float(part.y) + s * float(r.x) + c * float(r.y))
probs: list[str] = []
info = (
f"台面沿 y 的支撑区间 {[(round(lo, 4), round(hi, 4)) for lo, hi in spans]};"
f"开口 {[(round(lo, 4), round(hi, 4)) for lo, hi in gaps]};"
f"工件 y∈[{wy0:.4f},{wy1:.4f}] 承压 {supported * 1000:.1f}mm"
)
# (a) 孔位必须落在开口里 —— 否则规的通端顶在台面上,S07 永远等不到台肩落面。
for i, hy in enumerate(hole_ys):
ok = any(lo < hy < hi for lo, hi in gaps)
if not ok:
probs.append(
f"第 {i + 1} 个孔(世界 y={hy:+.4f})**不在台面开口内** —— "
f"规的通端会顶在台面上。\n"
f" 后果:S07 下压 12mm 就到底,一路到上限都等不到台肩落面,"
f"报「没触发接触判据」,看起来像传感器/阈值坏了。\n"
f" 修法:把开口挪到孔位下方(worlds/assembly.world 的 work_table "
f"碰撞体 y 区间),或改工件摆放。\n"
f" 当前开口:{[(round(lo, 4), round(hi, 4)) for lo, hi in gaps]}"
)
# (b) 承压宽度必须够 —— 否则工件会翻进槽里掉下去,而真值感知会继续按
# 名义位姿算,把 58mm 孔距算成 169mm(见 truth_holes.HoleTruth 的说明)。
min_support = 0.020
if supported < min_support:
probs.append(
f"工件跨在台面开口上的**承压宽度只有 {supported * 1000:.1f}mm**"
f"(要求 ≥ {min_support * 1000:.0f}mm)。\n"
f" 后果:板一有扰动就翻进槽里、落到地面;而 "
f"perception.source=truth 仍按 scene.yaml 的**名义位姿**投孔 → "
f"孔位下方已经不是工件而是槽底/地面 →\n"
f" 深度反投影把 58mm 孔距算成 169mm → S05 每拍都丢、最后超时。"
f"现象看起来是「检测坏了」,根因却是**工件掉了**。\n"
f" 修法:把开口收窄(改 worlds/assembly.world 里 work_table 碰撞体的 "
f"y 尺寸/位姿,开口只要够规的通端过就行,不需要覆盖两个孔所在的整个 y 带)。"
)
return probs, info
def check_gazebo_poses(scene, root) -> tuple[list[str], str]:
"""查 Gazebo 里物体的实际位姿,与 scene.yaml 对照。
@@ -441,7 +569,17 @@ def main() -> int:
if p:
print("\n".join(f" ✘ {x}" for x in p))
print("\n[5] 粗定位可见性(工件是否完整落在手外相机画面内)")
print("\n[5] 台面通槽 vs 工件(规过得去 + 工件掉不下去)")
p, info = check_table_slot(scene, root)
problems += p
if info:
print(f" {info}")
if p:
print("\n".join(f" ✘ {x}" for x in p))
else:
print(" ✓ 孔位都在开口内,且承压宽度够")
print("\n[6] 粗定位可见性(工件是否完整落在手外相机画面内)")
p, info = check_d435_visibility(scene, root)
problems += p
if info:
@@ -452,7 +590,7 @@ def main() -> int:
print(" ✓ 完整可见")
if not args.no_poses:
print("\n[6] Gazebo 实际位姿核对")
print("\n[7] Gazebo 实际位姿核对")
p, info = check_gazebo_poses(scene, root)
problems += p
if info: