真机落地前的仿真补齐:夹持/腕力/真值位姿,让仿真能验证 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
+4 -2
View File
@@ -60,8 +60,10 @@ models:
stroke_mm: 30.0
notes: >
rotate 是通止规装配 S08 循环的核心原语,必须可独立调用且可随时中止。
单次转角默认 180°(配置项 system.screw_step_deg),因为不确定基于工具系
能转多少度是极限。
单次转角由 site.yaml 的 gauge_assembly.screw_step_deg 决定(出厂 120°);
它**不是**随手取的:棘轮一个来回要 2×step 的关节行程,必须
2×step + 2×余量 ≤ 关节7 行程(Flexiv 各机型都是 ±175° ⇒ 350° ⇒ step ≤ 167°)。
填 180° 会必然贴限位(360° > 350°),见 docs/FRAMEWORK_RULES.md R2.10。
# --- 配置项声明 ------------------------------------------------------------
config_schema:
+88
View File
@@ -70,6 +70,12 @@ python3 scripts/check_sim_gripper.py # ★ 夹爪的几何验收(开
机械臂应当**在重力下保持站住**。若它瘫下去或消失,说明描述有问题 ——
见 [坑 1](#坑-1官方-flexiv_description-直接喂-gazebo-会被-sdformat-拒掉)。
> ⚠️ `check_sim_scene.py` 是**跑前**检查("现在这一轮能不能开始"),不是跑后检查:
> 一轮装配跑完之后场景**必然**是脏的 —— 放手时抓取锁释放、规掉在台面上,
> 落点可能磕到工件。所以跑完再查会报红,这是正常的。下一轮
> `run_gauge_assembly.sh --sim` 开头的上料脚本会把 `scene.yaml` 里**所有**物体
> 复位回名义位姿(`sim_load_gauge.py::_reset_scene_objects`),届时就干净了。
**这几条 `check_*.py` 才是验收标准,"有图像 / 服务有返回"证明不了什么。**
它们做的判断分别是:
@@ -532,6 +538,88 @@ python3 -m pytest src/arm_sim_gazebo/test/test_truth_holes.py -q
---
### 坑 14:上一轮把工件推走了 → 下一轮 S04 报"一个视觉目标点都没收到"
现象(`/tmp/pipeline_stage1.log`):
```
[sim_truth_perception-1] [ERROR] 仿真物体 'workpiece' 已经不在 scene.yaml 说的地方了 —— **停止发真值点**
名义 (x,y,z)=(0.5200,0.0000,0.4000) yaw=0.0°
实际 (x,y,z)=(0.4923,-0.0717,0.4000) yaw=8.3°
偏差 |Δp|=76.9mm、姿态差 8.3°(容差 2.0mm / 2.0°)
```
然后下一轮 `gauge_assembly` 报「一阶段 150s 没锁定:**一个视觉目标点都没收到**」,
看起来像"真值节点没起来 / 场景名读不出来"。
**这条不是 bug,是两条正确行为叠在一起**:
1. 真值感知是**纯几何**的 —— 它按 `scene.yaml` 的名义位姿算目标点与孔位。
物体一挪,算出来的就是假测量,而假测量**不会报错**:现象会是
「S05 两孔间距 169mm(真值 58mm)」或「S07 一路下压不接触」,
看起来像检测/标定/阈值坏了。所以 `sim_truth_perception` 有漂移看门狗。
它**不是一条线,是两条**(2026-09-24 改,判据是纯函数 `pose_deviation()`,
有单测;看门狗本身会自检"话题一帧都没收到"):
* `pose_info.drift_tol_m`(2mm)→ **告警**,但**照发点**:真值几何已经跟着
**实测**位姿算了(见下面 §5.1),2mm 只是"这场戏跟 scene.yaml 写的不一样了";
* `pose_info.drift_stop_m`(20mm)→ **停发**:到这个量级说明工件掉了/被搬走了,
场景前提没了,再发点等于指挥机械臂去装一个不存在的工件。
2. 谁把工件推走的:上一轮 S07 判到接触的那一刻臂上顶着几十牛顿
(位置控制的臂撞硬面**不会**在"刚碰到"停住),若卸载没生效,S08 再一转,
工件就被推走/推歪了。所以第 1 条其实是在**替你报告真因**。
**修法**:把场景搬回去。`scripts/sim_load_gauge.py` 开头现在会复位
`scene.yaml` 里**所有**物体(不止通止规)—— 所以正常情况下重跑一次就恢复正常,
不必重启仿真。若复位失败(比如工件卡在爪里),看门狗会继续拒发点,
那时**重启仿真**(`bash scripts/run_sim_gz.sh`)让它按场景重新 spawn。
排查顺序(按物理可信度):
```
① 看 sim_truth_perception 的日志有没有"不在 scene.yaml 说的地方了"
①a 偏差 ~2mm 级 → 只是告警,点照发,不用管(这是落地/蠕动的常态)
①b 偏差 >20mm → 场景前提没了:复位(重跑 sim_load_gauge.py)或重启仿真;
顺手查"谁推的它"(S07 的卸载有没有生效:日志里那行
"接触后卸载:回退 X mm … 残余 Y N")
② 没有 → 才轮到查"真值节点起没起来 / 场景名对不对"
```
### 5.1 真值口径:**几何按实测位姿算**,名义位姿只用来报"物体被搬走了"
(2026-09-24 定;实现 `arm_sim_gazebo/sim_live_pose.py`,谁要谁订阅。)
真值路径最早是"按 `scene.yaml` 的名义位姿算目标点与孔位",也就是**假设物体没动过**。
实测这个假设站不住,而且偏差是**安静**的:
| 现象 | 真因 |
|------|------|
| S05 算出的两孔间距 169mm(真值 58mm) | 工件掉进 90mm 通槽、落到地面(z≈0.6m),而真值仍按名义位姿把孔投到像素上 |
| S07 一路下压不接触 | 孔位差 2~3mm ⇒ 规尖(φ6)对不上孔(φ7,单边 0.5mm 余量),压在工件面上而不是进孔 |
而 2~3mm 是**天天都有**的:物体是"在名义位姿上方 spawn、再落下"的,落地就偏
2.6mm;工件骑在台面通槽上静置十几分钟还会再挪 1.9mm。
所以现在的口径是:
1. **算几何用实测位姿**(`/world/<world>/pose/info` → `LiveScenePose`)。
真值感知的目标点、二阶段定位的孔位都走这一条。这也更接近真机 ——
真机上的视觉也是"量到哪就是哪",不会拿图纸坐标去算。
2. **名义位姿只做两件事**:判"物体还在不在该在的地方"(上面那两条线),
和开局复位。
3. **开局复位**(`pose_info.reset_on_start: true`):只在节点启动后 20s 窗口内、
只做一次,把被盯的物体 set_pose 回名义位姿 —— 专门消掉 spawn 落地的
2~3mm。运行中的漂移**必须报错**,不能悄悄搬回去(那会抹掉"谁把它撞歪了"
的现场,还可能把工件从爪里拽走)。实现见
`sim_truth_perception._maybe_startup_reset` 与 `sim_pose_service.set_model_pose_yaw`。
```
⚠️ 复位/搬运用的是 Gazebo 的 `set_pose` 服务,命令行调用时 `--reqtype` 必须写
`ignition.msgs.Pose`(写 `gz.msgs.Pose` 会报 "Unable to create request",且不说是哪错了)。
实现只有一份:`arm_sim_gazebo/sim_pose_service.py`。
```
---
## 6. 现状与进度
### 已就绪(端到端验证过)
+68
View File
@@ -0,0 +1,68 @@
# =============================================================================
# devices/sim/gazebo/config/grasp_lock.yaml
#
# 仿真"抓取锁"(grasp lock)的口径。**只对仿真有意义**,真机没有任何对应物 ——
# 真机靠夹爪的摩擦夹持,本来就不会掉。
#
# -----------------------------------------------------------------------------
# 它解决什么问题(2026-09-24 一整轮排查换来的)
# -----------------------------------------------------------------------------
# 仿真里的夹持力不是"设"出来的,而是**位置误差 × 接触刚度**(见 gauge/model.sdf
# 里 kp 那段)。规站着不动时够用;机械臂一动就不行:
#
# S01 绕工具轴快转 150°、pre_lift/粗定位是 60~100mm 的连续轨迹 ——
# 这两步里规在指间打滑。实测工具长度 0.243m → 0.285m、规歪 62°,
# S07 随后拿到的几何全错,现象是"S07 判不到接触"或"规掉了(E1)"。
#
# 真机不会这样:握力 46N、µ≈0.8、规 0.265kg —— 摩擦余量两个数量级。
# 所以这把锁补的是 **Gazebo 缺的那块物理**(刚性接触没有静摩擦迟滞、也不模拟
# 指垫橡胶的贴合),不是绕过物理:规与指垫的接触对仍在,触觉与腕部 FT 的读数
# 依旧是真实接触力。
#
# -----------------------------------------------------------------------------
# ⚠️ 一处对不上就**静默失效**(不报错,只是规不跟着走)
# -----------------------------------------------------------------------------
# 本文件的名字必须与下面三处一致:
# ① gauge/model.sdf 里 DetachableJoint 插件的 <child_model>/<child_link>
# ② gauge/model.sdf 里插件的 <attach_topic>/<detach_topic>
# ③ 真名(Gazebo 里实际存在的连杆名)—— URDF 里与固定关节相连的连杆会被
# sdformat **合并(lump)**,名字会变:手指只剩 sim_left_finger_mount。
# 查真名的办法:
# ros2 topic echo /sim/world_pose_info ← 看全部 child_frame_id
# gazebo_sim.launch.py 启动时会读 gauge 的 SDF 核对 ① ②,不一致就直接报出来。
# -----------------------------------------------------------------------------
# 总开关。关掉它仿真照样跑,只是回到"靠摩擦夹持",也就是上面那串毛病。
enabled: true
# 被夹住的物体(scene.yaml 里的物体名 = Gazebo 里的模型名)。
object: "gauge"
# 该模型里被夹的那根连杆。
object_link: "body"
# 机器人侧:模型名 = launch 里 spawn 的 -name;连杆名必须是 Gazebo 的**真名**。
#
# ⚠️ 必须锁到**不会跟着张爪动的**连杆上 —— 默认是 `sim_link7`(法兰)。
# 为什么(2026-09-24 实测,一条假 E1 的真因):
# 起初锁到 `sim_left_finger_mount`(左手指)。夹住的规被**刚性**锁在这根
# 手指上,而 S08 棘轮每轮都要"松爪 → 回转 → 夹紧":张爪时手指往外走
# 40mm,被锁住的规却还插在孔里(6mm 销 / 7mm 方孔)——两处约束打架,
# 物理解算把规**从孔里拽出来**(实测规尖跳 +17.5mm、偏摆 64°),
# 然后在回转那一步掉到地上。日志只看得到"通止规掉了(E1)"。
# `sim_grav_base_link`(夹爪本体)在 sdformat 里被**合并**进了 `sim_link7`
# (见 /sim/world_pose_info 里的 `sim_link7_fixed_joint_lump__...`),
# 所以锚点就写 `sim_link7`:张爪不影响它,松爪/夹紧都不会带偏规。
# `sim_grasp_lock.load_grasp_lock()` 会拒绝含 `finger` 的连杆名(带这段原因)。
robot_model: "rizon_sim"
robot_link: "sim_link7"
# 触发话题(gz 侧,由本模块用 ign topic 发一个空消息触发)。
# 名字必须与 gauge/model.sdf 里插件的 attach_topic/detach_topic 一致。
attach_topic: "/sim/grasp_lock/attach"
detach_topic: "/sim/grasp_lock/detach"
# 插件状态回执(会 attach/detach 成功时各发一条)。用它来判断"锁到底有没有生效",
# 而不是靠"看规掉没掉"——后者要等故障发生才知道。
state_topic: "/sim/grasp_lock/state"
# 发一次触发消息后等多久 [ms](ign topic 是短命进程,给足启动时间)。
publish_timeout_ms: 3000
+52
View File
@@ -99,6 +99,58 @@ gates_px:
insert_ring_radius: [6, 30]
hole_spacing: [90, 700]
# ---------------------------------------------------------------------------
# 世界模型位姿回读 —— 仿真里唯一能发现"物体被撞走/掉下去了"的手段
# ---------------------------------------------------------------------------
# 为什么需要它(2026-09-24 花了一整轮排查换来的,值得完整读一遍):
# `perception.source=truth` 是**纯几何**的:它按下面 objects 里的**名义位姿**
# 把孔投到像素上,**从不问 Gazebo 物体现在到底在哪**。
# 于是工件一旦被碰走,"真值"就开始撒谎,而且每一处现象都指向别的原因:
#
# 实测:工件掉进台面通槽、落到地面(z≈0),而 scene.yaml 仍写着 z=0.40。
# S05 拿"名义孔位"的像素去查**真实**深度图 → 孔位处看到的是槽底/地面
# (0.6m,而工件面其实只有 0.21m)→ 三角化出 k1/k2 间距 169mm
# (真值 58mm)→ 被间距门槛丢掉 → 精拍连丢 9 拍 → S05 超时 ABORT。
# 现场现象像"检测坏了 / 标定坏了 / 阈值调高了",全都不是。
# 真正的信息只有一句:"**工件现在不在 scene.yaml 说的位置**。"
#
# Gazebo 自己会把每个模型的实际位姿发在 /world/<world>/pose/info
# (gz.msgs.Pose_V),桥成 tf2_msgs/TFMessage 之后,漂移监视器
# (sim_truth_perception)就能把"名义位姿 vs 实际位姿"直接对比出来并**指名报错**。
#
# 话题名里的 world 名必须与 worlds/assembly.world 的文件名一致(`assembly`)。
pose_info:
gz_topic: "/world/assembly/pose/info"
ros_topic: "/sim/world_pose_info"
# 类型固定这两条:ros_gz_bridge 原生支持 Pose_V ↔ TFMessage(实测确认,
# 见 ros_gz_sim_demos 的 tf_bridge.launch.py)。写错类型不会有任何报错,
# 只会"桥接器起来了但一个消息都没有" —— 所以别改。
ros_type: "tf2_msgs/msg/TFMessage"
gz_type: "gz.msgs.Pose_V"
# 漂移判据:**两个**阈值,别合成一个(2026-09-24 的教训)。
#
# drift_tol_m(2mm)= **告警**线:真值几何已经跟着**实测**位姿走
# (见 arm_sim_gazebo/sim_live_pose.py),所以 2mm 不再意味着"几何错了",
# 只意味着"这个场景跟 scene.yaml 写的不一样了"。而 2~3mm 是**天天都有**的:
# spawn 落地就偏 2.6mm,工件骑在通槽上静置十几分钟还会再挪 1.9mm。
# 2mm 的来历:真值投影在相机到工件 0.17m 处,fx≈447 ⇒ 1mm 位姿误差 ≈ 2.6px,
# 而 S05 的稳定判据是 2px —— 2mm 是"开始影响二阶段"的量级。
# yaw 取 2°:两孔间距 58mm,绕中心转 2° 会让孔横移 1mm(约 2.6px),同量级。
drift_tol_m: 0.002
drift_tol_deg: 2.0
# drift_stop_m 才是**停发**线:到这个量级说明工件掉了/被搬走了,场景前提没了
# (再发点等于指挥机械臂去装一个不存在的工件)。20mm 的来历:工件一半宽 70mm、
# 掉下台面是 400mm 量级,而"顺手碰一下"是几毫米 —— 20mm 两边都分得开。
drift_stop_m: 0.02
# 启动复位:开局把被盯的物体 set_pose 回名义位姿(仿真专有,默认关,这里开)。
# 为什么还要它:上面两条线是"容差",这一条是**消除源头** ——
# 物体是"在名义位姿上方 spawn、再落下来"的,落地那 2.6mm 偏差纯属 spawn
# 的落点问题(实测 set_pose 回名义位姿后它就停在那儿不动)。
# 只在开局 20s 窗口内、且只做一次 —— 运行中的漂移必须报错,不能悄悄搬回去
# (那会抹掉"谁把它撞歪了"的现场,还可能把工件从爪里拽走)。
# 实现见 sim_truth_perception._maybe_startup_reset(含完整理由)。
reset_on_start: true
# ---------------------------------------------------------------------------
# 场景物体
# ---------------------------------------------------------------------------
@@ -0,0 +1,12 @@
# ⚠ 本文件由 scripts/sim_load_gauge.py 自动生成(每次仿真上料都覆盖)。
# 它是**这次装夹**实测到的工具几何,run_gauge_assembly.sh --sim 会读它
# 并作为启动参数覆盖 site.yaml 的 gauge_assembly.tool_tip_below_flange_m。
# 为什么必须这样:仿真里规会在指间下滑到落座位置,下滑量随装夹变化
# (实测见过 243mm / 254mm 两种)。写成固定值迟早会算错悬停高度。
# 真机没有这个文件(现场装夹后把实测值写进 site.yaml)。
flange_z_m: 0.763
measured_at: '2026-09-24T19:40:08'
model: gauge
tip_z_m: 0.5198
tool_tip_below_flange_m: 0.2432
world: assembly
+50
View File
@@ -0,0 +1,50 @@
# =============================================================================
# devices/sim/gazebo/config/wrist_ft.yaml
#
# 仿真腕部力扭矩传感器(wrist force/torque)的话题与口径。
# sim_wrist_ft.py、gazebo_sim.launch.py 从这里读;改这里不用重编译。
#
# 真机的对应物:Flexiv 的 flexiv_robot_states_broadcaster 自动发
# /<sn>/ft_sensor_wrench geometry_msgs/msg/WrenchStamped(法兰系)
# 不需要任何配置 —— 它在真机 bringup 里无条件启动。
#
# -----------------------------------------------------------------------------
# 为什么仿真与真机能共用一套判据(这与触觉**相反**)
# -----------------------------------------------------------------------------
# 手指触觉(tactile.yaml)两边单位不同,所以阈值必须各配一套;腕部 FT 不同:
#
# 真机 ft_sensor_wrench 单位就是牛顿,坐标系是法兰系。
# 仿真 force_torque 传感器单位也是牛顿,挂 joint7 取 child(link7) 系,
# 而 link7 的 z 轴 = 工具轴(工具竖直朝下时即竖直方向)。
#
# 于是两边的"z 分量"都是**轴向力,单位牛顿** —— 公式一致,只有具体阈值
# 可以按现场标定分开配(见 site.yaml 的 gauge_assembly.contact_axial_delta)。
#
# ⚠️ 与触觉一样,这也是一处"选错不报错"的口径:口径错了只会表现为
# "判据不触发"或"一碰就炸",看起来像算法问题。
# -----------------------------------------------------------------------------
# gz 侧话题名。**必须与应用层按 sn 推出来的名字一致**:
# 应用层算 f"/{sn}/ft_sensor_wrench",仿真臂 sn = "sim"
# ⇒ /sim/ft_sensor_wrench
# 改 sn(config/site.yaml 的 robot.arms[sim])时必须同步改这里。
# 也同时写死在 arm.sim.sensors.xacro 的 wrist_ft_topic 默认值里 —— 两处
# 必须一致,check_sim_wrist_ft.py 会核对 xacro 展开结果里有没有这个话题。
topic: "/sim/ft_sensor_wrench"
# 挂载关节。改这里必须同步改 arm.sim.sensors.xacro 里的 <gazebo reference>。
# 为什么是 joint7 见 xacro 里那段注释(不能挂 fixed 关节:物理引擎不把它当
# 独立关节求解,传感器永远没输出且不报错)。
joint: "sim_joint7"
# 期望消息类型(ROS 侧)。gz.msgs.Wrench → WrenchStamped 由 ros_gz_bridge
# 直接支持(实测确认,无需中间适配器节点)。
#
# 为什么是 WrenchStamped 而不是 Wrench:真机发的就是 WrenchStamped。
# 类型一致,应用层才能不加分支地两边通用。
ros_type: "geometry_msgs/msg/WrenchStamped"
gz_type: "gz.msgs.Wrench"
# 传感器输出率 [Hz]。与真机 broadcaster 对齐(100Hz)。
# 下游按"数据够不够新"判活,率太低会被误判成掉线。
publish_hz: 100.0
+62 -3
View File
@@ -118,13 +118,24 @@
</collision>
<!-- 夹持段:夹爪夹这里,同样用软接触。
摩擦给 0.4:真机夹爪指垫是橡胶/聚氨酯,比金属-金属的 0.25 高。
给太低(<0.2)规会在指间打滑自转,而现象看起来像"夹爪没夹紧"。 -->
摩擦给 0.8:真机夹爪指垫是橡胶/聚氨酯,对钢件的 µ 在 0.6~1.0 之间。
这个数**直接决定"能不能把轴向力传给手指"**:能传的上限 = µ·N
(单指),握力 N≈46N → 0.8 时两指合计 ≈74N,0.4 时只有 ≈37N。
为什么原来是 0.4、为什么必须改(2026-09-24):
台肩一落面,接触刚度 5e4 N/m 意味着**过压 0.5mm 就是 25N**。µ=0.4
时两指上限 ≈37N,看着够;但一步步进就是 0.5mm,下一步就 50N > 37N
→ 规在爪里打滑,力就再也传不到指垫触觉上(读数停在 0,而它是对的)。
把 µ 改成接近真实的 0.8,连同"改用腕部 FT 当判据",两条一起才成立:
- 腕部 FT 在摩擦上游,打滑也测得准(首选判据);
- 摩擦接近真实值,才不会出现"规被顶得往上溜 20mm 顶到掌心"的
仿真特有现象(那会让 S07 在 25mm 处顶死、报假原因)。
给太低(<0.2)还会让规在指间打滑自转,现象看起来像"夹爪没夹紧"。 -->
<collision name="grip">
<pose>0 0 0.0555 0 0 0</pose>
<geometry><cylinder><radius>0.015</radius><length>0.045</length></cylinder></geometry>
<surface>
<friction><ode><mu>0.4</mu><mu2>0.4</mu2></ode></friction>
<friction><ode><mu>0.8</mu><mu2>0.8</mu2></ode></friction>
<contact>
<ode><kp>5e4</kp><kd>5e2</kd><max_vel>0.01</max_vel><min_depth>0.0005</min_depth></ode>
</contact>
@@ -189,5 +200,53 @@
</visual>
</link>
<!-- =====================================================================
抓取锁 GRASP LOCK —— 只存在于仿真,真机不需要
=====================================================================
做什么:夹爪一旦确认夹住,就用一个**固定关节**把规锁在手指上;
夹爪一张开就解锁。规与指垫之间的接触对仍然存在,
所以触觉/腕部 FT 的读数照旧是真实接触力。
为什么必须有它(2026-09-24 用一整轮排查换来的):
仿真里的夹持力不是"设"出来的,而是**位置误差 × 接触刚度**
(见上面 kp 那段说明)。规站着不动时够用,可机械臂一动就不行:
· S01 要绕工具轴快转 150°;
· pre_lift / 粗定位是一次 60~100mm 的连续轨迹;
这两步里规在指间**打滑**(实测工具长度 0.243m → 0.285m,规歪了 62°),
于是 S07 拿到的几何全是错的 —— 现象是"S07 判不到接触"或"规掉了(E1)",
而日志里看不到真因。
真机不会这样:握力 46N、µ≈0.8、规 0.27kg,摩擦余量两个数量级。
所以这把锁补的是**Gazebo 缺的那一小块物理**,不是绕过物理:
刚性接触求解器给不出静摩擦迟滞,也不会模拟指垫橡胶的贴合,
而现实的夹持恰恰靠这两件事。补上之后,仿真才配得上"验证装配逻辑"。
⚠️ 四处必须对上,否则它**静默失效**(不报错,只是规不跟着走):
1. <parent_link> = 本模型(规)里要被抓的那根连杆:body;
2. <child_model> = 机器人 spawn 时的 -name(launch 里是 rizon_sim);
3. <child_link> = Gazebo 里**真实存在**的连杆名,且必须是**不跟着
张爪动**的那一根 —— 这里填的是 `sim_link7`(法兰;夹爪本体
`sim_grav_base_link` 在 sdformat 里被合并进去了,见
`/sim/world_pose_info` 里的 `sim_link7_fixed_joint_lump__...`)。
⚠️ 2026-09-24 实测:填成 `sim_left_finger_mount`(手指)会出事 ——
S08 棘轮每轮"松爪 → 回转 → 夹紧",张爪时手指外移 40mm 而规被
刚性锁着、又插在孔里,两处约束打架把规**从孔里拽出来**
(规尖跳 +17.5mm、偏摆 64°)→ 下一步掉地 → 只报"通止规掉了(E1)"。
真名怎么查:
ros2 topic echo /sim/world_pose_info (列全部 child_frame_id)
4. attach/detach 话题与 sim_gripper_adapter 下发的名字一致;
两侧名字对不上时,锁只会"永远不触发"。
===================================================================== -->
<plugin filename="ignition-gazebo-detachable-joint-system"
name="ignition::gazebo::systems::DetachableJoint">
<parent_link>body</parent_link>
<child_model>rizon_sim</child_model>
<child_link>sim_link7</child_link>
<attach_topic>/sim/grasp_lock/attach</attach_topic>
<detach_topic>/sim/grasp_lock/detach</detach_topic>
<output_topic>/sim/grasp_lock/state</output_topic>
</plugin>
</model>
</sdf>
@@ -41,6 +41,8 @@
sensors_file:=''
d405_enable:=true
tactile_enable:=true
wrist_ft_enable:=true
wrist_ft_topic:='/sim/ft_sensor_wrench'
d405_x:=0.0
d405_y:=-0.06
d405_z:=0.05
@@ -313,6 +315,56 @@
</gazebo>
</xacro:if>
<!-- =======================================================================
腕部力扭矩(wrist force/torque)—— 与手指触觉是**两回事**,别互相替代
=======================================================================
为什么必须有它(2026-09-24 踩过,代价是整整两轮排查方向错):
"通止规"是**靠夹持摩擦抱住**的工具。轴向力(往下压的力)要传到手指上,
必须经过"指垫↔规"的摩擦面,而摩擦能传的上限是 µ·N:
夹持段 µ=0.25、实测握力 N≈46N → 单指 ~11.5N、两指 ~23N
台肩一落面,接触刚度 5e4 N/m 意味着**过压 0.5mm 就是 25N** —— 一步
步进量就足以让规在爪里打滑。所以手指触觉**根本读不到**"台肩落面"那
一下的力(读数停在 0,而它是对的)。拿手指触觉当"到底了"的判据,
阈值调多大都不对:调小了被噪声骗,调大了物理上永远达不到。
腕部 FT 装在这条力路的**上游**:工件→规→夹爪→法兰→腕部关节。
工具打不打滑都要经过它,所以它测得到真实的轴向力。真机同理 ——
真机上这个量由 Flexiv 的 flexiv_robot_states_broadcaster 发在
/<sn>/ft_sensor_wrench(geometry_msgs/WrenchStamped,法兰系)。
为什么挂在 ${prefix}joint7 而不是 flange_joint:
flange_joint 是 **fixed** 关节,物理引擎不会把它当独立关节求解
(它是刚体的一部分),挂上去的 force_torque 传感器永远没有输出、
而且**不报任何错**。joint7 是最后一个 revolute 关节,它下游挂着
link7 + flange + 夹爪 + 工具 —— 正好是"整个腕部以下"的全部载荷,
这才是工程意义上的腕部 FT。
为什么是 frame=child:
child 是关节的下游连杆(link7)。工具竖直朝下时 link7 的 z 轴
就是工具轴,所以 **z 分量 = 轴向力**,与真机 ft_sensor_wrench
(法兰系,z=法兰轴)口径一致 —— 应用层因此可以两边共用一套判据。
⚠️ 话题名必须与"应用层按 sn 推出的那个名字"一致:
仿真臂的 sn 固定是 `sim`(见 config/site.yaml 的 robot.arms[sim]),
应用层算 `/{sn}/ft_sensor_wrench` = /sim/ft_sensor_wrench。
改 sn 时必须同步改这里,否则只是"话题上没有数据"(不报错)。
-->
<xacro:if value="${wrist_ft_enable}">
<gazebo reference="${prefix}joint7">
<sensor name="wrist_ft" type="force_torque">
<always_on>true</always_on>
<!-- 与真机 flexiv_robot_states_broadcaster 的发布率(100Hz)对齐:
下游按"够不够新"判活,率太低会被当成掉线。 -->
<update_rate>100</update_rate>
<topic>${wrist_ft_topic}</topic>
<force_torque>
<frame>child</frame>
<measure_direction>parent_to_child</measure_direction>
</force_torque>
</sensor>
</gazebo>
</xacro:if>
</xacro:macro>
</robot>
+33 -14
View File
@@ -116,7 +116,7 @@
<link name="top">
<pose>0 0 0.375 0 0 0</pose>
<!-- ===================================================================
台面沿 Y 留一道 90mm 宽的通槽(|y| ≤ 45mm),台面分成南北两块。
台面沿 X 留一道 36mm 宽的通槽(|y| ≤ 18mm),台面分成南北两块。
为什么必须留这道槽(实测踩过,现象非常隐蔽):
通止规的**通端**长 30mm,而工件是 12mm 厚的板、板上的孔是
@@ -129,15 +129,34 @@
力也不是完全没有(顶台面时切向读 30~45N),所以看起来像
"阈值调太高",很容易被误判成传感器/阈值问题。
槽只开在工件孔位所在的 Y 带(孔在 y = ±29mm、φ7 见方),
不抬高工件、不动台面高度 → 相机距离、孔检测像素门槛、
site.yaml 里的悬停高度全都不用改。
南北两块各留 y ∈ [±45, ±600]mm,工件(100mm 宽)跨在槽上,
两侧各有 5mm 承压面 —— 板是刚体、只受重力,够用。
===================================================================
槽宽:36mm(|y| ≤ 18mm),**不是**初版的 90mm(2026-09-24 收窄,
代价是一整轮排查)
===================================================================
初版开的是 90mm 宽(|y| ≤ 45mm),理由是"工件上两个孔所在的 Y 带
都盖住"。但工件只有 100mm 宽(y 方向),跨在 90mm 的槽上时
**每侧只剩 5mm 承压面** —— 静力学上勉强平衡,一有横向扰动就翻。
实际后果(本次先看到的现象):
工件掉进槽里、落到地面(z≈0),而 `perception.source=truth`
仍然按 scene.yaml 里的**名义位姿**把孔投到像素上 ——
于是 S05 拿"名义孔位"的像素去查**真实**深度图:孔位处看到的是
槽底/地面(实测 0.6m,而工件面其实只有 0.21m),
三角化出来的 k1/k2 间距 169mm(真值 58mm),被间距门槛丢掉,
精拍连丢 9 拍 → S05 超时 ABORT。
现象像"检测/阈值坏了",根因却是**工件不在它该在的地方**。
18mm 是怎么定的(只跟"规的通端要能过"有关,与两孔间距无关):
通端 φ6(半径 3mm),孔 φ7(半径 3.5mm)→ 规占 |y| ≤ 3mm;
留 15mm 给定位误差(S05 对准 tol 0.7mm、工件摆放偏差、yaw 残余)。
承压面因此从 5mm 变成 32mm(y ∈ [±18, ±50]mm),工件不再翻得进去。
注意**方向**:两孔连线沿工件局部 X(模型里 insert_*_ring 在 x=±29mm),
场景里 yaw=0 ⇒ 两孔世界 y 都是 0。所以槽只要在 y=0 附近开一条
沿 X 的长槽就够,不需要覆盖整个 ±29mm。
`scripts/check_sim_scene.py` 会核对"孔位落在槽的开口范围内"这一条。
=================================================================== -->
<collision name="collision_south">
<pose>0 -0.3225 0 0 0 0</pose>
<geometry><box><size>0.8 0.555 0.05</size></box></geometry>
<pose>0 -0.309 0 0 0 0</pose>
<geometry><box><size>0.8 0.582 0.05</size></box></geometry>
<surface>
<friction>
<ode><mu>0.8</mu><mu2>0.8</mu2></ode>
@@ -153,8 +172,8 @@
</surface>
</collision>
<collision name="collision_north">
<pose>0 0.3225 0 0 0 0</pose>
<geometry><box><size>0.8 0.555 0.05</size></box></geometry>
<pose>0 0.309 0 0 0 0</pose>
<geometry><box><size>0.8 0.582 0.05</size></box></geometry>
<surface>
<friction>
<ode><mu>0.8</mu><mu2>0.8</mu2></ode>
@@ -170,16 +189,16 @@
</surface>
</collision>
<visual name="visual_south">
<pose>0 -0.3225 0 0 0 0</pose>
<geometry><box><size>0.8 0.555 0.05</size></box></geometry>
<pose>0 -0.309 0 0 0 0</pose>
<geometry><box><size>0.8 0.582 0.05</size></box></geometry>
<material>
<ambient>0.45 0.4 0.35 1</ambient>
<diffuse>0.55 0.5 0.42 1</diffuse>
</material>
</visual>
<visual name="visual_north">
<pose>0 0.3225 0 0 0 0</pose>
<geometry><box><size>0.8 0.555 0.05</size></box></geometry>
<pose>0 0.309 0 0 0 0</pose>
<geometry><box><size>0.8 0.582 0.05</size></box></geometry>
<material>
<ambient>0.45 0.4 0.35 1</ambient>
<diffuse>0.55 0.5 0.42 1</diffuse>
+135
View File
@@ -198,6 +198,88 @@ Fortress 恒发 `fx=fy=277, cx=160, cy=120`(它的默认 60° 视场 + 半分
物体的落座微动是正常的。卡到 `1e-6` 只会得到永远为真的告警,人就会把检查关掉。
默认 5mm 起步(见 `sim_scene.pose_mismatches`)。
### R2.8 工具长这类"装夹完才知道"的几何:仿真必须**实测并落盘**,不许写死标称值
悬停高度 = 工件表面 + **工具长** + 间隙。工具长写小几毫米,二阶段悬停时
规尖就停在工件**里面** —— 后面 S05 的每条残差、每个告警都是假象
(见 §8 的"残差恒定 11mm"那一条)。
仿真里这个值**装夹完才会定**:合爪后规会在指间下滑到落座位置,下滑量随装夹
变化(实测见过 243 / 254mm,差 11mm)。所以口径固定为三级,不许砍:
1. 上料脚本(`scripts/sim_load_gauge.py`)合爪后**实测**规尖相对法兰的位置;
2. 落盘 `devices/sim/gazebo/config/sim_tool_geometry.yaml`(含时间戳/世界名/模型名);
3. `run_gauge_assembly.sh --sim` 读它,作为**启动参数**
`tool_tip_below_flange_m:=` 覆盖 `site.yaml` 的标称值。
优先级与其余参数一致(R1.1:命令行 > `site.yaml` > 包内默认),真机不传这个
参数、走 `site.yaml` 的人工实测值。`site.yaml` 里那个数**只是兜底**,别拿它
当"这次的真实工具长"。下游拿到工具长后一律过 `gauge_fsm.hover_z_guard()`
(会检查悬停高度是否把规尖留在工件里,修不了就明确报错,不静默改)。
### R2.9 "目标角"和"增量"是两个口径,混用必出错 —— 一律用 `ratchet_delta_rad` 折算
关节角是**周期量**:`+338.9°` 和 `−21.1°` 是同一个位置,但 `338.9 − 158.9`
和 `−21.1 − 158.9` 差了整整一圈(一个"走 180°"、一个"倒着走 180°")。
棘轮(S08 拧入 / S09 拧出)里两种口径长得很像 —— 上游给的是**目标角**,
下游要的是**增量**。混用不会报错,只会算出"行程不够/足够"的反结论,然后
把"让余量"退到限位外面,最后报一句与真因无关的"偏差过大"(§8 那一条)。
规矩:
- 任何"目标 − 当前"都必须过 `gauge_fsm.ratchet_delta_rad(target, q_now)`,
它把差折进 `(−180°, +180°]`,同一个目标 + 同一个位置只有一个增量;
- **转向要显式给**:`ratchet_delta_rad(target, q_now, direction)`,`+1` 正转、
`-1` 反转。步长贴着 180° 时"谁更短"取决于 0.1° 级的读数差 —— 调用方按
自己读到的 `q=+162.0` 算出目标 `−18.0`,棘轮晚 0.1° 读到 `+162.1`,
`−180.1°` 折一圈就成了 `+179.9°`,**方向翻了**,于是棘轮朝螺纹反方向走、
贴限位、让余量、重试,净位移 0 ⇒ ABORT,而现场什么都没坏(§8 那一条)。
棘轮的转向是**工艺意图**(S08 顺时针拧入、S09 逆时针拧出),不能交给
数值巧合决定;
- 传参名必须写清口径(`target_rad` / `delta_rad`),不许叫 `angle`;
- 有单测钉住(`test_gauge_fsm.py`:越限目标的折算、让余量后落在限位内、
`+338.9°` 与 `−21.1°` 增量相同、180° 刀口不许翻向、给了方向一定落回目标)。
### R2.10 棘轮的步长受**关节行程**约束:`2×step + 2×余量 ≤ 行程`,且累计是整数圈
棘轮一个来回(慢转 + 回转)要 `2×step` 的行程,两头还要留余量 —— 贴死限位时
"让余量"**腾不出任何空间**(腾一步还是 `2×step`)。所以配 `180°` 的步长给
Rizon4/Rizon10 的关节 7(±175° ⇒ 行程 350°)是**必然失败**的:`2×180 = 360 > 350`。
实测症状(2026-09-24):第 1、2 轮正常,第 3 轮开始"贴限位 → 让余量 → 还是贴
限位",真因(配置)藏在最后一行,白烧 3 轮节拍。更隐蔽的是**每轮回转都比指令
少走 4~5°**(速度/加减速限制),这点亏欠只能靠余量吃 —— 余量给 0 时关节会在
7~8 轮后累计偏出限位、再也转不回来。
规矩:
- 步长 ≤ `(行程 − 2×余量) / 2`(Rizon4/10 关节7 ⇒ ≤167°,出厂取 **120°**);
- `screw_max_cycles × screw_step_deg` 必须是 **360° 的整数倍**,否则规停在
螺纹中间、S09 也就少拧出几分之一圈;
- 判据写成纯函数 `gauge_fsm.ratchet_travel_ok(step, limits, margin)`,
**S08 开头**就拿 `/robot_description` 里的限位算一次,不通过直接 ABORT
并打印"该改哪个键、改成多少";
- 出厂值有回归锚点:`test_gauge_config.py` 拿 `flexiv_description` 里**每台
支持臂**的真实限位去算,以后加一台行程更小的臂会自动报红。
### R2.11 工具还在孔里时**只许竖直退**,横向动作必须等它出来
刚拧出的规**仍然插在孔里**(真机只是沿螺纹退回几个毫米;仿真里**没有螺纹几何**,
"拧出"只让规在孔里转,深度一点没变)。这时任何带横向分量的动作都会把规**拽**在
孔壁上 —— 实测抓取锁被拽断、规掉在台面上。
**它在这里不报错**,两分钟后表现为"下一孔 S07 下探 35mm 判不到接触"(工具上已经
没规了),日志指不到真因(R5.1 的静默失效)。实测那一次的几何差得极小:规尖比
工件面低 30mm,而换孔的斜线只往上抬了 29mm —— 差 1mm 就是"拽出孔"和"干净退出"的
分界。所以必须是**先后有序的两个动作**,不能靠斜线一起走。
规矩:
- 拧出(S09)的**成功出口之前**先把工具提到**跟踪高**(`_track_z()` =
孔悬停高 + 50mm,流程里"已离开工件"的既有定义),之后再走 XY;
- 提出是**纯 Z**(`hold_xy=True`),横向分量一律非法(`test_gauge_exit_lift.py`
钉住结构顺序 + 目标高度 + "已经高了就不动");
- 提出失败只告警不失败:那一刻孔里已经没有要保护的规,硬失败会把一次成功的
装配变成失败;后面的换孔/复位仍会走到安全位。
---
## 3. 触觉与力
@@ -362,6 +444,32 @@ conda 激活不会往 `LD_LIBRARY_PATH` 加东西,而 gz 的插件搜索路径
- `--verbose` 要把**原始物理量**(指尖坐标)打出来,判错时能回看;
- 结论不对时,先怀疑判据,再怀疑被测对象 —— 上面两条都是先怀疑了代码。
### R4.13 仿真与真机的隔离**不许**依赖"环境里恰好没设某变量"
`scripts/sim_env.sh` 里原本写着:
```bash
export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-77}" # ← 错
```
看起来是"尊重调用者已有设置",实际把隔离交给了运气。实测(2026-09-24):
本机 shell 里就 export 着 `ROS_DOMAIN_ID=0`(**真机默认就是这个**),于是
`:-77` 当场取到 0 —— 仿真栈和真机落在**同一个 DDS domain** 上,
仿真的运动指令可能被真机控制器订阅到。而现场只会看到"脚本打印了 77、
`echo $ROS_DOMAIN_ID` 是 0"这种自相矛盾的输出。
**规则**:这类"安全隔离项"必须**强制**设成自己的值,要覆盖就用**专用名字**
(`SIM_ROS_DOMAIN_ID`)显式覆盖,并且把"我改掉了你的值"喊出来。同类的还有
`ROS_LOCALHOST_ONLY`、`CONDA_PREFIX`、代理变量。
```bash
if [[ -n "${SIM_ROS_DOMAIN_ID:-}" ]]; then export ROS_DOMAIN_ID="${SIM_ROS_DOMAIN_ID}"
elif [[ "${ROS_DOMAIN_ID:-77}" != "77" ]]; then
echo "[sim_env] ⚠ 已有 ROS_DOMAIN_ID=${ROS_DOMAIN_ID},已强制改为 77 …" >&2
export ROS_DOMAIN_ID=77
else export ROS_DOMAIN_ID=77; fi
```
---
## 5. 代码与测试
@@ -422,6 +530,20 @@ diff src/arm_application/arm_application/<文件> \
不一致时的现象是"我明明改了,行为却和没改一样",而且**没有任何提示** ——
最容易被误判成"我的改动没效果"。
**实测代价(2026-09-24)**:修好 verticalize 的静默跳过后忘了重编 `install_sim/`,
又白跑一轮 —— 两轮的日志**一字不差**,看起来像"这个改法根本无效",于是开始怀疑
改错了地方。判别只要一条命令:
```bash
grep -c "<你刚写进去的注释关键字>" \
install_sim/<包>/lib/python3*/site-packages/<包>/<文件> # 0 = 旧拷贝
```
**同日第二次撞上同一条(所以它值得写两遍)**:给 `sim_truth_perception` 加启动复位后
跑测试,`pytest` 直接报 `ImportError: cannot import name 'RESET_GIVE_UP'` ——
源码里明明有。因为测试 import 的是 `install_sim/` 里那份**旧拷贝**。
所以"改了源码 → 先 `bash scripts/build_sim.sh` → 再跑测试/仿真",顺序不能反。
### R5.8 同一份数学/解析实现只许有一份,并加**守卫测试**
四元数换算曾经在 `pipeline` / `stage2` / `stage3` / `visual_servo` 四个 launch 里
@@ -663,8 +785,21 @@ launch 会一直挂着、终端不回提示符。要显式挂 `OnProcessExit →
| 每次 pytest 十几条红,没人看测试结果了 | 环境缺 `onnx`,用例直接失败而不是 `skip` | R5.10 |
| `site.yaml` 里改了参数,行为一点没变 | 那个键**没接线**(配置表里有、代码里从没读过) | R1.2 |
| 改了源码,仿真里行为一点没变,也没有任何提示 | 仿真 `install_sim/` 是**拷贝**(不能 `--symlink-install`),没重编就跑 | R5.7 |
| `pytest` 报 `ImportError: cannot import name ...`,而源码里明明有 | 同一件事的另一副面孔:测试导入的是 `install_sim/` 里的旧拷贝 | R5.7 |
| S05 带着 6° 倾角去压孔 → 撞件/E2,日志只说"规划停滞" | `verticalize` 的纯旋转 hop 用**平移**做进度判据(dist≈0 ⇒ planned<0 ⇒ 整段跳过) | R5.1、R5.3 |
| S07 判到接触、也"卸载成功",S08 一转却掉件(E1) | 卸载回退量与 `_goto` 容差同量级 ⇒ 浮点回减后 `dist<tol` ⇒ **一步没走**报"已放干净" | R5.1、R5.3 |
| S08 第 2 轮就报"通止规掉了",爪里其实好好的 | 掉件判据吃 `is_gripped` 的**推断值瞬时翻转**,而 S08 会切指令 ⇒ 假 E1 | R5.1、R3.1 |
| 下一轮 S04 报"一个视觉目标点都没收到",像感知没起来 | 上一轮把**工件**推走了;真值看门狗正确地停发点,但没人把工件搬回去 | R5.3、坑 14 |
| **开局**就报"工件已经不在 scene.yaml 说的地方了(偏 2.6mm)",一轮都跑不起来 | 物体是"在名义位姿上方 spawn 再落下"的,落地会偏 2~3mm,正好压在 2mm 容差上;看门狗分不出"落地"和"被撞走" | R2.7、坑 14 |
| S09 第一步就 ABORT:"期望 +338.9°、实测 +26.0°,偏差过大",现场什么都没坏 | 棘轮把"关节的**目标角(绝对值)**"当**增量**用:差 180° 被算成 338.9° ⇒ "让余量"那一步算到限位外(−180° < −175°)⇒ 回旋校验必然不过 | R5.1、R5.8 |
| S05 迭代用满、残差**恒定** ~11mm,日志让你去查手眼/相机安装 | 悬停时工具尖端已经插进工件:闭环追的是"被自己推着走的工件" ⇒ 残差恒定(新增 `gauge_fsm.drag_verdict` 看门狗专门报这条) | R5.1、R4.12 |
| 悬停高度按 `site.yaml` 的工具长配好了,S05 还是把工件推走 | 仿真里规合爪后会**在指间下滑**到落座位置 ⇒ **实际**工具长比标称长十几毫米,且下滑量随装夹变化;写成固定值必然算错悬停高度 | R2.7、R4.12 |
| 装配失败了,脚本/CI 却报成功 | `ros2 launch` 的退出码恒为 0,脚本看了 `$?` | R6.9 |
| 流程明明打完了,终端一直不回到提示符 | launch 里的静态 TF 发布者是常驻的,主节点退出不会带走它 | R6.9 |
| S08 前两轮好好的,第 3 轮起"贴限位 → 让余量 → 还是贴限位"然后 ABORT | 棘轮步长 180° 配关节7 行程 350°:一个来回要 360° > 行程,必然有一头贴死("让余量"也腾不出空间);真因在配置里 | R2.10 |
| S08 朝**反**方向转,然后贴限位、让余量、重试,净位移 0 ⇒ ABORT | 步长正好 180° 落在"最短路径"的刀口上:0.1° 级的读数差就能把 `−180.1°` 折成 `+179.9°`,棘轮倒着走 | R2.9 |
| 回转那一步每轮都比指令少走 4~5°,攒到 7~8 轮后转不回来 | 速度/加减速限制让"快回转"实际走不满整步;余量必须 ≥ 这点亏欠,否则关节均位逐轮外漂 | R2.10 |
| 第一孔装得好好的,第二孔的 S07 下探 35mm 判不到接触 | 第一孔拧出后规**还插在孔里**,换孔的斜线把它从孔里**拽**出来 → 锁被拽断、规掉在台面("工具上已经没规了");提出与横move必须**先后有序** | R2.11 |
---
+112 -4
View File
@@ -160,6 +160,7 @@ ros2 topic pub --once /gauge/reload_ok std_msgs/msg/Bool "{data: true}"
| S07 接触判据 | 阈值**未在真机上标定**(仿真里做过通路验证)。⚠️ 见下面「S07 为什么在仿真里过不去」 |
| S08/S09 力矩到位判据 | 同上,属现场标定项 |
| 真机全流程 | **未跑过**(本仓库当前无真机)。干跑 / 仿真 / 单测已过,见 §7 |
| 仿真全流程 | ✅ **已跑通**(S00→S11、两个孔、DONE,连着两次;基线见 §7.1) |
### S07 为什么在仿真里过不去(2026-09-24 查到根因,别再往阈值上调)
@@ -171,6 +172,11 @@ ros2 topic pub --once /gauge/reload_ok std_msgs/msg/Bool "{data: true}"
25→35mm 下探预算、台面开 90mm 通槽让贯通孔真能穿过 —— 都做了,都该做
(确实是独立的 bug),但**没解决 S07**。真实因果链是:
> 补记(2026-09-24 晚):那道通槽后来从 90mm **收窄到 36mm** —— 90mm 比工件
> 还宽(工件 y 向只有 100mm),工件会掉进槽里,而真值感知又按名义位姿报孔位,
> 于是 S05 三角化出 169mm 的假孔距。见 `devices/sim/gazebo/worlds/assembly.world`
> 里 `work_table` 的注释与 `scripts/check_sim_scene.py` 的承压宽度检查。
| # | 实测到的 | 日志证据 |
|---|---------|---------|
| 1 | 悬停时规尖已在工件面下 **23mm** | `悬停时尖端离面 -23.0mm`(工具几何自检) |
@@ -197,17 +203,61 @@ ros2 topic pub --once /gauge/reload_ok std_msgs/msg/Bool "{data: true}"
3. 仍用指垫触觉,但阈值要按 **µ·N 反推**(≈23N 量级),且必须把夹持段
摩擦系数与握力一起标进 `site.yaml`。**最不推荐**:换了规/换了指垫就得重标。
**下一步(不要再对着触觉阈值试)**:实现第 1 条 —— S07 判据改读腕部 FT 的
轴向分量,触觉只作兜底。
**已落地(2026-09-24 当天实现并在仿真里跑通到 S07 判接触)**:
---
S07 的**首选判据已改成腕部 FT 轴向突变**(`gauge_fsm.wrench_touch`),触觉只作
兜底。真机用 Flexiv 的 `/<sn>/ft_sensor_wrench`、仿真用挂在 `joint7` 的
`force_torque` 传感器(`devices/sim/gazebo/config/wrist_ft.yaml`),两边都是
**牛顿 + 法兰/link7 系**,所以判据公式完全一致。
实测(仿真,k1 孔):
```
S07 基线:切向 (-1.5, -22.5)、法向 53.9;腕部轴向 -21.82N(活,话题 /sim/ft_sensor_wrench)
S07 工具几何:悬停法兰 z=0.6320、工具尖端在法兰下方 245.0mm → 尖端 z=0.3870
S07 接触(腕部 FT):轴向 +48.37N 相对基线 -21.82N 超过 +8.0N(下压 5.39mm)
```
下压 5.39mm 与几何自检算出来的"台肩还差 5.0mm 落面"吻合到亚毫米 ——
这既证明判据有效,也证明**工具几何配置是对的**。
**改判据之后暴露出的两个"静默假通过"**(都已修,见 `FRAMEWORK_RULES.md` §8):
1. **卸载回退一步没走,却报"已放干净"**。判到接触的那一刻臂上顶着的是
"出力上限"量级的力(实测 ~70N,与阈值无关),必须先回退 `contact_relief_mm`
再交给 S08。但回退量 0.5mm 与 `_goto` 的到位容差写成了**同一个量级**,
浮点回减后 `dist < tol` 当场成立 ⇒ `_goto` 一步没走就返回 True;
而残余力判据比的是"回退前 vs 回退后"两个**相同**的读数 ⇒ 报"残余 0.0N"。
结果 S08 带着 70N 去转(夹持摩擦上限 µ·N 两指合计 ≈73N)→ 掉件(E1)。
现在:容差由 `gauge_fsm.relief_tol_m(回退量)` 推导(**保证严格小于回退量**,
有单测),残余力按**基线**算而不是按回退前后之差算。
2. **S08 第 2 轮就报"通止规掉了",爪里其实好好的**。掉件判据原来吃
`GripperStatus.is_gripped` 的**瞬时翻转**,而它是**推断值**("指令到合拢侧 +
实际开口被顶住");S08 会在"合到某开口/全合"之间发指令,某一帧的指令落到与
实际开口同一档 ⇒ 翻 False ⇒ 假 E1,然后整条流程去等人工"放好通止规"。
现在判据改看**开口塌陷**(`GripperClient.dropped()`:夹住时的开口 − 当前开口
≥ 5mm)—— 真掉件是两指合到底,几十毫米的变化,不会被指令切换带偏。
3. **S09 第一步就 ABORT,报"期望 +338.9°、实测 +26.0°"**,而现场什么都没坏。
棘轮把上游给的**目标角(绝对值)**当**增量**用了:关节在 `+158.9°`、目标
`+338.9°`(故意越限,好让下一步有余量)被算成"要转 338.9°",于是"让余量"
退到 `−180°`(比限位 `−175°` 还低)⇒ 回旋校验不过 ⇒ ABORT。
现在全部过 `gauge_fsm.ratchet_delta_rad()` 折算(见 `FRAMEWORK_RULES.md` R2.9)。
4. **悬停高度按 `site.yaml` 的工具长算,S05 还是把工件推走**(迭代用满、残差
恒定 ~11mm,日志却让你查手眼/相机)。真因是仿真里规合爪后**在指间下滑**到
落座位置,实际工具长比标称长十几毫米 ⇒ 悬停时规尖已经插进工件 ⇒ 闭环追的是
"被自己推着走的工件"。现在:上料脚本**实测**工具长并落盘
`devices/sim/gazebo/config/sim_tool_geometry.yaml`,`run_gauge_assembly.sh --sim`
当启动参数覆盖标称值(`site.yaml` 里只是兜底);S05 另有 `drag_verdict`
看门狗,工件被推走立刻停并明确报 code=6,不再让你去查手眼。
250|---
## 7. 验收命令(都是真跑过的)
```bash
# 1) 纯逻辑层(不需要机器人)
PYTHONPATH=src/arm_application python3 -m pytest src/arm_application/test -q
# 仿真环境:349 passed, 14 skipped(缺 onnx 的用例跳过,不伪装成失败)
# 仿真环境:454 passed, 14 skipped(缺 onnx 的用例跳过,不伪装成失败)
# 2) 干跑全流程:S00→S01→…→S11→DONE,脚本退出码 0,结论 ok=true
bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 --sim --dry-run
@@ -217,6 +267,27 @@ bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 --sim --dry-run
bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 --sim
```
### 7.1 仿真全流程第一次真跑通(2026-09-24,留作基线)
```
S00_boot → S01_home_extreme → S02_grip_check → S04_track → pre_lift → stage1_track
→ S05_snap → hover → snap_coarse_0 → snap_fine_0 → align_move_0 → snap_fine_1
→ align_move_1 → snap_fine_2 → aligned(k1 残差 0.05mm)→ descend_above_hole → hold
→ S06_hover_k1 → S07_contact(腕部 FT +213N 判接触,下压 31.6mm;卸载回 0N)
→ S08_screw_in_k1(12 轮)→ S09_unscrew_k1 → 竖直提出 Z 0.627→0.707
→ S10_to_k2 → S06_hover_k2 → S07_contact → S08_screw_in_k2(12 轮)
→ S09_unscrew_k2 → 竖直提出 Z 0.638→0.707 → S11_reset --pose_ok--> DONE
通止规装配结束:state=DONE ok=True 已装 2/2 孔 掉件重放 0 次
```
**连着跑了两次都是 DONE**(第二次 `24` 次棘轮 = 12×2 孔,`relocations=0`),
说明场景复位是干净的、不残留上一轮的状态。落盘结论:
`/tmp/gauge_assembly_sim.json`(`ok=true`、`holes_done=2/2`、`residual_mm≈0.02`、
k1/k2 孔距 58mm —— 之前那个 169mm 是工件掉进 90mm 通槽后的假孔距)。
跑之前**必须**先编:`bash scripts/build_sim.sh`(坑 2)。
> 退出码是**脚本**给的:脚本读节点落盘的结论文件(`ros2 launch` 自己的退出码
> 恒为 0,见 R6.9)。
@@ -251,6 +322,43 @@ bash ~/ros2/scripts/run_gauge_assembly.sh --arm 89 --sim
5. **测试红一片时,人就不看测试了。** 仿真环境没装 `onnx`(只有真机 conda 有),
14 个用例常年报红。现在缺依赖的用例 `skip`(`importorskip`),
让"红"只表示真出问题。
6. **棘轮步长 180° 配 ±175° 的关节 7:必然贴限位。**(2026-09-24,S08 第 3 轮 ABORT)
棘轮一个来回(慢转 + 回转)要 `2×step` 的行程:`2×180 = 360°`,而关节 7 的
行程只有 `350°`(±175°)—— 关节**必然**有一头贴死,而"让余量"也腾不出空间
(腾一步还是 360°)。日志表现是"贴限位 → 让余量 → 还是贴限位",
**真因(配置)藏在最后一行**,白烧 3 轮节拍才炸。
更隐蔽的一半:每轮回转实测只走 175.7 / 174.8°(速度/加减速限制),**每轮亏
4~5°**;这点亏欠只能靠余量吃,余量给 0 时关节均位会在 7~8 轮后累计外漂、
再也转不回来。
现在的口径(`FRAMEWORK_RULES.md` R2.10):`2×step + 2×余量 ≤ 行程`,
出厂取 `screw_step_deg: 120`(12 轮 × 120° = 1440° = 4 整圈,仍是 360° 的
整数倍);S08 开头用 `/robot_description` 里的**真实限位**算一次
(`gauge_fsm.ratchet_travel_ok`),不通过直接 ABORT 并打印"改哪个键、改成多少"。
出厂值本身有回归锚点:`test_shipped_site_yaml_ratchet_step_fits_every_supported_arm`
会拿 `flexiv_description` 里每台支持臂的 joint7 限位去算。
7. **棘轮的"转向"不能交给"谁更短"。**(同日,S08 第 3 轮 ABORT 的**另一半**)
棘轮原来是"目标角 → 最近一圈的增量"。步长正好 180° 时,最近的那条路取决于
0.1° 级的读数差:调用方按自己读到的 `q=+162.0` 算出目标 `−18.0°`,
而棘轮晚一点读到 `+162.1`,`−180.1°` 折一圈就成了 `+179.9°` —— **方向翻了**。
于是棘轮朝螺纹反方向走,接着贴限位、让余量、重试,**净位移 0** ⇒ ABORT,
而现场什么都没坏(正是"最不该失败的一步失败")。
棘轮的转向是**工艺意图**(S08 顺时针拧入、S09 逆时针拧出),不能由数值巧合
决定:`ratchet_delta_rad(target, q_now, direction=±1)`,四个原子步都显式给方向。
8. **拧出后不竖直退出孔口,横向动作会把规拽出来。**(2026-09-24,k1 装完、k2 报废)
k1 的 S08/S09 走完(规"拧出"了),S10 直接斜着换到 k2 —— 而规**还插在 k1 的
孔里**(仿真里没有螺纹几何,"拧出"只是让规在孔里转;真机也只退回几个毫米)。
横move把规从孔里**拽**出来:抓取锁被拽断、规掉在台面上。
最坑的是**它在这里不报错**:两分钟后表现为"k2 的 S07 下探 35mm 判不到接触",
因为工具上已经没有规了。几何差得极小 —— 规尖比工件面低 30mm、斜线只抬了 29mm。
现在的口径(`FRAMEWORK_RULES.md` R2.11):S09 成功出口之前先**纯 Z** 提到
**跟踪高**(悬停 + 50mm,与 S04 的 `pre_lift` 同一个口径),再做任何横向动作;
`test_gauge_exit_lift.py` 钉住"顺序 + 高度 + 已高就不动",并且提出失败只告警
(那一刻孔里已没有要保护的规,硬失败会把成功的装配变成失败)。
---
+23
View File
@@ -77,6 +77,29 @@ if [[ "${NO_SKIP}" -eq 0 ]]; then
ARGS+=(--packages-skip "${SKIP[@]}")
fi
# --- 先做语法自检(colcon **不**编译 .py)------------------------------------
# colcon 对 Python 包只做拷贝:语法错了它照样"构建成功",而报错会出现在
# 很久以后的 `ros2 run` / `pytest` 收集阶段,且长这样:
# File "install_sim/.../sim_truth_perception.py", line 441
# 看上去像"安装产物坏了",其实根因在源码里(实测过一个中文字符串里混了
# ASCII 引号)。在这里提前炸,能把"源码错"和"安装产物错"分开。
echo "检查 Python 源码语法(colcon 不编译 .py,语法错要到运行时才炸)…"
python3 - <<'PY'
import pathlib
import sys
bad = []
for p in sorted(pathlib.Path("src").rglob("*.py")):
try:
compile(p.read_text(encoding="utf-8"), str(p), "exec")
except SyntaxError as e: # noqa: PERF203
bad.append(f" {p}:{e.lineno}: {e.msg}")
if bad:
print("源码有语法错误,先修再构建:", file=sys.stderr)
print("\n".join(bad), file=sys.stderr)
raise SystemExit(1)
PY
colcon --log-base log_sim "${ARGS[@]}" 2>&1 \
| grep -vE 'anaconda-auth|conda-content-trust|Unknown distribution option|warnings\.warn'
+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:
+5
View File
@@ -82,6 +82,11 @@ _SIDE_WORDS = {
_ARM_SECTION_KEYS = (
"camera", "gripper", "paths", "servo", "handeye", "go_home", "yolo", "perception",
# gauge_assembly:几何类参数是**按台子**的 —— 同一个数在仿真与真机上不同。
# 例:workpiece_surface_z_m 仿真 0.412(臂站地面、台面 0.40m),真机 0.012。
# 不登记就会**静默失效**:site.yaml 里写了 robots[sim].gauge_assembly,
# 合出来还是全局值,S07 的几何自检算错 0.4m 却不报错。
"gauge_assembly",
)
+17
View File
@@ -136,6 +136,23 @@ if [[ "${USE_SIM}" == true ]]; then
echo " python3 scripts/sim_load_gauge.py --dry-run # 只看算出来的位姿" >&2
exit 1
fi
# 工具长**用实测值**,不用 site.yaml 的标称值(2026-09-24 的教训,完整说明见
# sim_load_gauge._write_measured_geometry):仿真里规会在指间下滑到落座位置,
# 而下滑量随装夹变化(实测见过 243 / 254mm)。工具长写小了,二阶段悬停就会把
# 规尖停在工件**里面** —— 接着 S05 的横向对准变成拿规尖拨工件(工件被推走、
# 残差恒不收敛),日志却报"检查手眼/相机安装";写大了则 S07 提前判"台肩落面"。
# 真机没有这个文件(现场装夹后把实测值写进 site.yaml),所以这条只在仿真里走。
GEOM_FILE="${ROOT}/devices/sim/gazebo/config/sim_tool_geometry.yaml"
if [[ -f "${GEOM_FILE}" ]]; then
TOOL_LEN="$(python3 -c 'import sys,yaml;print(yaml.safe_load(open(sys.argv[1],encoding="utf-8"))["tool_tip_below_flange_m"])' "${GEOM_FILE}")"
if [[ -n "${TOOL_LEN}" ]]; then
ARGS+=(tool_tip_below_flange_m:="${TOOL_LEN}")
echo "=== 工具长用**实测值** ${TOOL_LEN}m(${GEOM_FILE#"${ROOT}/"})==="
fi
else
echo "⚠ 没有 ${GEOM_FILE#"${ROOT}/"}:工具长退回 site.yaml 的标称值。" >&2
echo " 上料脚本每次都会重写它 —— 没有这个文件说明上料没走完(或没落盘)。" >&2
fi
fi
echo "=== launch gauge_assembly(自检不过会自动报缺什么) ==="
+16 -1
View File
@@ -87,7 +87,22 @@ export YOLO_AUTOINSTALL=false
export ROS2_WS="${ROOT}"
# 仿真单独一个 DDS domain:避免与正在跑的真机节点互相抢话题/污染 TF。
# 真机用的是默认 domain,两者天然隔离。
export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-77}"
#
# ⚠️ 这里**强制** 77,不是 `${ROS_DOMAIN_ID:-77}`(2026-09-24 修的)。
# 写法上"尊重已有值"看起来更礼貌,实际会把隔离**交给运气**:实测本机 shell 里
# 就 export 着 ROS_DOMAIN_ID=0(真机默认),于是 `${ROS_DOMAIN_ID:-77}` 当场取到 0,
# 仿真栈和真机落在**同一个 domain** 上 —— 仿真的运动指令可能被真机控制器订阅到。
# 这是最坏的一类错误(动真机),而现场只会看到"脚本打印了 77、实际是 0"。
# 要换 domain 请显式设 SIM_ROS_DOMAIN_ID(名字里带 SIM,不会被真机环境的 export 撞上)。
if [[ -n "${SIM_ROS_DOMAIN_ID:-}" ]]; then
export ROS_DOMAIN_ID="${SIM_ROS_DOMAIN_ID}"
elif [[ "${ROS_DOMAIN_ID:-77}" != "77" ]]; then
echo "[sim_env] ⚠ 环境里已有 ROS_DOMAIN_ID=${ROS_DOMAIN_ID}(真机通常用 0)——" >&2
echo "[sim_env] 已**强制**改成 77:隔离不能靠「环境里恰好没设」。要另指定请设 SIM_ROS_DOMAIN_ID。" >&2
export ROS_DOMAIN_ID=77
else
export ROS_DOMAIN_ID=77
fi
# =============================================================================
# Gazebo 的插件与资源搜索路径 —— 必须在这里设,不能只在 launch 里设
+340 -41
View File
@@ -91,12 +91,78 @@ GAUGE_GRIP_DIA_MM = 30.0
#: gz 报得出 Contacts、夹爪也能读到接触,但规就是托不住、一路滑下去。
GAUGE_GRASP_CENTER_M = 0.043
#: 标定结果的合理性上限 [mm]:|开口 0 时的指间间距| 超过它就判标定无效。
#: 指节是连杆机构、间距与开口斜率实测 1.000,所以这个量应是个小偏置;
#: 大得离谱只有一个原因 —— 合爪时手指被台面/工件挡住了(量到的是被挡的值)。
#: 实测(2026-09-24):home 位姿下量出 −10.15mm,扫描区间整体偏 10mm,
#: 17 档全掉,白扫 80 秒。
CAL_SANITY_MM = 6.0
# 台面高度(scene.yaml 的 table_top_z):用来判断"够不够空间把规塞进去"。
FALLBACK_TABLE_Z = 0.40
# 触觉"夹住了"的判据:contact 位或 |fnormal| 超过这个噪声地板。
# 空载时仿真触觉的 fnormal 实测 ~1e-5(见 config/tactile.yaml 的基线说明)。
TACTILE_NOISE_FLOOR = 0.05
#: 合爪后规在指间**允许**的下滑量 [m]。超过就判"这次装夹和上次不一样"。
#:
#: 为什么是 3mm:下滑量会直接加在工具长上(site.yaml 的
#: ``gauge_assembly.tool_tip_below_flange_m``)。而流程现在**读实测值**
#:(见 :func:`_write_measured_geometry`),所以下滑本身不再是错误 ——
#: 它只是"这次的工具长和上次不同",改由实测值带着走。
#: 超过这个量仍然要**说出来**:说明装夹方式不稳定,值得人看一眼。
#: 实测(2026-09-24):同一个仿真台先后测到 254.4mm 与 243mm(差 11mm)。
DRIFT_SLIP_MAX_M = 0.003
def _write_measured_geometry(
path: Path,
*,
below: float,
flange_z: float,
tip_z: float,
world: str,
model: str,
) -> Optional[Path]:
"""把本次实测的工具几何落盘(供 ``run_gauge_assembly.sh --sim`` 读)。
**为什么要有这个文件**(2026-09-24 一整轮排查换来的):
仿真里规在指间会下滑到落座位置(合爪时规被两指挤住,会一直滑到
它的台阶卡在指腹上为止)。下滑量**随装夹变化** —— 实测见过 243mm
与 254mm 两种,差 11mm。而 site.yaml 里那个值决定两件事:
· 二阶段悬停高度(写小了 → 悬停时规尖已经插进工件 → S05 的横向
对准变成拿规尖拨工件,工件被推走、残差恒不收敛);
· S07 的下探预算(写小了 → 台肩还没落面就用完预算)。
两个症状都离真因(几何)很远,所以**不许**让它靠"人手抄一个数字"
来保持一致:上料脚本测出来就落盘,流程读它。
写入失败**不抛异常**(返回 ``None``):上料这件事本身已经成功了,
调用方只需要把"退回标称值"这件事喊出来。
"""
try:
data = {
"tool_tip_below_flange_m": round(float(below), 4),
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"world": world,
"model": model,
"flange_z_m": round(float(flange_z), 4),
"tip_z_m": round(float(tip_z), 4),
}
header = (
"# ⚠ 本文件由 scripts/sim_load_gauge.py 自动生成(每次仿真上料都覆盖)。\n"
"# 它是**这次装夹**实测到的工具几何,run_gauge_assembly.sh --sim 会读它\n"
"# 并作为启动参数覆盖 site.yaml 的 gauge_assembly.tool_tip_below_flange_m。\n"
"# 为什么必须这样:仿真里规会在指间下滑到落座位置,下滑量随装夹变化\n"
"# (实测见过 243mm / 254mm 两种)。写成固定值迟早会算错悬停高度。\n"
"# 真机没有这个文件(现场装夹后把实测值写进 site.yaml)。\n"
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(header + yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
return path
except Exception: # noqa: BLE001 - 见 docstring:失败只退回标称值
return None
def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as fh:
@@ -194,6 +260,59 @@ def gauge_target_pose(
return tip, q_gauge
def _reset_scene_objects(scene_cfg, world: str, spin_for) -> None:
"""把 ``scene.yaml`` 里**所有**物体放回名义位姿(幂等)。
为什么不止复位通止规(2026-09-24 实测踩到,代价是白跑一轮):
上一轮 S08 带着 ~70N 的轴向力转了一圈,把**工件**推走了 ——
实测 |Δp|=76.9mm、yaw 偏 8.3°。而真值感知是**纯几何**的:它按
``scene.yaml`` 的名义位姿算孔位/目标点,物体一挪,算出来的就是假测量。
幸好有漂移看门狗(``sim_truth_perception``)当场停下不发点,否则现象会是
「S05 两孔间距 169mm」或「S07 一路下压不接触」,看起来像检测坏了。
但看门狗只会"停下来",它**不会把工件搬回去** —— 于是下一轮跑不了,
报的还是那句「一个视觉目标点都没收到」,看起来像感知节点根本没起来。
复位这一下,让"重跑"真的从干净状态开始,不必重启整个仿真。
顺序:先全部搬回名义位姿,再等物理稳一下。逐条打印结果 ——
哪一件没复位成功必须看得见(它会以"真值不发点"的形式在下游爆出来)。
"""
objs = [o for o in (scene_cfg.get("objects") or []) if o.get("pose")]
if not objs:
print("⚠ scene.yaml 里没有带 pose 的物体,跳过场景复位。")
return
failed: List[str] = []
for obj in objs:
name = str(obj.get("name") or "")
if not name:
continue
p = obj.get("pose") or {}
try:
x = float(p.get("x", 0.0))
y = float(p.get("y", 0.0))
z = float(p.get("z", 0.0))
yaw = float(p.get("yaw", 0.0))
except (TypeError, ValueError) as exc:
print(f"⚠ {name}: pose 字段不是数字({exc}),跳过。")
failed.append(name)
continue
quat = (0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0))
ok, out = _set_gauge_pose(world, name, (x, y, z), quat)
if ok:
print(f"=== 复位: {name} → ({x:.3f}, {y:.3f}, {z:.3f}) yaw={math.degrees(yaw):.1f}° ===")
else:
print(f"⚠ 复位失败: {name}:{out[:120]}")
failed.append(name)
if failed:
print(f"⚠ 以下物体没能复位:{', '.join(failed)}\n"
f" 后果:真值感知会(正确地)拒绝发点,S04 收不到目标点、报"
f"「一个视觉目标点都没收到」。\n"
f" 真修法:重启仿真(bash scripts/run_sim_gz.sh)让它按场景重新 spawn。")
# 让物理静一下再往下走:连着瞬移多个物体会让接触求解器抖几下。
if objs:
spin_for(1.5)
def _run(cmd: List[str], timeout: float = 8.0) -> Tuple[int, str]:
"""跑一条外部命令,返回 (退出码, 合并输出)。不抛异常(调用方自己判)。"""
try:
@@ -208,34 +327,13 @@ def _run(cmd: List[str], timeout: float = 8.0) -> Tuple[int, str]:
def _set_gauge_pose(world: str, name: str, pos, quat) -> Tuple[bool, str]:
"""用 Gazebo 的世界服务把模型搬到指定位姿。
这里用 ``ign service`` 而不是另起一个 ROS 桥:仿真的场景加载本来就是
用命令行 `ros_gz_sim create` 做的(见 arm_sim_gazebo/sim_scene.py),
口径一致、少一个需要维护的话题/服务桥。
注意 ``--reqtype`` 必须写 ``ignition.msgs.Pose``(本环境实测:写
``gz.msgs.Pose`` 会报 "Unable to create request",一个字母的差别、
报错信息完全没提原因)。
实现**只有一份**:``arm_sim_gazebo.sim_pose_service.set_model_pose``
(那个 `--reqtype` 必须写 ``ignition.msgs.Pose`` 的坑也写在那边)。
真值感知的启动复位走的是同一个函数。
"""
req = (
f'name: "{name}", '
f"position: {{x: {pos[0]:.6f}, y: {pos[1]:.6f}, z: {pos[2]:.6f}}}, "
"orientation: {"
f"x: {quat[0]:.8f}, y: {quat[1]:.8f}, z: {quat[2]:.8f}, w: {quat[3]:.8f}"
"}"
)
code, out = _run(
[
"ign", "service",
"-s", f"/world/{world}/set_pose",
"--reqtype", "ignition.msgs.Pose",
"--reptype", "ignition.msgs.Boolean",
"--timeout", "3000",
"--req", req,
],
timeout=10.0,
)
ok = code == 0 and "data: true" in out
return ok, out.strip()
from arm_sim_gazebo.sim_pose_service import set_model_pose
return set_model_pose(world, name, pos, quat)
def _gauge_pose_now(name: str) -> Optional[Tuple[float, float, float]]:
@@ -977,6 +1075,64 @@ def _ros_side(args) -> int: # noqa: C901 - 线性流程,拆开反而更难读
print("=== --place-only:不动夹爪,只按当前姿态摆位 ===")
else:
print("=== 开爪(保证两指之间能让规进去) ===")
# ---- 0. 归位:先把规放回规座(幂等,避免上一次的残留污染这一次)--------
# 为什么必须做:本脚本是要**反复重跑**的(调试时一分钟一次)。上一次跑完规
# 可能还卡在指间/落在台面槽里,而后面两步都会被"指间有东西"毁掉:
# · 标定指间间距:规卡在指间时量到的是**规的外径**(实测 28.29mm 而不是
# 20mm),于是 gap_cal 凭空多出 8mm,整个扫描区间偏移 → 一处都夹不到;
# · 摆位:规已经在指间时再瞬移一次,落点是在旧位姿上叠的。
# 先把它放回 scene.yaml 写的位置再开始,行为就与"刚启动仿真"完全一致。
if not args.place_only:
# 先解开可能残留的抓取锁:上锁之后规是被**固定关节**钉在手指上的,
# 这时瞬移回规座会被关节立刻拉回来(实测:瞬移瞬间 z 变 0.36 又弹回),
# 而手指合到底也只能到"规的外径"(实测 28.29mm 而不是 20mm)——
# 于是标定读到的正是规径,gap_cal 凭空多 8mm。
try:
from arm_sim_gazebo.sim_grasp_lock import load_grasp_lock, publish_lock_event
_spec = load_grasp_lock(root=ROOT)
if _spec.enabled and publish_lock_event(_spec, False):
print("=== 归位前先解锁(清掉上一次可能残留的抓取锁) ===")
spin_for(0.5)
except Exception: # noqa: BLE001 - 锁是可选补救措施,读不到就跳过
pass
nom = None
for obj in (scene_cfg.get("objects") or []):
if str(obj.get("name")) == args.model:
p = obj.get("pose") or {}
try:
nom = (
float(p.get("x", 0.0)),
float(p.get("y", 0.0)),
float(p.get("z", 0.0)),
float(p.get("yaw", 0.0)),
)
except (TypeError, ValueError):
nom = None
break
if nom is None:
print(f"⚠ scene.yaml 里没有 {args.model} 的名义位姿,跳过「归位」这一步。")
else:
yaw = nom[3]
quat = (0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0))
ok, out = _set_gauge_pose(args.world, args.model, nom[:3], quat)
if ok:
print(f"=== 归位:把 {args.model} 放回规座 "
f"({nom[0]:.3f}, {nom[1]:.3f}, {nom[2]:.3f}) ===")
wait_tf(tcp_frames["left"], timeout_s=1.0) # 顺便让物理先稳一下
spin_for(1.5)
else:
print(f"⚠ 归位失败({args.model} 可能还在指间):{out[:120]}")
print(" 后果:下面的标定会量到规的外径,扫描区间整体偏移。")
# 顺带把**其它**场景物体也复位(最要紧的是工件)。
# 为什么:上一轮 S08 若带着大轴向力转了一圈,工件会被推走,而真值感知
# 按名义位姿算孔位 —— 看门狗会(正确地)停发点,于是下一轮报
# "一个视觉目标点都没收到",看起来像感知节点没起来。
# 详见 _reset_scene_objects 的说明。
_reset_scene_objects(scene_cfg, args.world, spin_for)
if not args.place_only:
if not gripper_call(cli_open, "open"):
print("✗ /gripper/open 调用超时")
@@ -1015,6 +1171,24 @@ def _ros_side(args) -> int: # noqa: C901 - 线性流程,拆开反而更难读
dz = pts["left"][2] - pts["right"][2]
return (dx * dx + dy * dy + dz * dz) ** 0.5 * 1000.0
# ---- 1.5 摆到"竖直上料位姿"(工具轴朝下 + 离台面够高) ---------------
# ⚠️ 这一步必须在**标定之前**(见下面对 gap_cal 的合理性检查):
# 标定要在"手指前方是空的"前提下量,而零位/ home 位姿又低又斜,
# 合爪时手指很容易顶在台面或工件上 —— 那时量到的间距是**被挡住的值**,
# 于是 gap_cal 凭空差出十几毫米,整个扫描区间都跟着错,一处都夹不到。
# 实测(2026-09-24):home 位姿下标定出"开口 20mm 时间距 9.85mm",
# 扫了 17 档全掉,白跑 80 秒。
# 不摆的话:仿真零位工具轴是**水平**的,水平夹一根钢规会被重力抽出去
#(实测合爪后漂移 857mm,直接掉回规座);home 位姿又斜又低,放不下。
# 这里用 IK 把两指 TCP 中点送到台面上方的空旷处、工具轴朝下。
if not args.place_only and not args.no_pose:
if not _go_vertical_load_pose(
node, buf, spin_for, wait_tf, tcp_frames, args,
):
print("✗ 摆竖直上料位姿失败。可以用 --no-pose 跳过(自己先把臂摆好)。")
rclpy.shutdown()
return 2
if not args.place_only:
if not gripper_call(cli_move, "move", position=args.cal_aperture_mm):
print("✗ /gripper/move 调用超时(标定用)")
@@ -1029,18 +1203,19 @@ def _ros_side(args) -> int: # noqa: C901 - 线性流程,拆开反而更难读
print(f" 开口 {args.cal_aperture_mm:.2f}mm → 指间间距 {gap_at_aperture:.2f}mm")
print(f" ⇒ 开口 0(全闭合)时间距 {gap_cal_mm:.2f}mm,"
f"间距 = 开口 + {gap_cal_mm:.2f}mm")
# ---- 1.5 摆到"竖直上料位姿"(工具轴朝下 + 离台面够高) ---------------
# 不摆的话:仿真零位工具轴是**水平**的,水平夹一根钢规会被重力抽出去
#(实测合爪后漂移 857mm,直接掉回规座);home 位姿又斜又低,放不下。
# 这里用 IK 把两指 TCP 中点送到台面上方的空旷处、工具轴朝下。
if not args.place_only and not args.no_pose:
if not _go_vertical_load_pose(
node, buf, spin_for, wait_tf, tcp_frames, args,
):
print("✗ 摆竖直上料位姿失败。可以用 --no-pose 跳过(自己先把臂摆好)。")
rclpy.shutdown()
return 2
# 合理性检查:指节是连杆机构,间距与开口严格线性、斜率实测 = 1.000,
# 所以 gap_cal(开口=0 时的间距)应该是个**小量**(几毫米量级)。
# 它大得离谱时唯一的原因是"手指被挡住了":量到的是被挡的位置。
# 这个检查为什么值得写:不查的话后面会白扫十几档、最后只说
# "都没夹住",还列一堆可能原因 —— 而真因是标定这一步就错了。
if abs(gap_cal_mm) > CAL_SANITY_MM:
print(f"✗ 标定结果不可信:开口 0 时间距 {gap_cal_mm:.2f}mm"
f"(正常应在 ±{CAL_SANITY_MM:.0f}mm 内)。")
print(" 最可能:合爪时手指顶在台面/工件上(手指前方不是空的)。")
print(" 修法:先把臂摆到空旷处再上料 ——")
print(" bash scripts/go_home.sh 或用 --no-pose 自行摆位。")
rclpy.shutdown()
return 2
# ---- 1.8 摆位(纯瞬移)与"人手托着合爪" -------------------------------
#
@@ -1264,8 +1439,14 @@ def _ros_side(args) -> int: # noqa: C901 - 线性流程,拆开反而更难读
"跳过位姿校验,只看触觉。")
else:
drift = math.dist(now_pos, tip_pos)
print(f"=== 校验:规还在不在手里 ===\n 位姿漂移 {drift * 1000:.1f}mm"
f"(合爪后被顶开/顶飞会远大于 20mm)")
print(f"=== 校验:规还在不在手里 ===")
print(f" 位姿漂移 {drift * 1000:.1f}mm。两种漂移**性质不同**,别混:")
print(" · > 20mm → 合爪时被顶飞 / 模型名或世界名不对(上料失败)")
print(" · 3~20mm → 规在指间**下滑**到了它的落座位置。这不是上料失败,"
"而是")
print(" site.yaml 的 tool_tip_below_flange_m 与实际装夹不符 ——")
print(" 下滑量会**直接加在工具长上**,写小了二阶段悬停就会把")
print(" 规尖停在工件里面(S05 横向对准于是拿规尖拨工件)")
tac_ok = False
for side in ("left", "right"):
item = tac.get(side)
@@ -1283,6 +1464,18 @@ def _ros_side(args) -> int: # noqa: C901 - 线性流程,拆开反而更难读
if drift is not None and drift > 0.02:
problems.append(f"规没在位姿上(漂移 {drift * 1000:.1f}mm)"
":可能合爪时被顶飞、或模型名/世界名不对")
elif drift is not None and drift > DRIFT_SLIP_MAX_M:
# 3~20mm:规落到了指间的落座位置。**不是**上料失败 —— 它是"这次的
# 工具长和 site.yaml 里那个值不一样",而工具长稍后会实测并落盘,
# 流程读的是实测值(见 _write_measured_geometry)。所以这里只"说出来":
# 下滑量偏大说明装夹不稳定,值得人看一眼。
print(f" ⚠ 规在指间下滑了 {drift * 1000:.1f}mm 才落座 —— 这次的工具长与"
f"site.yaml 的标称值差了这么多(不是上料失败):")
print(" 下滑量会直接加在工具长上。稍后的「实测工具几何」会把它测出来并"
"落盘成")
print(" sim_tool_geometry.yaml,流程(--sim)会读它覆盖标称值 —— 所以"
"能继续跑。")
print(" 但下滑量偏大说明装夹不稳,值得确认一下规是不是卡在指腹上了。")
if not tac_ok:
problems.append("两指触觉都没读到接触:可能没合上(看上面开口)、或规不在两指之间")
if problems:
@@ -1296,6 +1489,112 @@ def _ros_side(args) -> int: # noqa: C901 - 线性流程,拆开反而更难读
print("✓ 通止规已装进夹爪(位姿贴合 + 触觉有接触)。可以跑装配了:")
print(" bash scripts/run_gauge_assembly.sh --sim --holes 1")
# ---- 4. 上"抓取锁"(仿真专用)------------------------------------------
# 为什么由**这里**上锁,而不是等夹爪适配器自己判:
# 适配器的 is_gripped 判据是"指令到全闭合、实际被工件顶住"(真机 DH 没有
# 夹持状态字,只能这么推)。而本脚本是**按直径精确合到某个开口**的
# (例如 24.9mm 卡住 φ30 的规),指令与实际一致 —— 于是适配器**判不出**
# "夹住了",锁也就不会触发。
# 本脚本是"装夹"这件事的执行者,它自己知道刚刚校验过(位姿贴合 + 触觉
# 有接触),所以由它上锁最准,也最好解释。
# 解锁不需要在这里做:夹爪适配器在收到 open / 开口变大的 move 时会解锁
#(见 sim_gripper_adapter._release_grasp_lock)。
try:
from arm_sim_gazebo.sim_grasp_lock import load_grasp_lock, publish_lock_event
_spec = load_grasp_lock(root=ROOT)
if _spec.enabled and _spec.object == args.model:
if publish_lock_event(_spec, True):
print(f"✓ 抓取锁已上:{_spec.object}.{_spec.object_link} 固定到 "
f"{_spec.robot_model}.{_spec.robot_link}")
print(" (仿真专用补丁:Gazebo 的刚性接触给不出真实指垫的静摩擦,"
"不上锁时规会在连续轨迹里打滑 —— 真机 46N 握力 + µ≈0.8 本来就不掉)")
else:
print("⚠ 抓取锁没上成(详见日志)——机械臂连续运动时规可能打滑。")
elif _spec.enabled:
print(f"⚠ 抓取锁配的对象是 {_spec.object},与 --model {args.model} 不一致,跳过上锁。")
except Exception as exc: # noqa: BLE001 - 锁是补救措施,读不到配置不该拦住上料
print(f"⚠ 读抓取锁配置失败,跳过上锁:{exc}")
# ---- 7. 实测工具几何(给 site.yaml 的 tool_tip_below_flange_m 用)----
# 为什么必须**实测**而不是推算:这个值决定 S07 的悬停/下探预算(见
# gauge_fsm.descend_needed_m),差 10mm 就会让"台肩落面"永远到不了。
# 推算要凑三层偏移(TCP 在法兰下方多少 + 规尖在 TCP 下方多少),而
# 中间任何一层的定义(比如 tcp 帧是零偏移还是带偏置)都可能与假设不同。
# 实测只有一次减法:法兰 z − 规尖 z。真机同法(夹住规后直接读 TF)。
got_e2 = wait_tf(args.ee_frame, timeout_s=3.0)
tip_now = _gauge_pose_now(name)
if got_e2 is not None and tip_now is not None:
p_ee = got_e2[0]
below = float(p_ee[2]) - float(tip_now[2])
print("\n=== 实测工具几何(抄进 config/site.yaml 的 gauge_assembly)===")
print(f" 法兰 z={p_ee[2]:.4f} 规尖 z={tip_now[2]:.4f}"
f" → **tool_tip_below_flange_m: {below:.3f}**")
if below <= 0.0:
print(" ⚠ 规尖不在法兰下方 —— 多半是规掉了或者没夹在指间,"
"别把这个值抄进配置。")
else:
# ⚠️ 这里以前算的是「当前法兰 z − 工具长」,也就是**上料时臂停在哪**,
# 与悬停高度毫无关系 —— 实测它印出"工件面 z=0.412 → 尖端 z=0.5201",
# 看着很安全,而真正决定"会不会把规捅进工件"的是 site.yaml 里
# servo.hole_hover_z_m(当时 0.632 ⇒ 尖端 0.387 = 面**下** 25mm)。
# 现在按配置的悬停高度算,并且复用 gauge_fsm 的判据(一份实现)。
try:
from arm_application.gauge_fsm import hover_z_guard
site = _load_yaml(ROOT / "config" / "site.yaml")
rob = next(
(r for r in (site.get("robot") or {}).get("robots") or []
if str(r.get("sn")) == str(getattr(args, "arm", "sim"))),
{},
)
ga = dict(site.get("gauge_assembly") or {})
ga.update(rob.get("gauge_assembly") or {})
servo = dict(site.get("servo") or {})
servo.update(rob.get("servo") or {})
surface = float(ga.get("workpiece_surface_z_m") or 0.0)
hover = float(servo.get("hole_hover_z_m") or 0.0)
# 目标间隙与二阶段"最后轻降"用的是**同一个**配置(hole_detect.stage2
# .final_gap_mm):都是"规尖悬在工件面上方多少"。两处各读一份迟早
# 会打架,而症状是"上料脚本说安全、stage2 说危险"。
st2 = dict((site.get("hole_detect") or {}).get("stage2") or {})
gap = float(st2.get("final_gap_mm") or 2.0) / 1000.0
fixed, why = hover_z_guard(hover, surface, below, gap)
print(f" 用它核二阶段悬停高:servo.hole_hover_z_m={hover:.3f}、"
f"工件上表面 z={surface:.3f}")
if why:
print(" ⚠ **悬停时规尖会捅进工件**(S05 的横向对准会拖着工件走):")
print(f" {why}")
else:
print(f" → 悬停时规尖离工件面 "
f"{(hover - below - surface) * 1000:+.1f}mm(安全)")
except Exception as exc: # noqa: BLE001 - 这一项只是提示,别拦住上料
print(f" (悬停高度自检跳过:{exc})")
# ---- 把实测值落盘,交给流程当启动参数 --------------------------
# 为什么不能只"打印出来让人抄进 site.yaml"(2026-09-24 实测的教训):
# 仿真里规会在指间下滑到落座位置,而**下滑量随装夹变化** ——
# 同一个仿真台先后测到过 0.243 和 0.254(差 11mm)。写死哪个值,
# 下一次都可能算错悬停高度,而错误现象是"S05 残差恒不收敛"
# (看起来像手眼坏了)。所以:**测量 → 落盘 → 流程读它**。
# 真机走不到这里(真机由人装夹、同法读 TF,但由现场把值写进 site.yaml)。
geom_path = _write_measured_geometry(
ROOT / "devices" / "sim" / "gazebo" / "config" / "sim_tool_geometry.yaml",
below=below,
flange_z=float(p_ee[2]),
tip_z=float(tip_now[2]),
world=args.world,
model=name,
)
if geom_path is None:
print(" ⚠ 实测值没能落盘 —— 流程会退回 site.yaml 的标称值,"
"悬停/下探预算可能差几毫米到十几毫米。")
else:
print(f" 已落盘:{geom_path.relative_to(ROOT)}"
f"(run_gauge_assembly.sh --sim 会把它当启动参数覆盖 site.yaml)")
else:
print("\n(读不到法兰/规尖的位姿,跳过工具几何实测)")
rclpy.shutdown()
return 0
@@ -94,6 +94,9 @@ from arm_application.gauge_config import (
)
from arm_application.gauge_fsm import (
DESCEND_STALL_STEPS,
ROT_AXIS,
ROT_LIMIT,
ROT_SHORT,
Event,
FsmError,
GaugeContext,
@@ -109,9 +112,15 @@ from arm_application.gauge_fsm import (
pixel_stable,
points_stable,
ratchet_cycles,
ratchet_delta_rad,
ratchet_travel_ok,
relief_tol_m,
rotation_verdict,
screw_stop,
tangential_jump,
tool_clearance_m,
wrench_live,
wrench_touch,
)
from arm_application.gauge_gripper import (
CONTRACT_STATUS_TOPIC,
@@ -139,9 +148,26 @@ from arm_application.stage2_locate_node import _bgr_copy, _pt as _joint_point
#: 允许的"绕轴验证"误差 [deg]。超过就说明这根关节轴不是工具 Z 轴,
#: 后面所有以"转多少度"为单位的动作都不成立 —— 必须报错而不是继续。
AXIS_VERIFY_TOL_DEG = 5.0
#: 棘轮转腕时距离关节限位保留的余量 [deg]。
#: 3° 不够用:实测轨迹会**自己**少走 ~5°(比如命令 180° 只到位 175.1°,
#: 而关节7 的限位正好是 175.06° —— 两者叠在一起,判断"是被限位截住还是
#: 没走完"就分不清了)。留 8° 让这两种情况都还没有贴住。
RATCHET_LIMIT_MARGIN_DEG = 8.0
#: 限制"跟踪丢失 → 粗定位 → 跟踪"的来回次数,防死循环。
MAX_TRACK_RETRIES = 3
#: ``stage2_locate`` 返回码 → 人话。为什么要这张表:日志里只有 ``code=6`` 时,
#: 现场只能去翻源码;而每个码对应的**下一步动作完全不同**(垂直化/粗定位/
#: 检测/对准/工件被推走),写在这里就不用来回翻了。
_LOCATE_CODE_HINT: Dict[int, str] = {
2: "(垂直化或悬停失败:工具轴没竖直、或悬停高度到不了)",
3: "(粗定位失败:视野里没框到工件 —— 看相机/光照/权重)",
4: "(k1/k2 解不出来:孔被挡、曝光不对、或孔根本没在视野里)",
5: "(对准迭代用满仍未收敛:检查手眼/相机安装,或放宽 align_tol_m)",
6: "(工件被工具推走:不是手眼/检测的问题,是悬停时工具尖端已经进了工件"
" —— 查 gauge_assembly.tool_tip_below_flange_m 与 servo.hole_hover_z_m)",
}
class GaugeAbort(RuntimeError):
"""阶段内部判定要中止。带上 exit code,方便脚本区分原因。"""
@@ -158,6 +184,11 @@ class GripperClient:
(真机今天是 Trigger,契约/仿真 是 GripperExecute),应用层不需要知道。
"""
#: 判"掉件"的开口塌陷量 [mm]:被夹住的工件掉了以后,两指会一路合到底,
#: 开口从"工件宽度"塌下去几十毫米。为什么不用 ``is_gripped`` 瞬时值 —— 见
#: :meth:`dropped` 的说明(那是个推断值,会被 S08 的指令切换抖出假 E1)。
DROP_COLLAPSE_MM = 5.0
def __init__(self, node: Any, backend: str, status_topic: str) -> None:
self._node = node
self.backend = backend
@@ -166,6 +197,10 @@ class GripperClient:
self._last_gripped: Optional[bool] = None
self._legacy_grip_state: Optional[int] = None
self._ever_gripped = False
#: 最近一帧的开口 [mm](契约后端才有)。掉件判据看它。
self._last_aperture: Optional[float] = None
#: "夹住那一刻"的开口 [mm]。掉件判据是相对它的**塌陷量**,不是绝对值。
self._grip_aperture: Optional[float] = None
if backend == "contract":
self._open_cli = node.create_client(GripperExecute, "/gripper/open")
@@ -178,9 +213,15 @@ class GripperClient:
# -- 状态回调 ---------------------------------------------------------
def _on_contract(self, msg: GripperStatus) -> None:
pos = float(getattr(msg, "position", float("nan")))
if math.isfinite(pos):
self._last_aperture = pos
self._last_gripped = bool(msg.is_gripped)
if self._last_gripped:
self._ever_gripped = True
# ⚠️ 这里**故意不**记"夹住时的开口"。曾经记过,代价是一个假 E1:
# 本后端的 is_gripped 是**推断值**,夹爪**全开**时也会闪成 True
# (实测 99.29mm,见 dropped 的说明),于是基准被记成 99.29mm、
# 塌陷量算成 77mm ⇒ "通止规掉了(E1)",而规好好地在爪里。
# 基准只由 :meth:`adopt_current_grip` 在**明确知道夹住了**的时候记。
def _on_legacy(self, msg: Float32MultiArray) -> None:
fields = legacy_states_to_fields(list(msg.data))
@@ -191,6 +232,8 @@ class GripperClient:
if got is not None:
self._last_gripped = got
if got:
# 老后端只有状态字、没有开口,掉件判据本来就吃状态字(见 dropped),
# 所以这里记"曾经夹住过"是对的。
self._ever_gripped = True
# -- 查询 -------------------------------------------------------------
@@ -205,15 +248,109 @@ class GripperClient:
"""``True``/``False``/``None``(后端给不出明确答案)。"""
return self._last_gripped
def aperture(self) -> Optional[float]:
"""最近一帧的**开口** [mm](只有契约后端的 ``position`` 有;没有返回 None)。"""
return self._last_aperture
def grip_aperture(self) -> Optional[float]:
"""第一次判到"夹住"时的开口 [mm](掉件判据的基准,见 :meth:`dropped`)。"""
return self._grip_aperture
def collapse_mm(self) -> Optional[float]:
"""相对夹住时的**开口塌陷量** [mm](正 = 比夹住时更小)。拿不到返回 None。
S08/S09 每轮都要"松爪 → 回转 → 夹紧",日志里必须看得到这个数:
它一路变大就说明每轮夹紧都比上一轮更往里挤,或者规在往下滑。
"""
if self._grip_aperture is None or self._last_aperture is None:
return None
return float(self._grip_aperture) - float(self._last_aperture)
# -- 开口的"等稳定"与"记基准" ------------------------------------------
def settle(self, *, settle_s: float = 1.0, tol_mm: float = 0.05) -> Optional[float]:
"""等开口**稳定**下来,返回稳定后的开口 [mm](拿不到读数返回 None)。
为什么必须有这个(2026-09-24 用日志抓到的):
``close``/``open`` 服务**返回时动作还没做完**(JTC 轨迹是异步的)。
立刻读开口会读到**松手前的旧值**——实测 S08 夹紧后立刻读,读到的还是
全开 99.93mm,于是日志与基准全错("夹住时 99.29mm")。谁要在动作之后
判断开口,都必须先过这里。
"""
spin = getattr(self._node, "_spin", None)
deadline = time.monotonic() + max(0.0, settle_s)
prev = self._last_aperture
stable = 0
while True:
if spin is not None:
spin(0.05)
else: # pragma: no cover - 只有测试桩走这里
time.sleep(0.05)
now = self._last_aperture
if now is not None and prev is not None and abs(float(now) - float(prev)) <= tol_mm:
stable += 1
if stable >= 2:
return float(now)
else:
stable = 0
prev = now
if time.monotonic() >= deadline:
return None if now is None else float(now)
def adopt_current_grip(self, why: str, *, settle_s: float = 1.0) -> Optional[float]:
"""把**当前**开口记为"夹住时的开口"(掉件判据的基准),并记"曾经夹住过"。
只在**明确知道爪里夹着规**的时刻调用(S00 自检通过后、以及每一次
确认夹紧之后)——它是掉件判据唯一的参照,随手记会让判据失去意义
(见 :meth:`dropped` 与 :meth:`_on_contract` 的说明)。
"""
ap = self.settle(settle_s=settle_s)
if ap is None:
# 拿不到开口(老固件/别的夹爪)也要记"曾经夹住过":否则 dropped()
# 会永远返回 False,那比"会抖的旧判据"更糟(真掉件也看不见)。
self._ever_gripped = True
self._node.get_logger().warn(
f"拿不到开口读数({why})—— 掉件判据退回状态字,会抖但不会漏"
)
return None
self._grip_aperture = float(ap)
self._ever_gripped = True
self._node.get_logger().info(f"夹持基准:开口 {ap:.2f}mm({why})")
return float(ap)
def dropped(self) -> bool:
"""E1 掉件:**曾经夹住过**,现在报"掉落"。
装配前爪里本来就空,那时状态字也可能是"掉落/无物",直接判会一开场就
报 E1(``is_dropped_from_state`` 里解释了这一点)。
**契约后端的判据是"开口塌陷",不是 ``is_gripped`` 的瞬时翻转**
(2026-09-24 实测踩到,值得完整读):
仿真里 ``is_gripped`` 是**推断值**("指令到了合拢侧 + 实际开口比指令
大出容差")。S08 会在"合到某个开口 / 全合"之间发指令 —— 只要某一帧
的指令落到与实际开口同一档,``is_gripped`` 就翻成 False。于是
**S08 第 2/12 轮直接报 E1**(爪里其实好好的,是判据在抖),
然后整条流程去等人工"放好通止规",而仿真里根本没人能做这件事。
更坏的是这个推断值在**全开**时也会闪成 True(实测 99.29mm)——
拿它当基准就会得到"塌陷 77mm"的假 E1。所以基准只由
:meth:`adopt_current_grip` 明确记录(见 :meth:`_on_contract`)。
真正"掉了"的物理表现是**两指合到底**:开口从"工件宽度"塌到几毫米,
是几十毫米的变化,不会被指令切换带偏。所以判据用
``夹住时的开口 − 当前开口 ≥ DROP_COLLAPSE_MM``。
"""
if not self._ever_gripped:
return False
if self.backend == "legacy" and self._legacy_grip_state is not None:
return is_dropped_from_state(self._legacy_grip_state, self._ever_gripped)
return self._ever_gripped and self._last_gripped is False
if self._grip_aperture is None or self._last_aperture is None:
# 没有开口读数(老固件/别的夹爪不给 position):退回旧判据。
# 它确实会抖,但比"永远判不了掉件"好 —— 而且这种情况本身会
# 在 S00 的自检里被 `position` 相关检查点到。
return self._last_gripped is False
return (
float(self._grip_aperture) - float(self._last_aperture)
) >= self.DROP_COLLAPSE_MM
# -- 动作 -------------------------------------------------------------
def _call(
@@ -310,12 +447,31 @@ class GaugeAssembly(AssemblyPipeline):
self.declare_parameter("result_file", "/tmp/gauge_assembly_result.json")
site = load_site_dict()
# robots[].gauge_assembly 覆盖全局段 —— 与 servo / paths / perception 同一套规矩。
# 为什么必须有这条(2026-09-24):几何类参数是**按台子**的。
# `workpiece_surface_z_m` 在仿真里是 0.412(仿真臂站在地面、台面 0.40m 高),
# 真机是 0.012(基座与台面同高)。写死全局值的话,S07 的
# "悬停时工具尖端离面多高"自检会算错 —— 而现象是"一路下压判不到接触",
# 看起来像力阈值标歪了,排查会从阈值开始走偏。
_robot = self._robot_dict(site)
if isinstance(_robot.get("gauge_assembly"), dict):
site = dict(site)
site["gauge_assembly"] = deep_merge(
site.get("gauge_assembly") or {}, _robot["gauge_assembly"]
)
overrides: Dict[str, Any] = {}
if bool(self.get_parameter("dry_run").value):
overrides["dry_run"] = True
holes = int(self.get_parameter("holes_total").value)
if holes > 0:
overrides["holes_total"] = holes
# 工具长:仿真里由启动脚本传**实测值**(上料脚本刚量的),真机不传(走 site)。
# 为什么必须能覆盖:这个值决定悬停高度与 S07 下探预算,而仿真里规会在指间
# 下滑到落座位置、下滑量随装夹变化(实测见过 243 / 254mm,差 11mm)。
# 差 11mm 的后果见 gauge_fsm.hover_z_guard / drag_verdict 的说明。
tool_len = float(self.get_parameter("tool_tip_below_flange_m").value)
if tool_len > 0.0:
overrides["tool_tip_below_flange_m"] = tool_len
try:
self.cfg: GaugeConfig = from_site(site, overrides=overrides or None)
except GaugeConfigError as exc:
@@ -906,6 +1062,13 @@ class GaugeAssembly(AssemblyPipeline):
return Event.BOOT_OK
self._publish_gauge_result({"ok": False, "state": "S00", "failures": blockers})
return Event.BOOT_FAIL
# 记"夹住时的开口"(掉件判据的基准)。**放在这里而不是等到 S08**:
# · 此刻爪里确实夹着规(仿真上料脚本刚装好,真机是人工装好才开始跑);
# · 基准必须是**明确知道夹住**时记的 —— 本后端的 is_gripped 是全开时
# 都会闪 True 的推断值(实测 99.29mm),拿它当基准会造出假 E1
# (见 GripperClient.dropped)。
if self.grip is not None:
self.grip.adopt_current_grip("S00 自检通过:爪里已装好通止规")
return Event.BOOT_OK
# =====================================================================
@@ -1087,20 +1250,25 @@ class GaugeAssembly(AssemblyPipeline):
f"{tag}: 目标 {delta_deg:+.1f}°,实测绕工具 Z {got:+.1f}°"
f"(转轴偏差 {mis:.1f}°,阈值 {AXIS_VERIFY_TOL_DEG:.1f}°)"
)
if abs(got) < 3.0 and abs(delta_deg) > 10.0:
# 完全没动:最可能是撞上关节极限(用户要求的"转到极限角"就是这种情形)
verdict = rotation_verdict(delta_deg, got, mis, AXIS_VERIFY_TOL_DEG)
if verdict == ROT_LIMIT:
# 完全没动、或只走了一部分:都是**关节限位**把这段截住了
# (2026-09-24 实测:棘轮回转每轮顶到上限 ±175°,于是下一步反向
# 只剩 ~16° 余量)。交给调用方让余量重试(见 _ratchet_step);
# 调用方不处理时 FSM 会按未定义转移报错,不会静默走下去。
self.get_logger().warn(
f"{tag}: 指令 {delta_deg:+.1f}° 但几乎没动 —— 判为已贴关节极限"
f"{tag}: 期望 {delta_deg:+.1f}°、实测 {got:+.1f}°"
f"(差 {abs(delta_deg) - abs(got):.1f}°)—— 判为**贴到关节限位**"
)
return Event.JOINT_LIMIT
if mis > AXIS_VERIFY_TOL_DEG:
if verdict == ROT_AXIS:
self.get_logger().error(
f"{tag}: 转轴偏差 {mis:.1f}° 超过阈值 —— 关节7 轴与工具 Z 不共线,"
"以『度』为单位的动作(S01/S08/S09)都不可信,中止。"
"先确认工具系是否垂直向下、有没有装转接件"
)
return Event.ABORT
if abs(abs(got) - abs(delta_deg)) > max(AXIS_VERIFY_TOL_DEG, 0.1 * abs(delta_deg)):
if verdict == ROT_SHORT:
self.get_logger().error(
f"{tag}: 期望 {delta_deg:+.1f}°、实测 {got:+.1f}°,偏差过大,中止"
)
@@ -1108,6 +1276,146 @@ class GaugeAssembly(AssemblyPipeline):
self._axis_verified = True
return fsm_event
def _ratchet_step(
self, target_rad: float, *, tag: str, fsm_event: Event, speed_deg_s: Optional[float],
direction: int = 0,
) -> Event:
"""棘轮的一步"转腕":行程不够时**张开爪让出余量**再转。
:param target_rad: 关节 7 的**目标角(绝对值)**,与
:meth:`_rotate_joint_and_verify` 同一口径(那边算的就是
``target_rad - q_now``)。**不是增量。**
:param direction: 这一步的**工艺转向**:``+1`` 正转、``-1`` 反转、
``0`` 交给"最短路径"。棘轮的四步一律显式给:步长接近 180° 时
"最短路径"会被 0.1° 的读数差翻方向(详见
:func:`arm_application.gauge_fsm.ratchet_delta_rad`)。
⚠️ 为什么要把口径写在这里(2026-09-24 栽过一次,S09 直接 ABORT):
调用方(S08/S09)给的本来就是绝对值(``q[-1] ± screw_step_rad``),
而本函数此前把它当**增量**用(``prewind(want_deg=abs(deg(target)))``、
让余量时又按 ``q - target`` 算新目标)。于是 S09 第一步被算成
"要转 338.9°"(其实是目标 338.9°、增量只有 180°)⇒ 误判"行程不够"
⇒ 让余量那一步算到限位**外面**(``158.9 − 338.9 = −180°``,比
−175° 的限位还低 5°)⇒ 报"期望 −338.9°、实测 +26.0°,偏差过大"
⇒ 整条流程在**最不该失败的一步** ABORT(规在爪里好好的、什么坏事都没发生)。
两个函数各用一种口径,编译器不会说话,日志还会把"目标"和"增量"都印成
一个数 —— 所以口径写进参数名和 docstring。
为什么需要"让余量"这一步:
棘轮要求"一正一反各 180°",而关节 7 的行程只有 ±175°(3.0543 rad)。
拧入(S08)的**回转**那一步每轮都正好顶到上限(实测 +175.1°),
于是 S09 要做的**反方向**第一步只剩 ~16° 余量,被限位截住。
而这一轮其实什么坏事都没发生。
为什么"让余量"是免费的:
指间**张开**时规在打滑(这正是棘轮成立的原理),所以手腕空转既不会
把已经拧进去的螺纹转回去,也不会漏掉进度 —— 只是把行程重新分配。
让多少:整步(不去算亏欠了多少)。反正张开状态下这一步不产生任何装配
效果,整步最省事也最不容易算错;重试时的那一步才是"用力"的那一步。
两条路:**先算**(限位已知时,提前让余量,干净)+ **兜底**(真被限位
截住时再让,覆盖"限位没读到 / 轨迹自己没走完"这两种情况)。
两条路**最后都汇到同一次让余量**:只有一次,避免"先算发现不够、
让完还不够、再让一次"这种把行程越绕越少的做法。
"""
limits = self._wait_joint_limits()
q = None
if limits is not None:
q, _Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
# 目标角可能落在限位**外面**(这正是棘轮要解决的问题),先折成"最近一圈"
# 的增量(gauge_fsm.ratchet_delta_rad,含完整说明与单测):
# 关节在 +158.9°、目标 +338.9° ⇒ 增量 +180°(而不是 +338.9°)。
# **方向按工艺意图给**:步长贴着 180° 时"最短路径"会被读数差翻向(R2.10)。
delta_rad = ratchet_delta_rad(target_rad, float(q[-1]), direction)
why = ""
if limits is not None and abs(math.degrees(delta_rad)) > 1e-6:
plan = prewind(
q_now_rad=float(q[-1]), want_deg=abs(math.degrees(delta_rad)),
direction=1 if delta_rad > 0 else -1,
limits=limits, margin_deg=RATCHET_LIMIT_MARGIN_DEG,
)
if plan.clamped:
why = (
f"行程不够(关节7 在 {math.degrees(float(q[-1])):+.1f}°,"
f"这一步要转 {math.degrees(delta_rad):+.1f}°"
f"(目标 {math.degrees(float(target_rad)):+.1f}°),限位 "
f"{math.degrees(limits[0]):+.0f}~{math.degrees(limits[1]):+.0f}°)"
)
if not why:
ev = self._rotate_joint_and_verify(
target_rad, tag=tag, fsm_event=fsm_event, speed_deg_s=speed_deg_s
)
if ev is not Event.JOINT_LIMIT:
return ev
why = "轨迹被关节限位截住(算着够、实际没走完)"
return self._ratchet_rewind_then(
delta_rad, tag=tag, fsm_event=fsm_event, speed_deg_s=speed_deg_s, why=why,
)
def _ratchet_rewind_then(
self, delta_rad: float, *, tag: str, fsm_event: Event,
speed_deg_s: Optional[float], why: str,
) -> Event:
"""张开爪 → 反向让一步 → 夹紧 → 转原来那一步。
:param delta_rad: **要转的增量**(不是目标角,见 :meth:`_ratchet_step`)。
让余量时按它反着退一步,重试时再按它前进 —— 两次都以**当时的**
关节角为基准重新算目标,所以让完余量后关节会回到原处、工具净转
``delta_rad``。把"目标"固定成一开始那个绝对值是错的:让完余量之后
那个目标已经在另一圈上了(差 360°)。
"""
if self.grip is None:
self.get_logger().error(f"{tag}: {why},但没有夹爪客户端,无法让余量 → 中止")
return Event.ABORT
self.get_logger().info(
f"{tag}: {why} —— 张开爪让出一步(此刻规在打滑,不会动到螺纹)再转"
)
if not self.grip.open():
self.get_logger().error(f"{tag}: 需要张爪让余量,但开爪失败 → 中止")
return Event.ABORT
time.sleep(self.cfg.screw_release_wait_s)
q, _Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
float(q[-1]) - delta_rad, tag=f"{tag}_rewind",
fsm_event=fsm_event, speed_deg_s=speed_deg_s,
)
if ev is not fsm_event:
self.get_logger().error(
f"{tag}: 让余量那一步也没走开({ev})—— 关节7 的行程真用尽了,中止。\n"
f" 排查:site.yaml 的 gauge_assembly.screw_step_deg 太接近关节行程了"
f"(Rizon4 的关节7 是 ±175°),棘轮一步要 {math.degrees(abs(delta_rad)):.0f}°,"
f"留给'回转'的余量本来就不够。\n"
f" 改法:把 screw_step_deg 调小(例如 120°),并保证 "
f"screw_max_cycles × screw_step_deg 仍是 **360° 的整数倍** —— "
f"累计必须转整数圈,否则规会停在螺纹中间(S09 拧出也就少转几分之一圈)。"
)
return Event.ABORT
if not self.grip.close():
self.get_logger().error(f"{tag}: 让完余量后夹紧失败 → 中止")
return Event.ABORT
# 重试目标**按让完余量后的位置重算**:让余量退了一步,原来那个绝对目标
# 现在差了 360°(正好一整圈),照原样下发会让关节反向绕一圈回去。
q2, _Tee2 = self._wait_state(3.0)
if q2 is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
float(q2[-1]) + delta_rad, tag=f"{tag}_retry",
fsm_event=fsm_event, speed_deg_s=speed_deg_s,
)
if ev is not fsm_event:
self.get_logger().error(
f"{tag}: 让完余量重试仍然没走到位({ev})—— 中止"
f"(不是限位问题,先查是不是有东西卡住/螺纹咬死)"
)
return Event.ABORT
return fsm_event
# =====================================================================
# S02 夹持确认(预留)
# =====================================================================
@@ -1301,7 +1609,9 @@ class GaugeAssembly(AssemblyPipeline):
return Event.TIMEOUT
code, q, Tee, final = self._run_locate(q, Tee)
if code != 0 or not final:
self.get_logger().error(f"S05 精拍定位失败 code={code}")
self.get_logger().error(
f"S05 精拍定位失败 code={code}{_LOCATE_CODE_HINT.get(code, '')}"
)
return Event.TIMEOUT
self._snap_final = dict(final)
self._snap_pose = Tee.copy()
@@ -1440,6 +1750,21 @@ class GaugeAssembly(AssemblyPipeline):
return (0.0, 0.0)
return (float(getattr(best, "fshearx", 0.0)), float(getattr(best, "fsheary", 0.0)))
def _wrench_axial(self) -> Optional[float]:
"""当前腕部 FT 的轴向力 [N];一帧都没收到过时返回 None。
轴向 = ``force.z``。这个口径对真机与仿真**都成立**:
真机 ``/<sn>/ft_sensor_wrench`` 是法兰系,z 是法兰轴;
仿真传感器挂在 joint7 上取 child(link7) 系,z 也是工具轴。
工具锁垂直向下时,两者都等于"竖直方向的力"。
"""
return getattr(self, "_ft_fz", None)
def _wrench_age(self) -> Optional[float]:
"""腕部 FT 最近一帧的年龄 [s];一帧都没收到过时返回 None。"""
t = getattr(self, "_t_ft", None)
return None if t is None else (time.monotonic() - float(t))
def _step_contact(self) -> Event:
self._state("S07_contact")
if self.cfg.dry_run:
@@ -1452,8 +1777,16 @@ class GaugeAssembly(AssemblyPipeline):
# 基线必须在**还没碰工件**的时候测。S06 结束时悬停在工件上方,
# 正好是测基线的时刻(带载测会把阈值抬飞)。
ref = self._baseline(samples=8)
# 腕部 FT 的轴向基线:与触觉基线同一时刻采(这时还没碰工件)。
# 腕部 FT 在夹持摩擦的**上游**,所以它是 S07 的首选判据;触觉只是兜底
#(通止规靠夹持摩擦抱住,轴向力传不到指垫,见 gauge_fsm.wrench_touch)。
ft_ref = self._wrench_axial()
ft_age = self._wrench_age()
ft_alive = wrench_live(ft_age)
self.get_logger().info(
f"S07 基线:切向 ({ref[0]:.1f}, {ref[1]:.1f})、法向 {self._fn_rest:.1f}"
f";腕部轴向 {ft_ref if ft_ref is not None else float('nan'):+.2f}N"
f"({'活' if ft_alive else '**没有数据**'},话题 {self.ft_wrench_topic})"
)
z0 = float(Tee[2, 3])
@@ -1565,23 +1898,75 @@ class GaugeAssembly(AssemblyPipeline):
z_prev = z_now
if steps % 5 == 0:
now_dbg = self._shear_axes()
ax_dbg = self._wrench_axial()
self.get_logger().info(
f"S07 已下压 {abs(z_now - z0) * 1000:.2f}mm"
f"({steps}/{budget} 步)切向 ({now_dbg[0]:.1f}, {now_dbg[1]:.1f})"
f" 腕部轴向 "
f"{ax_dbg - ft_ref if ax_dbg is not None else float('nan'):+.1f}N"
)
# ---- 判据一(首选):腕部 FT 轴向突变 --------------------------
# 腕部 FT 在夹持摩擦的**上游**(工件→规→夹爪→法兰→腕部关节),
# 所以工具在爪里打不打滑都测得准。触觉做不到这点:
# 轴向力要传过"指垫↔规"的摩擦面,上限只有 µ·N≈23N,而台肩落面
# 一步就产生 25N —— 必然打滑,读数停在 0(而它是对的)。
# 完整因果链见 gauge_fsm.wrench_touch 与 docs/GAUGE_ASSEMBLY.md §6。
ft_now = self._wrench_axial()
if ft_alive and ft_now is not None and ft_ref is not None:
if wrench_touch(ft_now, ft_ref, self.cfg.contact_axial_delta_n):
self.get_logger().info(
f"S07 接触(腕部 FT):轴向 {ft_now:+.2f}N 相对基线 "
f"{ft_ref:+.2f}N 超过 +{self.cfg.contact_axial_delta_n:.1f}N"
f"(下压 {abs(float(Tee[2, 3]) - z0) * 1000:.2f}mm)"
)
# 必须先卸载再交给 S08 —— 判到接触的这一刻臂上顶着的是**出力上限**
# 那个量级的力(实测 200N+,与阈值无关),而规的轴向传力上限
# 只有 µ·N≈73N。见 _relieve_after_contact 的完整说明。
self._relieve_after_contact(q, Tee, "腕部 FT", ft_ref)
return Event.CONTACT_OK
elif steps == 1:
# 只报一次,别每步刷屏。降级到触觉兜底时阈值口径要按现场重新标定。
self.get_logger().warn(
f"S07 腕部 FT 没有数据(话题 {self.ft_wrench_topic},"
f"年龄 {ft_age if ft_age is not None else float('nan'):.1f}s)——"
"降级为只看触觉切向突变。\n"
" ⚠️ 触觉是**靠夹持摩擦**传力的,轴向力上限只有 µ·N(≈23N 量级),"
"台肩落面那一下可能传不过去、读数停在 0。\n"
" 真机上查:真机 bringup 无条件起 flexiv_robot_states_broadcaster,"
"话题是 /<sn>/ft_sensor_wrench;收不到就是话题名/网络的问题。\n"
" 仿真上查:devices/sim/gazebo/config/wrist_ft.yaml 的 topic 与"
" arm.sim.sensors.xacro 的 wrist_ft_topic 必须一致(都是 "
"/sim/ft_sensor_wrench)。"
)
try:
fn, _fn_max, _shear, _contact = self._tac_fresh(self.tac_timeout)
except Exception as exc: # noqa: BLE001
self.get_logger().error(f"S07 触觉断流,无法判断接触:{exc}")
self._retract(q, Tee, dz_m=0.01)
return Event.TIMEOUT
# 腕部 FT 活着时,触觉断流不影响判接触(两个来源互相独立)。
# 只有两个来源都没了才真的判不了。
if not ft_alive:
self.get_logger().error(
f"S07 腕部 FT 与触觉**都没有**数据,无法判断接触:{exc}\n"
" 这不是阈值问题,是数据源问题:先让其中一个活过来。"
)
self._retract(q, Tee, dz_m=0.01)
return Event.TIMEOUT
self.get_logger().warn(
f"S07 触觉断流({exc}),但腕部 FT 活着,继续下压"
)
fn, _fn_max, _shear, _contact = 0.0, 0.0, (0.0, 0.0), False
# ---- 判据二(兜底):触觉切向突变 ------------------------------
now = self._shear_axes()
if tangential_jump(now, ref, self.cfg.contact_tangential_delta):
self.get_logger().info(
f"S07 接触:切向 {now[0]:.1f}/{now[1]:.1f} 相对基线 "
f"S07 接触(触觉切向):切向 {now[0]:.1f}/{now[1]:.1f} 相对基线 "
f"{ref[0]:.1f}/{ref[1]:.1f} 超过 +{self.cfg.contact_tangential_delta:.0f}"
f"(法向 {fn:.1f},下压 {abs(float(Tee[2, 3]) - z0) * 1000:.2f}mm)"
)
# 与腕部 FT 那条同规矩:判到接触时臂上顶着的力远大于阈值,必须先卸载。
self._relieve_after_contact(q, Tee, "触觉切向", ft_ref)
return Event.CONTACT_OK
if fn - self._fn_rest > self.cfg.contact_safe_delta:
self.get_logger().error(
@@ -1590,14 +1975,132 @@ class GaugeAssembly(AssemblyPipeline):
)
self._retract(q, Tee, dz_m=0.01)
return Event.FORCE_OVER_LIMIT
# 腕部 FT 的独立安全上限:它测的是**真实轴向力**,所以这里能给出
# 一个物理量级的兜底(触觉那个 contact_safe_delta 是 au,量纲都对不上)。
# 取触觉安全上限的同样口径:一旦超过就说明"压得太狠了",与"接触了没"
# 无关 —— 接触判据应该早就返回了,能走到这里说明判据失灵。
if ft_alive and ft_now is not None and ft_ref is not None:
if abs(ft_now - ft_ref) > self.cfg.contact_safe_delta:
self.get_logger().error(
f"S07 腕部轴向力超安全上限:{ft_now:+.2f}N - 基线 {ft_ref:+.2f}N "
f"= {abs(ft_now - ft_ref):.1f}N > +{self.cfg.contact_safe_delta:.0f}N,"
"抬起并中止。\n"
" 能走到这里说明接触判据没生效(那么大的力早该判到接触)——"
"先查 contact_axial_delta_n 是不是被调得太大了。"
)
self._retract(q, Tee, dz_m=0.01)
return Event.FORCE_OVER_LIMIT
self.get_logger().error(
f"S07 下探 {max_depth_m * 1000:.1f}mm 都没触发接触判据。\n"
" 常见原因:① 切向阈值太大(先看上面打印的基线/实测值再调);"
"② 孔位偏了,规擦着孔壁下去但没接触面;③ 触觉通道不对"
" 排查顺序(按物理可信度,别从阈值开始调):\n"
" ① **先看上面每 5 步打印的「腕部轴向」**:它一直≈0 → 是数据源/几何问题,\n"
" 不是阈值问题(传感器没数据、悬停几何错、孔位偏了没碰到面);\n"
" ② 腕部轴向有涨但没到阈值 → 才轮到调 contact_axial_delta_n;\n"
" ③ 只有触觉兜底时:轴向力要经夹持摩擦传递,上限 µ·N≈23N,\n"
" 台肩落面那一下可能传不过来 —— 这时调阈值是白费,要修的是夹持。"
)
self._retract(q, Tee, dz_m=0.01)
return Event.TIMEOUT
def _relieve_after_contact(
self,
q: np.ndarray,
Tee: np.ndarray,
why: str,
axial_ref: Optional[float] = None,
) -> None:
"""判到接触之后的**卸载回退**:把下压量放掉,再交给 S08。
为什么必须有这一步(2026-09-24 用一整轮排查换来的,值得完整读):
位置控制的臂撞上硬面时,**不会**在"刚碰到"的地方停住 —— 它会一直顶到
自己的出力上限。仿真实测:指令 0.5mm 的一步压在工件上,腕部 FT 直接跳到
**+244N**,而臂这一步只走了 0.09mm(日志里那句"S07 单步未完全到位")。
也就是说"判到接触"的那一刻,臂上是顶着一个**大得多的力**的,
这个力跟 contact_axial_delta_n 没关系(阈值只决定"什么时候承认接触")。
而通止规是**靠夹持摩擦**抱住的:轴向上能传的力上限 = µ·N,两指合计
约 73N(µ=0.8、握力 46N)。244N 远超它 ⇒ 规被轴向力直接顶出爪外。
本次就是这样:S07 判到接触 → 进 S08 一转 → 规掉到地上 → 报 E1,
而 E1 的日志只说"通止规掉了",看不到"是顶着 244N 进去的"。
所以这里回退 contact_relief_mm,把力放回基线附近;S08 只带着"规自身的
重量 + 一点点残余"进孔 —— 那才是螺纹/棘轮真正需要的预压。
回退量刻意**不做闭环**(不"退到力小了为止"):判据闭环会在"接触刚度很大"
时来回震荡,而这里要的只是"把下压量放掉"。放不干净就报出来(见下),
让现场去改夹持(那是机械问题,不是参数问题)。
"""
dz = float(self.cfg.contact_relief_mm) / 1000.0
ft_before = self._wrench_axial()
if dz <= 0.0:
self.get_logger().warn(
f"S07 判到接触({why})但 contact_relief_mm=0 —— **不卸载**。\n"
" 位置控制的臂此刻顶着的力可能远大于判据阈值(实测 200N+),"
"而规靠夹持摩擦抱住、轴向上限只有 µ·N(≈73N)。"
"S08 一转就可能掉件(E1),且日志里看不到真因。"
)
return
target = np.array([float(Tee[0, 3]), float(Tee[1, 3]), float(Tee[2, 3]) + dz])
# ⚠️ 到位容差**必须远小于回退量**,不能写死一个与它同量级的常数。
# 实测(2026-09-24):回退 0.50mm 而 tol_m 也写 0.0005 ⇒ 目标 z 是
# `Tee[2,3] + 0.0005`,浮点回减后 dist 会比 0.0005 略小 ⇒ `_goto` 的
# 入口判据 `dist < tol_m` 当场成立、**一步没走就 return True**。
# 于是"卸载"变成一次空操作,而下面的残余力判据比的是"回退前 vs 回退后"
# 两个一模一样的读数 ⇒ 报"残余 0.0N,已放干净"(**假通过**)。
# 后果:S08 带着 70N 的轴向力去转 → 掉件(E1),日志里只有"规掉了"。
# 规则本身抽成 gauge_fsm.relief_tol_m 并有单测钉住(tol 必须 < 回退量)。
tol = relief_tol_m(dz)
ok = self._goto(
q, Tee, target, verticalize=True, tol_m=tol,
tol_tilt=self.vert_tol_rad * 2.5, tag="S07_relief",
v=min(0.02, self.cfg.contact_speed_m_s), max_hops=3,
settle_s=max(0.1, self.settle3 * 0.5), min_dur=0.3,
)[0]
time.sleep(0.2)
ft_after = self._wrench_axial()
msg = (
f"S07 接触后卸载:回退 {dz * 1000:.2f}mm,腕部轴向 "
f"{ft_before if ft_before is not None else float('nan'):+.1f}N → "
f"{ft_after if ft_after is not None else float('nan'):+.1f}N"
)
if not ok:
self.get_logger().warn(
msg + "\n ⚠ 卸载回退**没走到位**(上面有 _goto 的告警)。"
"残余力判据因此不可信 —— 这一路压进去的力仍留在规上。"
)
if ft_after is not None and ft_before is not None:
# 残余按**基线**(还没碰工件时的轴向力)算,不能按"回退前后之差"算:
# 回退本身要是没生效,前后读数相同 ⇒ 差值 0 ⇒ 假报"放干净"。
# 基线是"完全没有接触"的状态,这才是"卸载干不干净"的参照。
residual = (
abs(float(ft_after) - float(axial_ref))
if axial_ref is not None
else abs(float(ft_after) - float(ft_before))
)
ref_txt = (
f"基线 {float(axial_ref):+.1f}N" if axial_ref is not None else "无基线"
)
if residual > float(self.cfg.contact_hold_n):
self.get_logger().warn(
msg + f"(残余 {residual:.1f}N(相对{ref_txt})"
f" > {self.cfg.contact_hold_n:.1f}N)\n"
" ⚠ 卸载没放干净:这一路压进去的力**超过了夹持摩擦能承受的量**"
"(上限 µ·N,两指合计 ≈73N)。\n"
" 接着 S08 一转,规会从爪里被推出去(现象是「通止规掉了」E1,"
"看不到真因)。\n"
" 两条真修法:① 降峰值 —— 调小 contact_step_mm(步长越小峰值越小)"
"或加大 contact_relief_mm;② 加握力 —— 改夹爪合拢力度/指垫摩擦。\n"
" **不要**去调 contact_axial_delta_n:它只管「什么时候承认接触」,"
"管不了「判到的时候已经顶了多大」。"
)
else:
self.get_logger().info(
msg + f"(残余 {residual:.1f}N(相对{ref_txt}),已放干净)"
)
else:
self.get_logger().info(msg + "(腕部 FT 没数据,无法确认卸载效果)")
def _baseline(self, samples: int) -> Tuple[float, float]:
"""静止基线的两路切向均值(顺手把法向基线记到 ``self._fn_rest``)。
@@ -1633,6 +2136,29 @@ class GaugeAssembly(AssemblyPipeline):
# =====================================================================
# S08 旋转装配循环(棘轮)
# =====================================================================
def _log_regrip(self, tag: str) -> None:
"""把"这一轮夹紧后的开口"打出来(含相对夹住时的塌陷量)。
为什么每轮都要打(2026-09-24 实测):
S08/S09 每轮都是"慢转 → 松爪 → 回转 → 夹紧",而 `close` 在仿真里
是"位置控制走到全合 0mm" —— **真机走到力上限就停,仿真会一路挤进规里**。
只报一句"通止规掉了(E1)"时,人分不出"真掉了"和"被挤小了"。
把开口与塌陷量打出来一眼就能分辨:
· 塌陷量逐轮变大、开口还在十几毫米 → 是**被挤**(规还在指间)
· 开口塌到接近 0(两指合到底) → 是**真掉了**
"""
if self.grip is None:
return
ap = self.grip.settle(settle_s=1.0) # 服务返回时动作还没做完,等它停
if ap is None:
return
gp = self.grip.grip_aperture()
col = self.grip.collapse_mm()
detail = ""
if gp is not None and col is not None:
detail = f"(夹住时 {gp:.2f}mm,塌陷 {col:+.2f}mm)"
self.get_logger().info(f"{tag}:开口 {ap:.2f}mm{detail}")
def _step_screw_in(self) -> Event:
hole = self._hole_name()
self._state(f"S08_screw_in_{hole}")
@@ -1649,6 +2175,20 @@ class GaugeAssembly(AssemblyPipeline):
f"停止判据「切向 >+{self.cfg.screw_shear_delta:.0f} "
f"且法向 >+{self.cfg.screw_normal_delta:.0f}」"
)
# 步长 vs 关节行程:**在这里算一次**,不要等第 3 轮贴限位再炸。
# 2026-09-24 实测:180° 的步长配 ±175° 的关节 7,第 3 轮 ABORT,
# 日志是一长串"贴限位 → 让余量 → 还是贴限位",真因(配置)藏在最后。
travel = ratchet_travel_ok(
self.cfg.screw_step_deg, self._wait_joint_limits(), RATCHET_LIMIT_MARGIN_DEG
)
self.get_logger().info(
f"S08 行程核算:一步棘轮要 {travel.need_deg:.0f}°,"
f"关节7 行程 {travel.travel_deg:.0f}°"
f"(最大可用步长 {travel.max_step_deg:.0f}°)—— {'放得下' if travel.ok else '放不下'}"
)
if not travel.ok:
self.get_logger().error(f"S08 配置自检不通过(先改配置,别硬跑):{travel.why}")
return Event.ABORT
for cycle in range(self.cfg.screw_max_cycles):
q, Tee = self._wait_state(5.0)
if q is None or Tee is None:
@@ -1663,9 +2203,10 @@ class GaugeAssembly(AssemblyPipeline):
# --- 四步原子动作,顺序固定(慢转 → 松爪 → 快回转 → 夹紧)---
# 慢转是**顺时针**:与 S01 的逆时针预绕相反,这样预绕行程就是
# 这一整段拧入的回转空间(S01 不预绕,转两圈就贴限位)。
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) - self.cfg.screw_step_rad, tag=f"S08_slow_{cycle}",
fsm_event=Event.SCREW_DONE, speed_deg_s=self.cfg.screw_speed_deg_s,
direction=-1, # 顺时针拧入(与 S01 的预绕相反)
)
if ev is not Event.SCREW_DONE:
return ev
@@ -1678,15 +2219,17 @@ class GaugeAssembly(AssemblyPipeline):
q, Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) + self.cfg.screw_step_rad, tag=f"S08_back_{cycle}",
fsm_event=Event.SCREW_DONE, speed_deg_s=self.cfg.screw_back_speed_deg_s,
direction=+1, # 回转:与慢转正好相反
)
if ev is not Event.SCREW_DONE:
return ev
if not self.grip.close():
self.get_logger().error("S08 夹紧失败 —— 下一轮慢转会打滑磨螺纹")
return Event.ABORT
self._log_regrip(f"S08 第 {cycle + 1} 轮夹紧后")
else:
self.get_logger().error("S08 没有夹爪客户端,无法松爪/夹紧,中止")
return Event.ABORT
@@ -1715,9 +2258,17 @@ class GaugeAssembly(AssemblyPipeline):
self._progress(f"{hole} 拧入到位({cycle + 1} 轮)")
return Event.SCREW_STOP
if self.grip is not None and self.grip.dropped():
ap = self.grip.aperture()
gp = self.grip.grip_aperture()
self.get_logger().error(
"S08 通止规掉了(E1)—— 回 S01 流程:张开爪、提示人工放好,"
"确认后继续(按看板:该孔记为完成,换下一个孔)"
"确认后继续(按看板:该孔记为完成,换下一个孔)\n"
f" 开口:夹住时 {gp if gp is None else f'{gp:.2f}mm'}"
f" → 现在 {ap if ap is None else f'{ap:.2f}mm'}"
f"(判据:塌陷 ≥ {GripperClient.DROP_COLLAPSE_MM:.1f}mm)\n"
" 分辨真掉还是被挤:开口塌到接近 0 = 两指合到底(真掉);\n"
" 开口还有十几毫米、只是比夹住时小了几毫米 = 被**挤**"
"(仿真位置控制的「全合」不知道「挡住就停」,见 GripperClient.dropped)"
)
self._e1_pending = True
return Event.GAUGE_DROPPED
@@ -1748,9 +2299,10 @@ class GaugeAssembly(AssemblyPipeline):
if q is None:
return Event.TIMEOUT
# 反向:慢转是逆时针(与 S08 相反),空手回转是顺时针
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) + self.cfg.screw_step_rad, tag=f"S09_slow_{cycle}",
fsm_event=Event.UNSCREW_DONE, speed_deg_s=self.cfg.screw_speed_deg_s,
direction=+1, # 逆时针拧出
)
if ev is not Event.UNSCREW_DONE:
return ev
@@ -1762,17 +2314,64 @@ class GaugeAssembly(AssemblyPipeline):
q, Tee = self._wait_state(3.0)
if q is None:
return Event.TIMEOUT
ev = self._rotate_joint_and_verify(
ev = self._ratchet_step(
float(q[-1]) - self.cfg.screw_step_rad, tag=f"S09_back_{cycle}",
fsm_event=Event.UNSCREW_DONE, speed_deg_s=self.cfg.screw_back_speed_deg_s,
direction=-1, # 回转:与慢转相反
)
if ev is not Event.UNSCREW_DONE:
return ev
if not self.grip.close():
return Event.ABORT
self._log_regrip(f"S09 第 {cycle + 1} 轮夹紧后")
self._lift_clear_of_hole(hole)
self._progress(f"{hole} 已拧出")
return Event.UNSCREW_DONE
def _lift_clear_of_hole(self, hole: str) -> None:
"""拧出后**先竖直把规提出孔**,之后才允许任何横向动作。
**为什么必须有这一步**(2026-09-24 仿真实测,两次栽在同一个地方):
1. 仿真里**没有螺纹几何**:S09 的"拧出"只是让规在孔里转,规**仍然插在
孔里**(真机会沿螺纹退出来几个毫米,也没退出多少)。
2. 于是紧跟着的横向动作(S10 换孔 / S11 回 home)会把规从孔里**拽**
出来 —— 实测抓取锁被拽断、规掉在台面上,而下游只表现为
"下一孔 S07 下探 35mm 判不到接触"(工具上已经没规了),
日志完全指不到真因。
3. 真机也怕这个动作:螺纹刚脱开时横向别住最容易啃伤螺纹。标准做法就是
**先竖直退出来,再走 XY**。
高度提到**跟踪高**(``_track_z()`` = 孔悬停高 + 50mm)而不是写死毫米数:
跟踪高是这条流程里"工具已离开工件"的既有定义(``_ensure_clearance``
也用它),拿它当判据不用再维护第二套口径。实测:不提出时规尖比工件面
**低 30mm**,而 S10 的斜线只往上抬了 29mm —— 差 1mm 就是"拽出孔"和
"干净退出"的分界,所以必须是**先后有序**的两个动作,不能靠斜线一起走。
失败只告警(不失败):这一刻孔里已经没有规要保护了,硬失败会把一次成功
的装配变成失败;后面的 S10/S11 仍会走到安全位。
"""
try:
q, Tee = self._wait_state(3.0)
if q is None or Tee is None:
return
tz = self._track_z()
z = float(Tee[2, 3])
if z >= tz - 0.008:
return
self.get_logger().info(
f"S09 {hole} 拧出后竖直提出:Z {z:.3f} → {tz:.3f}m"
"(先离开孔口,再做横向动作)"
)
_, q2, _Tee2 = self._goto(
q, Tee,
np.array([float(Tee[0, 3]), float(Tee[1, 3]), tz]),
verticalize=True, tol_m=0.003, tol_tilt=self.vert_tol_rad * 2.0,
tag=f"S09_exit_{hole}", v=self.move_speed, max_hops=8,
hold_xy=True, settle_s=0.25,
)
except Exception as exc: # noqa: BLE001
self.get_logger().warn(f"S09 {hole} 提出孔口失败(继续走 S10/S11):{exc}")
# =====================================================================
# S10 换孔 / S11 复位
# =====================================================================
@@ -53,6 +53,9 @@ _UNIT_SUFFIX: Dict[str, str] = {
"px": "_px",
"pct": "_pct",
"au": "_delta",
# 腕部 FT 的**真牛顿**。与 au 分开一个后缀是刻意的:两个口径混用不会报错,
# 只会"判据不触发"或"一碰就炸",看起来像算法问题(见 site.yaml 的注释)。
"N": "_n",
}
#: 显式无量纲的键(布尔、计数、比例、枚举)。**必须在这里列出来** ——
@@ -99,6 +102,9 @@ _TYPES: Dict[str, type] = {
"tool_tip_below_flange_m": float,
"workpiece_surface_z_m": float,
"contact_tangential_delta": float,
"contact_axial_delta_n": float,
"contact_relief_mm": float,
"contact_hold_n": float,
"contact_safe_delta": float,
"screw_step_deg": float,
"screw_speed_deg_s": float,
@@ -142,6 +148,12 @@ _UNITS: Dict[str, str] = {
"tool_tip_below_flange_m": "m",
"workpiece_surface_z_m": "m",
"contact_tangential_delta": "au",
# 腕部 FT 是**真牛顿**(与触觉的 au 不是一个口径,所以键名也不带 taut 前缀)
"contact_axial_delta_n": "N",
# 卸载回退是**长度**(与 step_mm 同一把尺)
"contact_relief_mm": "mm",
# 残余轴向力是**牛顿**(腕部 FT 口径,不是触觉的 au)
"contact_hold_n": "N",
"contact_safe_delta": "au",
"screw_step_deg": "deg",
"screw_speed_deg_s": "deg/s",
@@ -209,9 +221,42 @@ DEFAULTS: Dict[str, Any] = {
"tool_tip_below_flange_m": 0.243,
"workpiece_surface_z_m": 0.0, # 0 = 未知,运行期用 stage2 实测孔位兜底
"contact_tangential_delta": 300.0,
# 腕部 FT 的轴向力阈值 [N]。**这是 S07 的首选判据**,触觉只是兜底。
#
# 为什么默认是 8N 这么小:这是"接触了没有"的判据,不是"压到位了没有"。
# 台肩一落面,接触刚度 5e4 N/m 意味着过压 0.1mm 就有 5N、0.2mm 有 10N ——
# 所以 8N 对应约 0.16mm 的过压,**在打滑之前**就能判到(打滑上限由
# µ·N 决定:µ=0.25、N≈46N → 两指合计 ~23N ≈ 0.46mm)。留了 3 倍余量。
# 现场标定法:空载慢慢压到台肩落面,看日志里"腕部轴向"从基线涨到多少,
# 取那个值的 1/3 作为阈值(要能区分"擦到孔壁"和"台肩落面")。
"contact_axial_delta_n": 8.0,
# 接触到之后的**卸载回退** [mm]。
#
# 为什么必须有这一步(2026-09-24 一整轮排查换来的):
# 位置控制的臂在"撞上硬面"时会把力顶到自己的**出力上限**才停住 ——
# 仿真实测:一步 0.5mm 的指令压在工件上,腕部 FT 读数直接跳到 **+244N**,
# 而臂这一步根本没走完("实测只走了 +0.09mm")。这不是仿真特有的:
# 接触刚度越高、步长越大,峰值力就越大,而位置环**不限制**它。
#
# 规是**靠夹持摩擦**抱住的,轴向能传的力上限是 µ·N(两指合计 ~73N@µ=0.8)。
# 244N 远超这个上限 ⇒ 规被轴向力**顶出爪外**(本次就是:S08 一转,
# 规直接掉到地上,报 E1)。所以"判到接触"之后不能就那样顶着,
# 必须先把下压量放掉,再交给 S08 拧入。
#
# 0.5mm 的来历:判到接触时的**真实**过压量通常只有 0.0x mm(刚度 5e4 N/m
# 时,10N 对应 0.2mm),回退 0.5mm 足够把力放干净,而规尖仍在孔里
# (通端穿板 18mm),不会脱出。
"contact_relief_mm": 0.5,
# 卸载后允许的残余轴向力 [N]。
# 超了不失败(可能只是回退量不够),但要**大声报**:它说明"夹持摩擦力不够
# 承受这次接触",接着 S08 一转就会掉件。调法只有两条 —— 降峰值(调小
# contact_step_mm / contact_relief_mm)或加握力(改夹爪合拢力度/指垫)。
"contact_hold_n": 5.0,
"contact_safe_delta": 2000.0,
# S08
"screw_step_deg": 180.0,
# 步长必须满足 2×step + 2×余量 ≤ 关节7 行程(Rizon4/10 ±175° ⇒ step ≤ 167°):
# 180° 会必然贴限位(棘轮一个来回要 360° > 350°),实测第 3 轮 ABORT。
"screw_step_deg": 120.0,
"screw_speed_deg_s": 30.0,
"screw_back_speed_deg_s": 120.0,
"screw_max_cycles": 12,
@@ -259,6 +304,9 @@ class GaugeConfig:
tool_tip_below_flange_m: float
workpiece_surface_z_m: float
contact_tangential_delta: float
contact_axial_delta_n: float
contact_relief_mm: float
contact_hold_n: float
contact_safe_delta: float
screw_step_deg: float
screw_speed_deg_s: float
@@ -310,8 +358,14 @@ class GaugeConfig:
# 悬停高度**不在这里**:它取自 servo.hole_hover_z_m(stage2 那一层),
# 这里只印本段的容差 —— 一段配置里出现"另一个段的键"会让人改错地方。
f" S06 对位容差 {self.align_tol_mm:.1f}mm(悬停高取自 servo.hole_hover_z_m)",
f" S07 切向突变阈值 +{self.contact_tangential_delta:.0f}(两通道任一,现场标定),"
f" S07 腕部轴向突变阈值 +{self.contact_axial_delta_n:.0f}N(**首选判据**,"
"腕部 FT 在夹持摩擦的上游,工具打滑也测得准),"
f"切向突变阈值 +{self.contact_tangential_delta:.0f}au(触觉兜底,两通道任一,现场标定),"
f"安全上限 +{self.contact_safe_delta:.0f},最多下探 {self.contact_max_depth_mm:.0f}mm",
f" S07 接触后卸载:回退 {self.contact_relief_mm:.2f}mm,"
f"残余轴向力要求 ≤{self.contact_hold_n:.1f}N"
"(位置控制的臂撞上硬面会把力顶到出力上限再停住,实测能到 200N+;"
"规靠夹持摩擦抱住,轴向上限只有 µ·N≈73N,顶着不放 S08 一转就掉件)",
f" S07 工具几何:尖端在法兰下方 {self.tool_tip_below_flange_m * 1000:.1f}mm,"
f"工件上表面 z={self.workpiece_surface_z_m:.3f}m"
"(启动时会按这两个值 + servo.hole_hover_z_m 算「悬停时工具到底入没入工件」,"
@@ -457,6 +511,20 @@ def _sanity(cfg: GaugeConfig) -> None:
)
if cfg.snap_tol_px <= 0.0:
raise GaugeConfigError(f"gauge_assembly.snap_tol_px 必须为正,收到 {cfg.snap_tol_px}")
if cfg.contact_relief_mm < 0.0:
raise GaugeConfigError(
f"gauge_assembly.contact_relief_mm 不能为负(0 = 接触到之后不卸载),"
f"收到 {cfg.contact_relief_mm}。\n"
" 它为什么值得一条检查:位置控制的臂撞上硬面会把轴向下压顶到**出力上限**"
"才停住(实测 200N+),而规靠夹持摩擦抱住、轴向上限只有 µ·N(≈73N)。"
"卸载这一步省掉,S08 一转就掉件(报 E1)—— 而那时的日志只会说"
"「通止规掉了」,看不到真因。"
)
if cfg.contact_hold_n <= 0.0:
raise GaugeConfigError(
f"gauge_assembly.contact_hold_n 必须为正(卸载后的残余轴向力上限 [N]),"
f"收到 {cfg.contact_hold_n}。0 会让「卸载干不干净」永远判不过。"
)
if cfg.snap_tol_mm <= 0.0:
raise GaugeConfigError(f"gauge_assembly.snap_tol_mm 必须为正,收到 {cfg.snap_tol_mm}")
if cfg.snap_stable_feature not in SNAP_STABLE_FEATURES:
@@ -358,6 +358,54 @@ def tangential_jump(
return any(abs(n - r) >= delta for n, r in zip(shear_now, shear_ref))
def wrench_touch(
axial_now: float, axial_ref: float, delta: float
) -> bool:
"""S07(**首选判据**):腕部 FT 的轴向力相对基线突变 → 判为接触到工件。
为什么这是首选、而不是手指触觉(2026-09-24 用一整轮排查换来的结论):
通止规是**靠夹持摩擦抱住**的工具。轴向力要传到手指,必须过"指垫↔规"的
摩擦面,而摩擦能传的上限是 µ·N:
夹持段 µ=0.25、实测握力 N≈46N → 单指 ~11.5N、两指 ~23N
可台肩落面那一下,接触刚度 5e4 N/m 意味着**过压 0.5mm 就是 25N** ——
一步的步进量就足以让规在爪里打滑。**于是手指触觉物理上读不到那个力**
(读数停在 0,而它是对的),阈值怎么调都不对:调小被噪声骗,调大永远
达不到。当时确实是照"阈值调高了"去改的,白费一轮。
腕部 FT 在这条力路的**上游**:工件→规→夹爪→法兰→腕部关节。
工具打不打滑都要经过它,所以它测得到真实的轴向力。
判据用**绝对增量**而不是带符号压入量,理由与 ``tangential_jump`` 相同:
传感器坐标系的正负号方向取决于安装,赌错了就是"整段流程静默失效"
(下发正常、日志正常、就是停不住)。取绝对值只会更早停一点点,
这个方向的错是安全的 —— 与 ``stage2_locate_node._contact_detected``
的既有口径也一致(那里也是 ``abs(fz - baseline)``)。
:param axial_now: 当前轴向力读数 [N]。
:param axial_ref: 静止(还没碰工件)时的轴向基线 [N]。
:param delta: 触发阈值(相对基线的增量)[N]。
"""
return abs(float(axial_now) - float(axial_ref)) >= float(delta)
#: S07 腕部 FT 判据需要多"新"的数据才算活着 [s]。
#: 真机 broadcaster 与仿真传感器都是 100Hz,0.5s 相当于容许掉 50 帧 ——
#: 只用来排除"根本没在发",不是用来做实时性判据的。
WRENCH_MAX_AGE_S: float = 0.5
def wrench_live(age_s: Optional[float]) -> bool:
"""腕部 FT 是否有活的数据(够新)。
``age_s`` 为 None 表示**一帧都没收到过**(话题名写错、传感器没起来)。
注意"收到过但数值为 0"**是活的** —— 静止时轴向力本来就接近 0。
这个区别很关键:拿数值大小判活,会让"安静的传感器"被误判成掉线,
而那正是 2026-09-24 在触觉上踩过的同一个坑(见 stage3 的 ``live_tactile``)。
"""
return age_s is not None and float(age_s) <= WRENCH_MAX_AGE_S
#: S07 单步下压给 ``_goto`` 的到位容差 = 步长的这个比例。
#: **必须 < 1,而且只能小不能大**:``_goto`` 的语义是"已经落在容差里就直接返回、
#: 一点都不动"。容差 ≥ 步长时,每一次"往下走 0.5mm"的请求都被判成"已在容差内"
@@ -368,10 +416,34 @@ DESCEND_TOL_FRAC: float = 0.4
DESCEND_TOL_FLOOR_M: float = 2.0e-5
#: 判定"这一步真的往下走了"的位移下限,占步长的比例。
DESCEND_PROGRESS_FRAC: float = 0.2
#: 卸载回退的到位容差占**回退量**的比例(见 :func:`relief_tol_m`)。
RELIEF_TOL_FRAC: float = 0.2
#: 允许连续多少步毫无位移后中止(防"没接触也一直转"的静默自旋)。
DESCEND_STALL_STEPS: int = 5
def relief_tol_m(dz_m: float) -> float:
"""S07 判到接触后**卸载回退**该给 ``_goto`` 的到位容差 [m]。
为什么单独立一条(2026-09-24 实测踩到,代价是一轮假通过):
回退量 0.50mm,而 ``_goto`` 的容差被写死成 0.0005 m(**同量级**)。
目标 z 是 ``Tee[2,3] + 0.0005``,浮点回减后 ``dist`` 会比 0.0005 略小
⇒ ``_goto`` 的入口判据 ``dist < tol_m`` 当场成立 ⇒ **一步没走就返回 True**。
于是"卸载"变成空操作,而残余力判据比的是"回退前 vs 回退后"两个**相同**的
读数 ⇒ 报"残余 0.0N,已放干净"(假通过)。
接着 S08 带着 ~70N 的轴向力去转(夹持摩擦上限 µ·N 两指合计 ≈73N)
→ 掉件(E1),而 E1 的日志只说"规掉了"。
返回 ``回退量的 20%``,再夹到 ``[DESCEND_TOL_FLOOR_M, 回退量的一半]`` ——
后者保证**容差永远严格小于回退量**,`_goto` 不可能在入口就判"已到位"。
"""
dz = float(dz_m)
if dz <= 0.0:
raise ValueError(f"卸载回退量必须为正,收到 {dz_m}")
return min(max(DESCEND_TOL_FLOOR_M, dz * RELIEF_TOL_FRAC), dz * 0.5)
def descend_tol_m(step_m: float) -> float:
"""S07 单步下压该给 ``_goto`` 的到位容差 [m]。
@@ -441,6 +513,181 @@ def tool_clearance_m(
return tip_z - float(surface_z_m)
def hover_z_for_tool_clearance(
surface_z_m: float,
tool_tip_below_flange_m: float,
clearance_m: float,
) -> float:
"""让工具尖端停在工件面上方 ``clearance_m`` 所需的**法兰**高度 [m]。
就是 ``tool_clearance_m`` 的逆运算 —— 写成一个函数是为了让"该用多高"这个
结论只有一个来源(下面 ``hover_z_guard`` 与调用方都用它,不各推一遍)。
"""
return float(surface_z_m) + float(tool_tip_below_flange_m) + float(clearance_m)
def hover_z_guard(
hover_z_m: float,
surface_z_m: float,
tool_tip_below_flange_m: float,
want_clearance_m: float,
) -> Tuple[float, str]:
"""二阶段悬停高度的自检 → ``(该用的悬停高度, 说明; 不用改时说明是空串)``。
**为什么必须有这条**(2026-09-24 实测栽在这上面):
悬停高度是**按相机**定的(腕部相机要离工件面 ~0.17m 才看得清孔),
而工具(夹爪 + 通止规)可能比相机长得多 —— 仿真里实测
``tool_tip_below_flange_m=0.245``,而悬停算的是
``0.412 + 0.17 + 0.05 = 0.632`` ⇒ 工具尖端在工件上表面**下面 25mm**。
后果不在 S07(S07 有自己的几何自检),而在**更早的 S05**:
"悬停"这一步本身就把工具捅进工件里了,于是 S05 的横向对准等于
拿规尖去拨工件 —— 实测把工件从 x=0.520 拖到 0.4284(**92mm**)。
而真值孔位是按工件的**实测**位姿算的(``sim_live_pose``,这是对的),
工件一走,孔位就跟着工具走 ⇒ 残差恒定在 13mm、迭代 9 次永不收敛
(日志:``[fine 3..8] 残差=13.25/13.19/…/11.79mm``)。
现象看起来像"手眼标定坏了 / 检测不认孔"。
真机同参数同结果:这不是仿真专有,只要"悬停按相机定、工具比相机长"就会这样。
判据取"明显低于目标间隙"而不是"一毫米都不许低":配置的悬停与实测的
工件面 z 都有几毫米的不确定度,拿 0 当界会把正常配置判成错的。
"""
clear_m = tool_clearance_m(hover_z_m, tool_tip_below_flange_m, surface_z_m)
if clear_m >= float(want_clearance_m) - 0.001:
return float(hover_z_m), ""
need_z = hover_z_for_tool_clearance(surface_z_m, tool_tip_below_flange_m, want_clearance_m)
why = (
f"悬停高度会让工具尖端钻进工件:法兰 {float(hover_z_m):.4f} − 工具长 "
f"{float(tool_tip_below_flange_m) * 1000:.1f}mm = 尖端 "
f"{float(hover_z_m) - float(tool_tip_below_flange_m):.4f},工件上表面 "
f"{float(surface_z_m):.4f} → 尖端在面{'下' if clear_m < 0 else '上仅'} "
f"{abs(clear_m) * 1000:.1f}mm(要求 ≥ {float(want_clearance_m) * 1000:.1f}mm)。\n"
f" 悬停高度通常是按**相机**视距定的,而工具比相机长 —— 两者冲突时必须"
f"先保证不碰工件。\n"
f" 已按几何抬到 {need_z:.4f}(= {float(surface_z_m):.3f} 上表面 + "
f"{float(tool_tip_below_flange_m) * 1000:.0f}mm 工具 + "
f"{float(want_clearance_m) * 1000:.1f}mm 间隙)。\n"
f" 要固定这个值:改 site.yaml 的 robots[<sn>].servo.hole_hover_z_m = "
f"{need_z:.3f}(以及同组件的 ee_z_m ≥ 它 +0.05)。"
)
return need_z, why
#: S05 精对位时判"工件被工具推走"的两级阈值 [m]。
#
# 为什么需要这条判据(2026-09-24 又栽了一次,见下面 drag_verdict 的说明):
# S05 的对准是"看着孔走"的闭环,而孔位是**工件上**的。工具一旦碰到工件,
# 闭环追的就不再是孔,而是"自己推着走的工件" —— 残差恒定(本轮 11mm)、
# 迭代用满,日志报的是"检查手眼/相机安装",与真因(工具比相机长、
# 悬停时已经插进工件)差着十万八千里。
# 取 3mm:工件是刚体,两拍之间它整体挪动 >3mm 只可能是被推/被撞(正常加工
# 过程里它一动不动)。同一对孔的两拍测量噪声在 0.2mm 量级,离阈值很远。
SNAP_DRAG_TOL_M: float = 0.003
#: 单拍就这么大 ⇒ 不必等第二拍确认(刚体不会自己"挪回去")。
SNAP_DRAG_HARD_M: float = 0.006
#: "已经到位"的容差:避免 ±1e-9 的浮点噪声变成"再转一整圈"。
_RATCHET_FULL_TURN_EPS: float = 1e-9
def ratchet_delta_rad(target_rad: float, q_now_rad: float, direction: int = 0) -> float:
""""目标角 − 当前角",折成**一次**能转到的增量 [rad]。
**为什么要有这个函数**(2026-09-24 实测:棘轮把整条流程 ABORT 在 S09 第一步):
S08/S09 给棘轮的是**关节 7 的目标角(绝对值)**(``q[-1] ± step``),
而棘轮此前把它当**增量**用。于是"关节在 +158.9°、目标 +338.9°"被算成
"要转 338.9°"(实际增量只有 180°)⇒ 误判行程不够 ⇒ 让余量那一步算到
限位外面(``158.9 − 338.9 = −180°``,比 −175° 还低)⇒ 报
"期望 −338.9°、实测 +26.0°,偏差过大" ⇒ ABORT。现场什么坏事都没发生。
差值必须折一圈:目标常常**故意**超出限位(这正是要"让余量"的原因),
``+338.9°`` 与 ``−21.1°`` 是同一个关节位置。
**为什么还要 direction**(2026-09-24 又一次实测:S08 第 3 轮 ABORT):
不给方向时这里取"最短的那条路",而名义步长**正好是 180°** 时,
哪条路更短取决于 0.1° 级的读数差:调用方按自己读到的 ``q`` 算出目标
``−18.0°``,棘轮再用**稍晚一点**读到的 ``q=+162.1°`` 去算差值,
``−180.1°`` 折一圈就成了 ``+179.9°`` —— 方向**翻了个个儿**。
于是棘轮朝螺纹的反方向走,接着贴限位、让余量、重试,净位移 0 ⇒ ABORT。
棘轮的转向是**工艺意图**(S08 顺时针拧入、S09 逆时针拧出),不能交给
"谁更短"这种数值巧合决定,所以由调用方明确给出 ``direction``。
注:``direction=0``(默认)保留旧的"最短路径"语义,给"目标本来就明确
在某一侧"的调用方用;棘轮的四个原子步一律显式给方向。
:param target_rad: 目标角(绝对值,可以是限位外的角度)。
:param q_now_rad: 当前关节角。
:param direction: 想要的转向:``+1`` 正转、``-1`` 反转、``0`` 取最短路径。
:returns: 增量 [rad]。``direction=0`` 时落在 ``(−π, π]``;给了方向时落在
``[0, 2π)``(正转)或 ``(−2π, 0]``(反转)——**一定**沿指定方向,
且是能落到目标角的最小那一步。**同一个目标、同一个当前位置**只会有一个
增量 —— 棘轮的"让余量"与"重试"都按它算,两次不会互相矛盾。
"""
base = float(target_rad) - float(q_now_rad)
two_pi = 2.0 * math.pi
if direction == 0:
return math.remainder(base, two_pi)
# 先折到 [0, 2π),再按想要的转向取那一侧(与目标角差正好一整圈,是同一个位置)。
pos = math.fmod(base, two_pi)
if pos < 0.0:
pos += two_pi
if pos >= two_pi - _RATCHET_FULL_TURN_EPS:
pos = 0.0 # 已经(几乎)在目标上:不要为了凑方向白转一整圈
if direction > 0:
return pos
return pos - two_pi if pos > 0.0 else 0.0
def drag_verdict(
mid_now: Tuple[float, float],
mid_prev: Optional[Tuple[float, float]],
mid_prev2: Optional[Tuple[float, float]] = None,
*,
tol_m: float = SNAP_DRAG_TOL_M,
hard_m: float = SNAP_DRAG_HARD_M,
) -> Tuple[bool, float]:
"""两孔中点是否在两拍之间"整体挪动" → ``(是否判定为被推走, 本拍位移 m)``。
**为什么看"中点"而不是看目标孔**:目标孔是**追**的对象,工具一动它就该动
(对准本意如此),看它分不出"正常对准"和"推着工件走"。而两孔**中点**在
对准过程中不该动 —— 它只有工件整体平移时才会动。所以中点位移干净地对应
"工件被搬动了"。
**为什么看"两孔中点"而不是单孔**:单孔在相机里会因分辨率/遮挡抖动,且
目标孔本来就在被逼近;中点是两者的平均,噪声更小、语义更明确。
:param mid_now: 本拍两孔中点 (x, y)[m](基座系)。
:param mid_prev: 上一拍中点;``None`` = 还没有上一拍(第一拍不判)。
:param mid_prev2: 上上拍中点。用来确认"位移是持续的"(单拍可能是误检)。
:returns: ``(判定, 本拍位移 m)``。
"""
if mid_prev is None:
return False, 0.0
dx = float(mid_now[0]) - float(mid_prev[0])
dy = float(mid_now[1]) - float(mid_prev[1])
d1 = math.hypot(dx, dy)
if d1 >= float(hard_m):
return True, d1
if d1 < float(tol_m) or mid_prev2 is None:
return False, d1
px = float(mid_prev[0]) - float(mid_prev2[0])
py = float(mid_prev[1]) - float(mid_prev2[1])
d2 = math.hypot(px, py)
# 第二拍确认:上一拍也挪了(≥ 阈值的一半),且方向一致(点积 > 0)。
# 为什么允许方向只"大致一致":工件被推着走时前后两拍的推动量本来就不同
# (工件一让开,工具就少推一点),拿夹角严格相等会漏判。
if d2 >= float(tol_m) * 0.5 and (dx * px + dy * py) > 0.0:
return True, d1
return False, d1
def descend_needed_m(
flange_z_m: float,
tool_tip_below_flange_m: float,
@@ -512,6 +759,115 @@ def ratchet_cycles(total_deg: float, step_deg: float) -> int:
return int(math.ceil(total_deg / step_deg))
@dataclass(frozen=True)
class RatchetTravel:
"""棘轮步长与关节行程的**可用性核算**(S08 开头自检,别等到第 3 轮才炸)。"""
ok: bool
step_deg: float # 配置的一步转角
travel_deg: float # 关节行程(上限 − 下限)
need_deg: float # 一步棘轮真正需要的行程
max_step_deg: float # 行程允许的最大步长
why: str # 不 ok 时给人看的说明(ok 时为空串)
def ratchet_travel_ok(
step_deg: float, limits_rad: Optional[Tuple[float, float]], margin_deg: float
) -> RatchetTravel:
"""一步棘轮 ``step_deg`` 放得进关节行程吗?
**为什么需要**:2026-09-24 实测,``screw_step_deg=180`` 配 Rizon4 的
关节 7(±175°,行程 350°)在第 3 轮 ABORT —— 慢转那一步要转 180°、
回转那一步还要 180°,两次加起来 360° > 350°,关节**必然**有一头贴限位;
更糟的是"让余量"也腾不出地方(要腾就得往反向再让一整步,还是 360°)。
日志里那一长串"贴限位 → 让余量 → 还是贴限位"就是这么来的。
判据取 ``2×step + 2×margin ≤ 行程``:
- 2×step:棘轮一个来回(慢转 + 回转)要的行程;
- 2×margin:两头都要留余量 —— 贴死限位再让余量时**一点空间都没有**,
而实测每轮回转还会比指令少走 4~5°(速度/加减速限制),
这点亏欠要靠余量吃,否则 7~8 轮后累计偏出限位、再也转不回来。
:param limits_rad: ``(lower, upper)`` [rad];``None`` = 拿不到限位,放行
(返回 ``ok=True``,理由写清楚:真机靠驱动器报 JOINT_LIMIT 兜底)。
"""
need = 2.0 * float(step_deg) + 2.0 * float(margin_deg)
if limits_rad is None:
return RatchetTravel(
ok=True, step_deg=float(step_deg), travel_deg=float("inf"),
need_deg=need, max_step_deg=float("inf"),
why="",
)
travel = math.degrees(float(limits_rad[1]) - float(limits_rad[0]))
max_step = 0.5 * (travel - 2.0 * float(margin_deg))
if need <= travel:
return RatchetTravel(
ok=True, step_deg=float(step_deg), travel_deg=travel,
need_deg=need, max_step_deg=max_step, why="",
)
# 给个能直接照着改的步长:往整十度取整(现场习惯填 120/150/180),
# 但不许把步长压到 30° 以下 —— 那样拧进/拧出的轮数会多到失控。
suggest = max(30.0, math.floor(max_step / 10.0) * 10.0)
why = (
f"棘轮一步 {step_deg:.0f}° 放不进关节 7 的行程:一步棘轮要 "
f"2×{step_deg:.0f}° + 2×{margin_deg:.0f}°(余量) = {need:.0f}°,"
f"而行程只有 {travel:.0f}°(上限 {math.degrees(limits_rad[1]):.0f}° − "
f"下限 {math.degrees(limits_rad[0]):.0f}°)。\n"
f" 改法:site.yaml 的 gauge_assembly.screw_step_deg 改到 "
f"{max_step:.0f}° 以内(例如 {suggest:.0f}°),并保证 "
f"screw_max_cycles × screw_step_deg 仍是 **360° 的整数倍**("
f"累计必须转整数圈,否则规停在螺纹中间、S09 也就少拧出几分之一圈)。"
)
return RatchetTravel(
ok=False, step_deg=float(step_deg), travel_deg=travel,
need_deg=need, max_step_deg=max_step, why=why,
)
#: ``rotation_verdict`` 的结论。
ROT_OK = "ok"
ROT_LIMIT = "limit" # 转了一部分、方向对、但没走完 ⇒ 最可能是贴到关节限位
ROT_AXIS = "axis" # 转轴与工具 Z 不共线 ⇒ 以「度」为单位的一切动作都不成立
ROT_SHORT = "short" # 其它偏差(方向不对/过冲)⇒ 真有问题,该停
def rotation_verdict(
delta_deg: float,
got_deg: float,
mis_deg: float,
tol_deg: float,
) -> str:
"""绕工具 Z 转 ``delta_deg``、实测转 ``got_deg`` 的验收判据。
**纯函数**:这条判据 2026-09-24 把整条 S09 判死过一次
("期望 +180.0°、实测 +16.4°,偏差过大" → ABORT),而真因只是
**关节7 的行程被前面的棘轮回转顶到了上限**(±175°,而棘轮一步就要 180°)。
判错两个方向都贵:
* 把"贴限位"判成"short" ⇒ 白扔一整轮(还可能把规撞歪);
* 把"真有问题"判成"limit" ⇒ 该停的没停,继续拧。
还有一条**曾经漏判**的:只比绝对值时,"让它 +180、它 -170"会算成
10° 误差、落在 10% 容差里 ⇒ 判成功,而实际上一个孔都没拧。所以方向必须比。
所以它必须能脱离节点被钉住。
"""
if abs(got_deg) < 3.0 and abs(delta_deg) > 10.0:
return ROT_LIMIT # 几乎没动:与下面的"只走了一部分"同因不同余量
if mis_deg > tol_deg:
return ROT_AXIS
if abs(got_deg) >= 3.0 and abs(delta_deg) >= 3.0 and got_deg * delta_deg < 0:
# 方向反了。这条以前**漏判**:老判据只比绝对值
# (``abs(abs(got) - abs(delta))``),所以"让它 +180、它 -170"会算 10° 误差
# 落在 10% 容差里 → 判成功,而实际上一个孔都没拧。
return ROT_SHORT
slack = max(tol_deg, 0.1 * abs(delta_deg))
short = abs(delta_deg) - abs(got_deg)
if short > slack:
return ROT_LIMIT # 方向对、只是没走完 ⇒ 被硬止点截住
if short < -slack:
return ROT_SHORT # 过冲:转多了,同样不该当成到位
return ROT_OK
def is_joint_limit_error(text: str) -> bool:
"""S01:把驱动/规划报的字符串认成"撞到关节极限"。
@@ -247,7 +247,9 @@ class Stage3Search(Stage2Locate):
sn_us = self.prefix.rstrip("_").replace("-", "_")
self.ft_topic = str(s3.get("ft_topic") or f"/{sn_us}/flexiv_robot_states")
if self.ft_watchdog and RobotStates is not None:
self.create_subscription(RobotStates, self.ft_topic, self._on_ft, 10)
self.create_subscription(
RobotStates, self.ft_topic, self._on_robot_states, 10
)
self._state_pub = self.create_publisher(String, "/stage3/state", 10)
self._result_pub = self.create_publisher(String, "/stage3/result", 10)
@@ -271,7 +273,18 @@ class Stage3Search(Stage2Locate):
def _on_tac_r(self, msg: Any) -> None:
self._tac_r = (time.monotonic(), msg)
def _on_ft(self, msg: Any) -> None:
def _on_robot_states(self, msg: Any) -> None:
"""缓存 ``flexiv_msgs/RobotStates``(腕部力看门狗的输入)。
⚠️ 这个方法名**必须**与 ``Stage2Locate`` 里那个腕部 FT 的回调区分开。
2026-09-24 踩到:两者原先都叫 ``_on_ft``,子类的定义**静默覆盖**了父类的,
而两个订阅都在 ``__init__`` 里建好了 —— 于是:
Stage2Locate 建的 WrenchStamped 订阅 → 实际进了这个 RobotStates 缓存;
``_ft_fz`` 永远不写 → S07 打"腕部 FT 没有数据"(而话题数据是好的)。
真机上更阴:两个话题都存在,``self._ft`` 里 RobotStates 与 WrenchStamped
交替被覆盖 → ``_ft_mag()`` 时有时无 → 看门狗时灵时不灵。
这类"同名覆盖 + 不报错"的坑,只要名字区分开就根除。
"""
self._ft = (time.monotonic(), msg)
def _tac_fresh(self, timeout_s: float = 1.0) -> Tuple[float, float, float, bool]:
+53 -2
View File
@@ -27,7 +27,11 @@ from arm_application.gauge_config import (
missing_entries,
unit_violations,
)
from arm_application.gauge_fsm import descend_iter_budget, descend_tol_m
from arm_application.gauge_fsm import (
descend_iter_budget,
descend_tol_m,
ratchet_travel_ok,
)
# =============================================================================
@@ -185,7 +189,7 @@ def test_describe_prints_effective_values_and_marks_calibration_needed():
text = "\n".join(cfg.describe())
# 实际生效的值(不是"我读了 site.yaml")
assert "S01 预绕 150.0°" in text
assert "S08 棘轮 180°×12 轮 = 累计 2160°" in text
assert "S08 棘轮 120°×12 轮 = 累计 1440°" in text
# 必须提示这些阈值要现场标定,否则默认值会被当成"调好的参数"
assert "现场标定" in text
# 稳定判据的口径要写出来,避免下次有人改成"相邻帧之差"
@@ -265,3 +269,50 @@ def test_shipped_site_yaml_s07_step_cannot_deadlock():
assert descend_iter_budget(depth_m, step_m) >= depth_m / step_m
# 顺带钉住"出厂配置的派生值印得出来"(describe 里会打印容差)
assert "S07" in "\n".join(cfg.describe())
# =============================================================================
# 出厂配置的 S08:棘轮步长必须放得进**支持的所有臂**的关节7 行程
# =============================================================================
def test_shipped_site_yaml_ratchet_step_fits_every_supported_arm():
"""**回归锚点**(2026-09-24 在仿真里踩到:S08 第 3 轮 ABORT)。
`screw_step_deg: 180` 配关节 7 的 ±175°(行程 350°)时,棘轮一个来回要
2×180°=360° > 350° —— 关节**必然**有一头贴限位,"让余量"也腾不出空间
(腾一步还是 360°)。日志表现是一长串"贴限位 → 让余量 → 还是贴限位",
真因(配置)藏在最后一行,白烧 3 轮节拍。
这条测试盯**出厂 site.yaml 的真实取值**,并且拿 `flexiv_description` 里
**每台支持臂**的 joint7 限位去算(不是写死 350°):以后加一台行程更小的
臂,这里会自动报红。余量取 `gauge_assembly_node.RATCHET_LIMIT_MARGIN_DEG`
的同一个口径 —— 两处不一致时,测试会红,而不是现场崩。
"""
import math
import yaml
root = Path(__file__).resolve().parents[3]
doc = yaml.safe_load((root / "config" / "site.yaml").read_text(encoding="utf-8"))
step = float(doc["gauge_assembly"]["screw_step_deg"])
margin = 8.0 # 与 gauge_assembly_node.RATCHET_LIMIT_MARGIN_DEG 同口径
from arm_application.gauge_assembly_node import RATCHET_LIMIT_MARGIN_DEG
assert margin == RATCHET_LIMIT_MARGIN_DEG, "余量口径变了,测试要跟着改"
limit_files = sorted((root / "src" / "flexiv_description" / "config").glob("*/joint_limits.yaml"))
assert limit_files, "找不到任何臂的 joint_limits.yaml —— 路径变了?"
for path in limit_files:
doc_l = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
joint7 = (doc_l.get("joint_limits") or {}).get("joint7") or doc_l.get("joint7")
assert joint7, f"{path} 里没有 joint7 限位"
limits = (float(joint7["lower"]), float(joint7["upper"]))
verdict = ratchet_travel_ok(step, limits, margin)
assert verdict.ok, f"{path.name}: 出厂配置的棘轮步长放不下 —— {verdict.why}"
assert verdict.max_step_deg > 0.0
# 累计转角必须是整数圈:否则规停在螺纹中间,S09 拧出也少转几分之一圈
cycles = int(doc["gauge_assembly"]["screw_max_cycles"])
assert math.isclose(step * cycles % 360.0, 0.0, abs_tol=1e-9), (
f"screw_step_deg×screw_max_cycles = {step * cycles}° 不是 360° 的整数倍"
)
@@ -0,0 +1,182 @@
"""S09 拧出后**先竖直提出孔口**:结构上有序,行为上提对高度。
为什么单独钉住(2026-09-24 仿真实测,两次栽在同一个地方)
--------------------------------------------------------
仿真里**没有螺纹几何**:S09 的"拧出"只让规在孔里转,规**仍然插在孔里**
(真机也只是沿螺纹退回几个毫米)。于是紧跟着的横向动作 —— S10 换孔 / S11 回 home
—— 会把规从孔里**拽**出来:实测抓取锁被拽断、规掉在台面上。
最坑的是**它不在这里报错**:两分钟后下游只表现为"下一孔 S07 下探 35mm 判不到
接触"(工具上已经没规了),日志指不到真因(规则 R5.1 的静默失效)。
所以这里有两条断言,缺一不可:
* **结构**:``_step_unscrew`` 在"成功返回"之前必须调用提出动作 —— 顺序错了
(比如把提出挪到 S10 里)就等于没做,因为横向动作已经发生了;
* **行为**:提出目标是**跟踪高**(``_track_z()`` = 悬停高 + 50mm),不是写死的
毫米数;已经在跟踪高上时不动(否则每次换孔白等 1 秒多)。
"""
from __future__ import annotations
import ast
import pathlib
from typing import Any, List
import numpy as np
import pytest
from arm_application.gauge_assembly_node import GaugeAssembly
NODE_SRC = (
pathlib.Path(__file__).resolve().parents[1]
/ "arm_application" / "gauge_assembly_node.py"
)
# =============================================================================
# 结构:提出动作必须在 S09 的"成功出口"之前
# =============================================================================
def _node_ast() -> ast.Module:
return ast.parse(NODE_SRC.read_text(encoding="utf-8"))
def _find_method(name: str) -> ast.FunctionDef:
for node in ast.walk(_node_ast()):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise AssertionError(f"节点里找不到方法 {name}")
def _call_lines(func: ast.AST, attr: str) -> List[int]:
out: List[int] = []
for node in ast.walk(func):
if isinstance(node, ast.Call):
f = node.func
if isinstance(f, ast.Attribute) and f.attr == attr:
out.append(node.lineno)
return out
def test_unscrew_lifts_out_of_the_hole_before_succeeding():
"""拧出成功后必须先提出孔口,再返回 ``UNSCREW_DONE``。"""
fn = _find_method("_step_unscrew")
lifts = _call_lines(fn, "_lift_clear_of_hole")
assert lifts, (
"S09 结束时没有竖直提出孔口 —— S10/S11 的横向动作会把规从孔里拽出来"
"(实测:锁被拽断、规掉在台面上,下游表现为『S07 判不到接触』)"
)
exits = [
n.lineno
for n in ast.walk(fn)
if isinstance(n, ast.Return)
and isinstance(n.value, ast.Attribute)
and n.value.attr == "UNSCREW_DONE"
]
assert exits, "S09 没有成功出口?"
assert min(lifts) < max(exits), (
"提出孔口必须在**成功返回之前**:顺序反了等于没做(横向动作已经发生)"
)
def test_lift_helper_exists_and_uses_the_track_height_idiom():
"""提出高度用 ``_track_z()``(悬停高 + 50mm),不写死毫米数。"""
fn = _find_method("_lift_clear_of_hole")
assert _call_lines(fn, "_track_z"), (
"提出高度必须用 _track_z():这是流程里『工具已离开工件』的既有定义,"
"写死毫米数会多出一套没人维护的口径"
)
assert _call_lines(fn, "_goto"), "提出是一次真实运动(_goto),不是改个变量"
src = ast.get_source_segment(NODE_SRC.read_text(encoding="utf-8"), fn) or ""
assert "hold_xy" in src, "提出必须是**纯 Z**:横向分量会把规别在孔壁上"
# =============================================================================
# 行为:提对高度 / 已在高度上不动 / 拿不到状态不炸
# =============================================================================
class _StubLogger:
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def info(self, msg: str) -> None:
self._sink.append(msg)
def warn(self, msg: str) -> None:
self._sink.append(msg)
class _StubAssembly:
"""``_lift_clear_of_hole`` 只用这几样东西;测试里不建任何 ROS 实体。"""
move_speed = 0.05
vert_tol_rad = 0.02
def __init__(self, z: Any, track_z: float = 0.707, raise_on_goto: bool = False) -> None:
self.logs: List[str] = []
self.moves: List[Any] = []
self._z = z
self._tz = float(track_z)
self._raise = bool(raise_on_goto)
def _wait_state(self, _timeout: float) -> Any:
if self._z is None:
return None, None
Tee = np.eye(4)
Tee[0, 3] = 0.5130
Tee[1, 3] = -0.0020
Tee[2, 3] = float(self._z)
return np.zeros(7), Tee
def _track_z(self) -> float:
return self._tz
def get_logger(self) -> Any:
return _StubLogger(self.logs)
def _goto(self, q: Any, Tee: Any, target: Any, **kw: Any) -> Any:
if self._raise:
raise RuntimeError("轨迹被拒")
self.moves.append((np.array(target, dtype=float), kw))
return True, q, Tee
def _stub(z: Any, track_z: float = 0.707, raise_on_goto: bool = False) -> _StubAssembly:
return _StubAssembly(z, track_z, raise_on_goto)
def test_deep_in_the_hole_lifts_to_track_height():
"""规还在孔里(实测规尖比工件面低 30mm)→ 提到跟踪高,纯 Z。"""
st = _stub(0.628)
GaugeAssembly._lift_clear_of_hole(st, "k1") # type: ignore[arg-type]
assert len(st.moves) == 1, "应当恰好走一次提出"
target, kw = st.moves[0]
assert target[0] == pytest.approx(0.5130) and target[1] == pytest.approx(-0.0020), (
"提出必须是**纯 Z**:XY 一动就是把规别在孔壁上"
)
assert target[2] == pytest.approx(0.707), "目标高度就是跟踪高(悬停 + 50mm)"
assert kw.get("hold_xy") is True
assert kw.get("tag") == "S09_exit_k1"
assert any("竖直提出" in m for m in st.logs), "日志要能回看这次提出(不然失败时查不到)"
def test_already_high_enough_does_not_move():
"""已经比跟踪高还高 → 一步都不走(否则每次换孔白等一秒多)。"""
st = _stub(0.80)
GaugeAssembly._lift_clear_of_hole(st, "k1") # type: ignore[arg-type]
assert st.moves == []
assert st.logs == []
def test_no_joint_state_is_not_fatal():
"""拿不到状态只告警:这一刻孔里已经没有规要保护,硬失败会把成功的装配变成失败。"""
st = _stub(None)
GaugeAssembly._lift_clear_of_hole(st, "k1") # type: ignore[arg-type]
assert st.moves == []
assert st.logs == [], "拿不到状态连日志都不用刷(静默跳过即可)"
def test_goto_exception_is_swallowed_with_a_warning():
"""提出失败(轨迹被拒)只告警,流程继续 —— 后面 S10/S11 仍会走到安全位。"""
bad = _stub(0.628, raise_on_goto=True)
GaugeAssembly._lift_clear_of_hole(bad, "k1") # type: ignore[arg-type]
assert any("提出孔口失败" in m for m in bad.logs)
+368 -2
View File
@@ -25,21 +25,34 @@ from arm_application.gauge_fsm import (
State,
DESCEND_BUDGET_FLOOR_M,
GAUGE_SHOULDER_ABOVE_TIP_M,
WRENCH_MAX_AGE_S,
descend_budget_m,
descend_iter_budget,
descend_needed_m,
descend_progress,
descend_tol_m,
relief_tol_m,
tool_clearance_m,
wrench_live,
wrench_touch,
is_joint_limit_error,
pixel_stable,
points_stable,
hover_z_guard,
hover_z_for_tool_clearance,
ratchet_cycles,
resolve_next,
rotation_verdict,
screw_stop,
tangential_jump,
drag_verdict,
ratchet_delta_rad,
ratchet_travel_ok,
SNAP_DRAG_TOL_M,
)
from arm_application.gauge_config import DEFAULTS
# =============================================================================
# 主线:k1 → k2 → 复位
@@ -456,6 +469,34 @@ def test_descend_tol_rejects_nonpositive_step():
descend_tol_m(-0.0005)
def test_relief_tolerance_is_strictly_smaller_than_the_relief_move():
"""卸载回退的容差必须**严格小于**回退量(与下压步长同一条纪律)。
2026-09-24 实测踩到:回退 0.50mm 而容差被写死 0.0005 m(同量级),
目标 z 的浮点回减让 ``dist`` 比 0.0005 略小 ⇒ ``_goto`` 入口就判"已到位"
⇒ 卸载成为空操作,而残余力判据比的是两个相同读数 ⇒ 假报"已放干净"。
之后 S08 带着 ~70N 轴向力去转(夹持摩擦上限 ≈73N)→ 掉件(E1)。
"""
for dz_mm in (0.05, 0.2, 0.5, 1.0, 3.0):
dz = dz_mm / 1000.0
tol = relief_tol_m(dz)
assert 0.0 < tol < dz, f"回退 {dz_mm}mm 的容差 {tol * 1000:.4f}mm 不小于回退量"
def test_relief_tolerance_has_a_floor_but_never_swallows_the_move():
"""回退量极小时容差退不下去,但**仍不许**大于等于回退量。"""
dz = 1e-5
tol = relief_tol_m(dz)
assert 0.0 < tol <= dz * 0.5
def test_relief_tolerance_rejects_nonpositive_move():
with pytest.raises(ValueError):
relief_tol_m(0.0)
with pytest.raises(ValueError):
relief_tol_m(-0.0005)
def test_descend_iter_budget_covers_the_whole_depth():
"""步数上限必须够走完整个下探深度(否则会在"还没到底"时被上限截断)。"""
step_m, depth_m = 0.0005, 0.025
@@ -519,8 +560,6 @@ def test_shipped_budget_covers_shipped_gauge_and_hover():
以后谁把 contact_max_depth_mm 调回去、或把 tool_tip_below_flange_m 改大
到不匹配,这里就会红。
"""
from arm_application.gauge_config import DEFAULTS
budget_m = DEFAULTS["contact_max_depth_mm"] / 1000.0
tip_below = DEFAULTS["tool_tip_below_flange_m"]
# 出厂配置里 workpiece_surface_z_m = 0(运行期用 stage2 实测孔位兜底),
@@ -532,6 +571,57 @@ def test_shipped_budget_covers_shipped_gauge_and_hover():
)
def test_wrench_touch_is_sign_agnostic():
"""腕部 FT 判据取**绝对增量**,不赌正负号。
正负号方向取决于传感器安装(真机法兰系、仿真 link7 系,压下去是 +z 还是 -z
都可能)。赌错了就是"整段流程静默失效":下发正常、日志正常、就是停不住。
取绝对值只会更早停一点点,这个方向的错是安全的 —— 与既有
stage2_locate_node._contact_detected 的口径(abs(fz - baseline))也一致。
"""
assert wrench_touch(20.0, 0.0, 8.0)
assert wrench_touch(-20.0, 0.0, 8.0)
# 基线本身不是 0(带载)时同样只看增量
assert wrench_touch(54.0, 46.0, 8.0)
assert wrench_touch(38.0, 46.0, 8.0)
def test_wrench_touch_below_threshold_does_not_fire():
assert not wrench_touch(7.9, 0.0, 8.0)
assert not wrench_touch(46.0, 46.0, 8.0)
# 边界:等于阈值算触发(与 tangential_jump 的 >= 口径一致)
assert wrench_touch(8.0, 0.0, 8.0)
def test_wrench_touch_threshold_is_reachable_before_grip_slips():
"""阈值的量级必须**远低于**夹持摩擦能传的上限,否则等于设了个永远达不到的数。
这是"用 300 当阈值"那个 bug 的一般化回归:夹持段 µ=0.25、实测握力 ≈46N
⇒ 两指合计能传约 µ·N ≈ 23N。接触刚度 5e4 N/m,所以 8N 阈值 ≈ 0.16mm
过压就判到,而打滑要到 ≈0.46mm —— 留了约 3 倍余量。
"""
mu, grip_n = 0.25, 46.0
slip_limit_n = mu * grip_n * 2 # 两指
assert DEFAULTS["contact_axial_delta_n"] < slip_limit_n / 2, (
f"阈值 {DEFAULTS['contact_axial_delta_n']}N 太接近夹持摩擦上限 "
f"{slip_limit_n:.1f}N —— 打滑前可能来不及判到"
)
def test_wrench_live_uses_freshness_not_magnitude():
"""判活看"够不够新",**不看数值大小**。
静止时轴向力本来就接近 0。拿数值判活会把"安静的传感器"误判成掉线 ——
这正是 2026-09-24 在触觉上踩过的同一个坑(判据从 |fnormal|>1.0 改成
"够新 + connected")。所以这里专门断言 age 有值但数值为 0 → 活的。
"""
assert wrench_live(0.0)
assert wrench_live(WRENCH_MAX_AGE_S)
assert not wrench_live(WRENCH_MAX_AGE_S + 1e-6)
# None = 一帧都没收到过(话题名错/传感器没起来)→ 不是活的
assert not wrench_live(None)
def test_descend_iter_budget_zero_depth_means_no_iterations():
assert descend_iter_budget(0.0, 0.0005) == 0
with pytest.raises(ValueError):
@@ -546,3 +636,279 @@ def test_descend_progress_only_counts_real_downward_motion():
assert not descend_progress(0.6316, 0.63159, step_m) # 只走了 0.01mm:被死区吃掉
assert not descend_progress(0.6316, 0.6316, step_m) # 完全没动(空转)
assert not descend_progress(0.6316, 0.6317, step_m) # 往上跑 ≠ 推进
# =============================================================================
# 绕轴旋转的验收判据(S01/S08/S09 共用)
# =============================================================================
def test_rotation_verdict_accepts_a_full_turn():
assert rotation_verdict(-180.0, -179.7, 0.0, 5.0) == "ok"
assert rotation_verdict(+180.0, +175.1, 0.0, 5.0) == "ok" # 5° 之内算到位
def test_rotation_verdict_calls_a_partial_turn_a_joint_limit():
"""2026-09-24 实测的那一条:**只转了 16.4°**(被关节7 的上限截住)。
期望 +180、实测 +16.4:方向对、只是没走完 ⇒ 判"贴限位"(调用方张开爪
让出余量再转),**不是**"偏差过大"直接 ABORT —— 后者把一整轮白扔,
而且日志看起来像"这台臂坏了"。
"""
assert rotation_verdict(+180.0, +16.4, 0.1, 5.0) == "limit"
# 完全没动也是同一类(只是余量更少)
assert rotation_verdict(+180.0, +0.4, 0.0, 5.0) == "limit"
def test_rotation_verdict_still_stops_on_wrong_direction_or_overshoot():
"""判"贴限位"必须**卡住方向**:反向转/过冲都是真问题,不能拿去让余量重试。"""
assert rotation_verdict(+180.0, -170.0, 0.0, 5.0) == "short" # 转反了
assert rotation_verdict(+180.0, +260.0, 0.0, 5.0) == "short" # 过冲
# 小幅指令(≤10°)几乎没动时**不算**"完全没动"那条特例,于是落进
# "方向对但没走完" ⇒ 同样判 limit。这是有意的:让余量那一步是免费的
# (张开爪时规在打滑),宁可白让一次,也别把"其实贴住了"判成"臂坏了"。
assert rotation_verdict(+8.0, +0.2, 0.0, 5.0) == "limit"
def test_rotation_verdict_axis_misalignment_beats_everything():
"""转轴不共线(>5°)时,任何角度数字都没有意义 —— 必须先报它。"""
assert rotation_verdict(+180.0, +179.0, 6.0, 5.0) == "axis"
assert rotation_verdict(+180.0, +16.4, 6.0, 5.0) == "axis"
def test_ratchet_step_of_180_never_fits_the_rizon_joint7_range():
"""棘轮一步 180° 在 Rizon4 的关节7 行程(±175°)里**永远**放不下。
这是 S09 中止的物理根源,也解释了为什么转腕前要先让余量:
贴着上限时反向那一步只剩十几度(实测 16.4°)。
"""
from arm_application.gauge_kin import prewind
lim = (-3.0543, 3.0543) # Rizon4 关节7:±175°
# 贴在上限(棘轮回转每轮都停在这儿)→ 想再往 + 转:放不下,必须让余量
plan = prewind(q_now_rad=math.radians(171.0), want_deg=180.0, direction=+1,
limits=lim, margin_deg=8.0)
assert plan.clamped
# 同一个位置往 - 转:放得下(这正是"让余量"那一步)
plan = prewind(q_now_rad=math.radians(171.0), want_deg=180.0, direction=-1,
limits=lim, margin_deg=8.0)
assert not plan.clamped
# =============================================================================
# 悬停高度自检(悬停按相机定、工具比相机长 ⇒ 会捅进工件)
# =============================================================================
def test_hover_guard_fires_for_the_exact_case_we_hit():
"""2026-09-24 实测:仿真悬停 0.632、工具长 0.245、工件面 0.412。
尖端 0.632−0.245=0.387,比工件面低 25mm ⇒ 悬停这一步就把工具捅进去了,
紧接着 S05 的横向对准把工件拖走 92mm。这里必须报"要改"并给出几何值。
"""
fixed, why = hover_z_guard(0.632, 0.412, 0.245, 0.002)
assert why, "这种悬停必须被判为不安全"
assert fixed == pytest.approx(0.412 + 0.245 + 0.002)
assert "钻进工件" in why
assert "hole_hover_z_m = 0.659" in why # 要能直接照着改配置
def test_hover_guard_is_silent_when_the_tool_stays_clear():
"""几何对的配置一个字都不许说(否则每次启动都刷一堆无用告警)。"""
fixed, why = hover_z_guard(0.659, 0.412, 0.245, 0.002)
assert why == ""
assert fixed == pytest.approx(0.659)
# 更高也照过(悬停高一点只是拍得远,不算错)
assert hover_z_guard(0.700, 0.412, 0.245, 0.002)[1] == ""
def test_hover_guard_uses_want_clearance_when_configuring():
"""修正值必须落在"刚好 want_clearance"的位置,而不是随手抬一点。"""
fixed, _ = hover_z_guard(0.50, 0.412, 0.245, 0.005)
assert fixed == pytest.approx(0.412 + 0.245 + 0.005)
assert hover_z_for_tool_clearance(0.412, 0.245, 0.005) == pytest.approx(fixed)
def test_hover_guard_tolerates_millimetre_level_shortfall():
"""夹在目标间隙附近(差不到 1mm)不算错:工件面 z 本身就有几毫米不确定度。"""
assert hover_z_guard(0.6580, 0.412, 0.245, 0.002)[1] == "" # 差 1.0mm 之内
assert hover_z_guard(0.6560, 0.412, 0.245, 0.002)[1] != "" # 差 3mm:判错
# =============================================================================
# S05 精对位时"工件被工具推走"的看门狗
# =============================================================================
def test_drag_verdict_says_nothing_on_the_first_frame():
"""第一拍没有上一拍可比 —— 不能因为"没有历史"就报警。"""
assert drag_verdict((0.49, 0.0), None)[0] is False
def test_drag_verdict_ignores_measurement_jitter():
"""正常对准时两孔中点不动(动的是目标孔,不是中点)—— 亚毫米抖动不算。"""
ok, d = drag_verdict((0.4905, 0.0002), (0.4900, 0.0000), (0.4899, 0.0000))
assert ok is False
assert d == pytest.approx(math.hypot(0.0005, 0.0002), abs=1e-9)
def test_drag_verdict_fires_on_one_big_jump():
"""单拍就挪了 8mm:刚体不会自己挪回去,不必等第二拍确认。"""
ok, d = drag_verdict((0.4820, 0.0), (0.4900, 0.0))
assert ok is True
assert d == pytest.approx(0.008)
def test_drag_verdict_needs_two_frames_when_it_is_only_3_plus_mm():
"""只大过 3mm 一点点:要求上一拍也朝同方向挪过,避免一次误检就中止流程。"""
mid = (0.4860, 0.0)
prev = (0.4900, 0.0)
# 上一拍没动(d2 = 0)⇒ 只当噪声
assert drag_verdict(mid, prev, (0.4901, 0.0))[0] is False
# 上一拍也挪了 4mm、方向一致 ⇒ 判被推走
assert drag_verdict(mid, prev, (0.4940, 0.0))[0] is True
# 上一拍也挪了 4mm,但方向**相反**(工件抖动/误检)⇒ 不判
assert drag_verdict(mid, prev, (0.4820, 0.0))[0] is False
def test_drag_verdict_matches_what_we_actually_saw_in_gazebo():
"""2026-09-24 实测那一轮的真实数字:工件每拍被推 11.8mm(残差恒 11mm)。
这条是回归测试:当时的现场没有任何判据报警,日志只说"检查手眼/相机安装",
于是一路追到迭代用满。现在同样的数列必须在**第一拍**就判出来。
"""
ks = [0.4910, 0.4910, 0.4792, 0.4674, 0.4566, 0.4458, 0.4350, 0.4239, 0.4121]
mids = [(k - 0.029, 0.0) for k in ks] # 中点 = k1 左移两孔间距的一半
flags = [
drag_verdict(mids[i], mids[i - 1] if i >= 1 else None,
mids[i - 2] if i >= 2 else None)[0]
for i in range(len(mids))
]
assert flags == [False, False, True, True, True, True, True, True, True]
assert SNAP_DRAG_TOL_M < 0.0118
# =============================================================================
# 棘轮:目标角(绝对值)→ 增量。2026-09-24 S09 第一步 ABORT 的根因
# =============================================================================
def test_ratchet_delta_wraps_a_target_that_is_past_the_limit():
"""实测那一笔:关节在 +158.9°、目标 +338.9° ⇒ 增量是 +180°(不是 +338.9°)。
差值的折算是整个棘轮的地基:把它当成 338.9° 会让"行程够不够"算错、
让余量那一步算到限位外面,最后报一句与真因无关的"偏差过大"。
"""
d = ratchet_delta_rad(math.radians(338.9), math.radians(158.9))
assert math.degrees(d) == pytest.approx(180.0, abs=1e-6)
def test_ratchet_rewind_target_stays_inside_the_limit():
"""让余量那一步必须落在限位内 —— 这正是当时失败的地方(−180° < −175°)。"""
lo, up = math.radians(-175.0), math.radians(175.0)
q_now = math.radians(158.9)
delta = ratchet_delta_rad(math.radians(338.9), q_now)
rewind = q_now - delta # 让出整步
assert lo < rewind < up
assert math.degrees(rewind) == pytest.approx(-21.1, abs=1e-6)
# 让完余量再前进整步 ⇒ 关节回到原处、工具净转 +180°
retry = rewind + delta
assert math.degrees(retry) == pytest.approx(158.9, abs=1e-6)
def test_ratchet_delta_is_the_same_number_for_both_wrapped_aims():
"""+338.9° 与 −21.1° 是同一个关节位置(差一整圈)—— 增量必须一致。"""
q_now = math.radians(158.9)
a = ratchet_delta_rad(math.radians(338.9), q_now)
b = ratchet_delta_rad(math.radians(-21.1), q_now)
assert a == pytest.approx(b, abs=1e-9)
def test_ratchet_delta_covers_a_plain_back_and_forth_cycle():
"""正常的棘轮一步(不贴限位):从 +150° 拧 −180°、再回转 +180°。"""
q = math.radians(150.0)
d = ratchet_delta_rad(math.radians(-30.0), q)
assert math.degrees(d) == pytest.approx(-180.0, abs=1e-6)
d2 = ratchet_delta_rad(math.radians(150.0), q + d)
assert math.degrees(d2) == pytest.approx(180.0, abs=1e-6)
# =============================================================================
# 棘轮的**转向**必须由工艺意图决定,不能交给"谁更短"
# 2026-09-24 实测:S08 第 3 轮 ABORT(步长 180° 的刀口)
# =============================================================================
def test_ratchet_180_degree_step_flips_direction_on_a_tenth_of_a_degree_read():
"""**实测那一笔**:调用方读到 q=+162.0 ⇒ 目标 −18.0;棘轮晚 0.1° 读到
q=+162.1 ⇒ "最短路径"给出 **+179.9°**(方向反了)。
180° 正好落在 ±180° 的刀口上:0.1° 的读数差就能决定"往前"还是"往后"。
结果是棘轮朝螺纹反方向走 → 贴限位 → 让余量 → 重试 → **净位移 0** ⇒ ABORT,
而现场什么都没坏。所以转向必须由调用方按工艺意图给定。
"""
d_naive = ratchet_delta_rad(math.radians(-18.0), math.radians(162.1))
assert math.degrees(d_naive) == pytest.approx(+179.9, abs=0.05) # 旧口径:翻向
d_screw = ratchet_delta_rad(math.radians(-18.0), math.radians(162.1), -1)
assert math.degrees(d_screw) == pytest.approx(-180.1, abs=0.05), "拧入必须往 − 转"
d_back = ratchet_delta_rad(math.radians(342.0), math.radians(162.1), +1)
assert math.degrees(d_back) == pytest.approx(+179.9, abs=0.05), "回转必须往 + 转"
def test_directed_delta_always_lands_on_the_target_and_keeps_the_sign():
"""给了方向:增量符号一定等于指定方向,且转过去**就是**目标角(差整圈)。"""
q = math.radians(162.1)
for want_sign, target_deg in ((+1, 342.0), (+1, -18.0), (-1, -18.0), (-1, 342.0)):
d = ratchet_delta_rad(math.radians(target_deg), q, want_sign)
assert d != 0.0
assert math.copysign(1.0, d) == want_sign, (want_sign, target_deg, math.degrees(d))
# 转过去与目标角**同一个位置**(差整数圈)
turns = (q + d - math.radians(target_deg)) / (2.0 * math.pi)
assert abs(turns - round(turns)) < 1e-9
# 且是那一侧**最小**的一步(再多转一整圈就不是"最小"了)
assert abs(d) <= 2.0 * math.pi + 1e-9
def test_directed_delta_does_not_waste_a_full_turn_when_already_there():
"""已经在目标角上时不许"为了凑方向"白转一整圈 —— 那是 1 秒多的空转。"""
q = math.radians(30.0)
assert ratchet_delta_rad(q, q, +1) == 0.0
assert ratchet_delta_rad(q, q, -1) == 0.0
# 差一点点也算"就在那儿"(1e-12 rad 量级)而不是 359.999…°
assert abs(ratchet_delta_rad(q, q - 1e-12, +1)) < 1e-9
def test_plain_shortest_path_is_still_available_for_non_ratchet_callers():
"""``direction=0`` 保留"最短路径"语义(给目标本来就明确在某一侧的调用方)。"""
d = ratchet_delta_rad(math.radians(338.9), math.radians(158.9))
assert math.degrees(d) == pytest.approx(180.0, abs=1e-6)
assert math.degrees(ratchet_delta_rad(math.radians(10.0), math.radians(350.0))) == pytest.approx(20.0)
# =============================================================================
# 棘轮步长 vs 关节行程:S08 开头就要判,不能等第 3 轮贴限位
# =============================================================================
def test_ratchet_travel_rejects_180_on_the_rizon_joint7():
"""180° 的步长在 ±175° 的关节 7 上**永远**放不下(实测第 3 轮 ABORT)。
一个来回要 2×180° = 360°,比行程 350° 还大 —— 无论怎么让余量都腾不出
空间,所以这不是"调调就好",是**配置错**,必须在 S08 开头就拦下来。
"""
lim = (-3.0543, 3.0543) # Rizon4/10 关节7:±175°
v = ratchet_travel_ok(180.0, lim, 8.0)
assert not v.ok
assert v.need_deg == pytest.approx(376.0)
assert v.travel_deg == pytest.approx(350.0, abs=0.1)
assert v.max_step_deg == pytest.approx(167.0, abs=0.1)
assert "screw_step_deg" in v.why, "报错要直接指出该改哪个键"
assert "screw_max_cycles" in v.why, "还要提醒累计必须是整数圈"
def test_ratchet_travel_accepts_the_shipped_step_and_margin():
"""出厂值(120°)必须过 —— 否则 S08 一进门就 ABORT,仿真永远跑不完。"""
from arm_application.gauge_config import DEFAULTS
lim = (-3.0543, 3.0543)
v = ratchet_travel_ok(DEFAULTS["screw_step_deg"], lim, 8.0)
assert v.ok, v.why
# 建议值必须落在上限内,否则"照着改"会再撞一次
assert v.max_step_deg >= DEFAULTS["screw_step_deg"]
def test_ratchet_travel_passes_when_limits_are_unknown_but_says_so():
"""拿不到限位不能猜着拦:放行,理由交给驱动器报 JOINT_LIMIT 兜底。"""
v = ratchet_travel_ok(180.0, None, 8.0)
assert v.ok
assert v.travel_deg == float("inf")
assert v.max_step_deg == float("inf")
assert v.why == ""
@@ -0,0 +1,151 @@
"""``GripperClient.dropped()``:E1 掉件判据只认**开口塌陷**,不认状态位抖动。
为什么值得单独钉住(2026-09-24 实测踩到):
仿真里 ``GripperStatus.is_gripped`` 是**推断值**("指令到了合拢侧 + 实际开口
比指令大出容差")。S08 拧规时会在"合到某个开口 / 全合"之间发指令,只要某一帧
的指令落到与实际开口同一档,``is_gripped`` 就翻成 False —— 于是
**S08 第 2/12 轮报「通止规掉了」**(爪里其实好好的),整条流程转去等人工
"放好通止规",而仿真里没有这个人。
判据改成"夹住时的开口 − 当前开口 ≥ 5mm"以后,这种抖动不再报掉件;而真的掉了
(两指合到底、开口塌几十毫米)仍然会报。
"""
from __future__ import annotations
from typing import Any, List
from arm_msgs.msg import GripperStatus
class _StubNode:
"""``GripperClient`` 只用到这三个方法;测试里不建任何 ROS 实体。"""
def __init__(self) -> None:
self.warnings: List[str] = []
def create_client(self, srv_type: Any, name: str) -> None:
return None
def create_subscription(self, msg_type: Any, topic: str, cb: Any, qos: Any) -> None:
self._cb = cb
return None
def get_logger(self) -> Any:
return _StubLogger(self.warnings)
class _StubLogger:
def __init__(self, sink: List[str]) -> None:
self._sink = sink
def info(self, msg: str) -> None:
self._sink.append(msg)
def warn(self, msg: str) -> None:
self._sink.append(msg)
def error(self, msg: str) -> None:
self._sink.append(msg)
def _status(is_gripped: bool, position: float) -> GripperStatus:
msg = GripperStatus()
msg.is_gripped = bool(is_gripped)
msg.position = float(position)
return msg
def _client():
from arm_application.gauge_assembly_node import GripperClient
node = _StubNode()
return GripperClient(node, "contract", "/gripper/status"), node
def test_no_drop_before_ever_gripped():
"""装配前爪里本来就空:状态位说什么都不算掉件(否则一开场就 E1)。"""
client, _ = _client()
client._on_contract(_status(False, 0.0))
assert client.dropped() is False
def test_a_wide_open_flap_does_not_poison_the_baseline():
"""**本次踩到的那个假 E1 的真因**:全开时 is_gripped 也闪 True(实测 99.29mm)。
以前"第一次判到夹住"就记基准 ⇒ 基准被记成 99.29mm ⇒ 塌陷量算成 77mm ⇒
报"通止规掉了",而规好好地在爪里、真实开口一直是 21.9mm。
现在基准只由明确调用 adopt_current_grip 记录:状态位怎么闪都不动它。
"""
client, _ = _client()
client._on_contract(_status(True, 99.29)) # 全开时的假 GRIPPING
assert client._grip_aperture is None, "状态位不许写基准"
assert client.dropped() is False
client._on_contract(_status(True, 21.9)) # 真正夹住规之后
client.adopt_current_grip("测试:确认夹住")
assert client._grip_aperture == 21.9
client._on_contract(_status(False, 21.8))
assert client.dropped() is False, "开口没塌就不是掉件"
def test_is_gripped_flap_is_not_a_drop():
"""**本次踩到的那个假 E1**:指令切换让 is_gripped 翻 False,开口没塌。"""
client, _ = _client()
client._on_contract(_status(True, 24.9)) # 夹住 φ30 的规,开口停在 24.9mm
client.adopt_current_grip("测试:确认夹住")
assert client.dropped() is False
client._on_contract(_status(False, 24.8)) # 指令落到同一档 → 推断值翻 False
assert client.dropped() is False, "开口没塌就不是掉件"
def test_real_drop_is_detected_by_aperture_collapse():
"""真的掉了:两指一路合到底,开口塌到几毫米。"""
client, _ = _client()
client._on_contract(_status(True, 24.9))
client.adopt_current_grip("测试:确认夹住")
client._on_contract(_status(False, 0.2))
assert client.dropped() is True
def test_aperture_jitter_within_the_threshold_is_not_a_drop():
"""实测抖动是亚毫米量级;阈值 5mm 不该被它触发。"""
client, _ = _client()
client._on_contract(_status(True, 24.9))
client.adopt_current_grip("测试:确认夹住")
client._on_contract(_status(False, 24.9 - 4.9))
assert client.dropped() is False
def test_without_aperture_reading_falls_back_to_the_old_criterion():
"""拿不到 position(老固件/别的夹爪)时退回旧判据:会抖,但不至于"永远判不了掉件"。"""
client, _ = _client()
client._on_contract(_status(True, float("nan")))
assert client.adopt_current_grip("测试:确认夹住") is None
assert client._grip_aperture is None
client._on_contract(_status(False, float("nan")))
assert client.dropped() is True, "没有开口读数时不能变成「永远不报掉件」"
def test_settle_waits_for_the_motion_to_finish():
"""``settle`` 不能读"动作还没做完"的旧值。
实测:``close`` 服务返回时手指还在动,立刻读开口读到的是**全开 99.93mm** ——
于是"第 1 轮夹紧后:开口 99.93mm(夹住时 99.29mm)"这种荒唐日志。
``settle`` 要一直等到连续两帧一样才认。
"""
client, _ = _client()
client._on_contract(_status(True, 99.93))
assert client.settle(settle_s=0.2) == 99.93
# 动作进行中:值一直在变 ⇒ 不到稳定就不返回(这里只验证它会返回**最终**值)
client._on_contract(_status(False, 30.0))
assert client.settle(settle_s=0.2) == 30.0
def test_adopt_current_grip_records_what_is_in_the_jaws_now():
"""基准 = "确认夹住那一刻"的开口;之后状态位再翻也不改参照。"""
client, _ = _client()
client._on_contract(_status(True, 21.9))
assert client.adopt_current_grip("测试") == 21.9
client._on_contract(_status(False, 21.4))
assert client.grip_aperture() == 21.9
assert client.collapse_mm() == 0.5
@@ -0,0 +1,190 @@
"""回归:节点类之间的**回调方法重名**必须被拦住。
为什么值得一条专门的测试(2026-09-24 踩到的真实故障,代价是两轮排查方向全错):
Stage2Locate.__init__ 里建了
create_subscription(WrenchStamped, ft_wrench_topic, self._on_ft, ...)
Stage3Search(子类)里定义了
def _on_ft(self, msg): self._ft = (time.monotonic(), msg)
Python 的方法解析顺序让**子类覆盖父类**,而两个订阅都已经在 __init__ 里
建好了。于是那条 WrenchStamped 订阅的回调实际是子类那个 —— 它把消息塞进
stage3 的 RobotStates 缓存,而父类的 `_ft_fz` 永远不被赋值。
现象:"S07 腕部 FT 没有数据(话题 /sim/ft_sensor_wrench)",
而在 shell 里 `ros2 topic echo` 那条话题**数据完全正常**、
QoS 也完全兼容(实测 BEST_EFFORT 与 RELIABLE 订阅者都收得到)。
于是排查方向被引到"话题名写错 / QoS 不匹配 / 桥接没起来"上,全部走偏。
真机上更阴:两个话题都存在,`self._ft` 里 RobotStates 与 WrenchStamped
交替覆盖 → `_ft_mag()` 时有时无 → 腕部力看门狗时灵时不灵(偶发,最难查)。
规则:**整条继承链上,`_on_*` 形式的方法名必须唯一 —— 除非子类显式
``super().<同名>(...)`` 委托回父类**。后者是"扩展"而不是"遮蔽",是允许的
(``GaugeAssembly._on_color`` 就属于这种:先 ``super()._on_color(msg)`` 存帧,
再补记"收到时刻")。判定标准就是有没有那一次委托调用 ——
没有委托的同名覆盖,一定会让父类的那份状态**永远不更新**,而且不报任何错。
命名空间不够用就改名字(`_on_wrench` / `_on_robot_states` 比 `_on_ft` 好,
后者里那个 "ft" 到底指腕部六维力还是手指触觉,本来就含糊)——
不要靠"反正签名不同"来赌,Python 不检查签名。
为什么用 AST 读源码而不是 import 后反射:
这几个节点模块 import 了 ROS 生成的消息包(``arm_msgs`` 等),
在没有 source 过工作空间的 pytest 环境里 import 不进来。
而这条规则是**纯源码层面**的,不需要真的构造节点。
这也是本仓库既有的做法(见 ``test_gauge_step_events.py``)。
"""
from __future__ import annotations
import ast
import pathlib
from typing import Dict, List
_APP = pathlib.Path(__file__).resolve().parents[1] / "arm_application"
#: 继承链上的源文件。
_CHAIN_FILES = (
"stage2_locate_node.py",
"stage3_search_node.py",
"pipeline_node.py",
"gauge_assembly_node.py",
)
#: 从哪个类开始往上走(装配流程真正实例化的那个)。
_LEAF = "GaugeAssembly"
class _Cls:
"""一个类的:基类名、本类定义的 `_on_*` 方法(名字 → AST 节点)。"""
def __init__(self, name: str, bases: List[str], handlers: Dict[str, ast.AST]) -> None:
self.name = name
self.bases = bases
self.handlers = handlers
def _classes_in(path: pathlib.Path) -> Dict[str, _Cls]:
tree = ast.parse(path.read_text(encoding="utf-8"))
out: Dict[str, _Cls] = {}
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
bases = [b.id for b in node.bases if isinstance(b, ast.Name)]
handlers = {
item.name: item
for item in node.body
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
and item.name.startswith("_on_")
}
out[node.name] = _Cls(node.name, bases, handlers)
return out
def _table() -> Dict[str, _Cls]:
table: Dict[str, _Cls] = {}
for name in _CHAIN_FILES:
table.update(_classes_in(_APP / name))
return table
def _chain() -> List[str]:
"""从 ``_LEAF`` 沿基类往上,返回类名列表(子类在前)。"""
table = _table()
order: List[str] = []
cur = _LEAF
while cur and cur in table:
order.append(cur)
bases = table[cur].bases
cur = bases[0] if bases else ""
assert len(order) >= 3, f"继承链没解析出来(只找到 {order})—— 改过类名?"
return order
def _delegates_to_super(func: ast.AST, name: str) -> bool:
"""``func`` 的方法体里有没有 ``super().<name>(...)`` 这一次委托调用。"""
for node in ast.walk(func):
if not isinstance(node, ast.Call):
continue
callee = node.func
if not (isinstance(callee, ast.Attribute) and callee.attr == name):
continue
owner = callee.value
if (
isinstance(owner, ast.Call)
and isinstance(owner.func, ast.Name)
and owner.func.id == "super"
):
return True
return False
def test_leaf_class_is_reachable_from_every_chain_file():
"""先确认这条测试自己在看对文件 —— 文件改名/移动时别静默变成空跑。"""
chain = _chain()
assert _LEAF in chain
for expected in ("Stage2Locate", "Stage3Search", "AssemblyPipeline"):
assert expected in chain, f"继承链里没有 {expected}(链 = {chain})"
def test_no_silent_callback_shadowing_across_inheritance_chain():
"""同名 `_on_*` 只允许两种情形:唯一,或子类显式 ``super()`` 委托。
挂了怎么修(二选一):
a) 在子类那个方法体里加 ``super().<同名>(msg)`` —— 表示"我是扩展";
b) 改掉其中一个的名字(带来源,如 ``_on_wrench`` / ``_on_robot_states``)。
别什么都不做:父类那份状态会永远不更新,而**不会有任何报错**。
"""
table = _table()
owner: Dict[str, str] = {}
problems: List[str] = []
# _chain() 是"子类在前",所以后出现的才是父类。
for cls_name in _chain():
cls = table.get(cls_name)
if cls is None:
continue
for handler in sorted(cls.handlers):
owner.setdefault(handler, cls_name)
# 再逐个查:凡是有多个类定义了的,子类必须委托。
for handler, owner_cls in owner.items():
definers = [c for c in _chain() if c in table and handler in table[c].handlers]
if len(definers) < 2:
continue
for cls_name in definers:
if cls_name == definers[-1]:
continue # 链最靠上的那个(基类)不需要委托
cls = table[cls_name]
if _delegates_to_super(cls.handlers[handler], handler):
continue
problems.append(
f"{handler}: 定义在 {', '.join(definers)} —— {cls_name} 覆盖了"
f"{owner_cls} 的同名回调,但方法体里没有 super().{handler}(...) 委托。\n"
f" 实际生效的只有 {cls_name} 那个,父类那份状态永远不会更新。"
)
assert not problems, (
"回调被静默遮蔽。症状是「某路数据永远收不到」而话题本身完全正常,"
"极难从现象倒推到这里:\n " + "\n ".join(problems)
)
def test_wrench_and_robot_states_callbacks_are_not_the_same_method():
"""专门钉住这一对名字 —— 它们是最容易被合并成 `_on_ft` 的两个。
腕部 FT(WrenchStamped,100Hz,法兰系)与整机状态(RobotStates,含外部
力/力矩)是两个**不同的**话题、不同的消息类型,缓存也必须是两个字段:
``_ft_fz``(轴向力标量)与 ``_ft``(整帧 RobotStates)。
"""
table = _table()
s2 = set(table["Stage2Locate"].handlers)
s3 = set(table["Stage3Search"].handlers)
assert "_on_wrench" in s2, (
"Stage2Locate 里腕部 FT 的回调应叫 _on_wrench(不能再叫 _on_ft:"
"会被 Stage3Search 的同名方法遮蔽)"
)
assert "_on_robot_states" in s3, "Stage3Search 里整机状态的回调应叫 _on_robot_states"
assert not (s2 & s3), f"两个类还有重名的回调: {sorted(s2 & s3)}"
@@ -566,6 +566,7 @@ def prepare_urdf(
rizon_type: str = "Rizon4",
arm_prefix: str = "sim",
robot_sn: str = "",
wrist_ft_topic: str = "",
) -> Tuple[str, NormalizeReport]:
"""展开 + 归一化,返回 (URDF 文本, 归一化报告)。
@@ -576,6 +577,10 @@ def prepare_urdf(
(SDF 里只存了插件名和参数文件路径,接口声明不在 SDF 里,实测确认)。
所以若 RSP 发的是**未归一化**的那份,控制器注册的关节名就会和 Gazebo 里的
对不上,症状是 "Joint 'sim_joint1' not found in the robot model"。
:param wrist_ft_topic: 腕部 FT 的 gz 话题名。**从 wrist_ft.yaml 传进来**,
而不是让 xacro 里再写一份 —— 话题名有三处要对上(配置、URDF、
应用层按 sn 推出的名字),两处写死迟早漂移。空 = 用 xacro 的默认值。
"""
root = repo_root()
xacro_path = Path(xacro_path) if xacro_path else default_xacro(root)
@@ -590,16 +595,18 @@ def prepare_urdf(
f"缺了会导致 Gazebo 报 'Failed to load system plugin'。"
)
urdf = expand_xacro(
xacro_path,
{
"controllers_file": str(Path(controllers_file).resolve()),
"load_gripper": "true" if load_gripper else "false",
"with_sensors": "true" if with_sensors else "false",
"rizon_type": rizon_type,
"arm_prefix": arm_prefix,
"robot_sn": robot_sn,
},
)
xacro_args = {
"controllers_file": str(Path(controllers_file).resolve()),
"load_gripper": "true" if load_gripper else "false",
"with_sensors": "true" if with_sensors else "false",
"rizon_type": rizon_type,
"arm_prefix": arm_prefix,
"robot_sn": robot_sn,
}
# 只在给了值时才覆盖,否则保持 xacro 里那个有注释、能单独跑 xacro 的默认值。
if wrist_ft_topic:
xacro_args["wrist_ft_topic"] = wrist_ft_topic
urdf = expand_xacro(xacro_path, xacro_args)
normalized, report = normalize_urdf(urdf)
return normalized, report
@@ -0,0 +1,329 @@
"""仿真"抓取锁"(grasp lock):让夹爪夹住的物体在仿真里真的不会滑掉。
=============================================================================
它在链路上的位置
=============================================================================
真机:夹爪 46N 握力 + 指垫摩擦 → 工件被"抱住",机械臂怎么动都不掉。
仿真:夹持力 = 位置误差 × 接触刚度(见 gauge/model.sdf 的 kp 说明)。
规站着不动时够用;一旦 S01 绕工具轴快转 150°、或 pre_lift 走 60~100mm
的连续轨迹,规就在指间打滑(实测工具长度 0.243m → 0.285m、歪 62°),
S07 随后拿到的几何全错 —— 现象是"判不到接触"或"规掉了(E1)"。
本模块做的唯一一件事:**在夹爪确认夹住时,用 DetachableJoint 插件把物体
与手指用一个固定关节锁起来;夹爪张开时解锁。**
这不是"作弊绕过物理":刚性接触求解器给不出静摩擦迟滞,也不模拟指垫橡胶
的贴合,而真实夹持恰恰靠这两件事。锁补的就是这一块。物体与指垫之间的
**接触对仍然存在**,所以触觉、腕部 FT 的读数照旧是真实的接触力。
=============================================================================
为什么用 `ign topic` 命令行而不是 ros_gz_bridge
=============================================================================
触发消息是 `ignition.msgs.Empty`。bridging 一个"空消息"要走
ros_gz_bridge 的 Empty 映射,而这条链路上**没有任何东西需要它出现在 ROS
侧** —— 唯一的目的就是把 gz 的那个插件叫一下。多接一个 bridge 就多一个
"配错了不报错"的点(bridge 类型名对不上时它只是安静地什么也不桥)。
代价是每次触发起一个短命进程(几十毫秒)。触发频率是"每次夹/放一次",
不是每个控制周期 —— 这个代价可以忽略。
=============================================================================
静默失效的四个点(改配置时全都要对上)
=============================================================================
① <child_model> = 机器人 spawn 的 -name(launch 里 rizon_sim)
② <child_link> = Gazebo 里**真实存在**的连杆名(URDF 里与固定关节相连的
连杆会被 sdformat 合并,手指只剩 sim_left_finger_mount)
③ attach/detach 话题名与 gauge/model.sdf 里的插件一致
④ 插件确实写进了 gauge 的 SDF
①②③④ 全都不会自己报错:锁不触发时,现象只是"规莫名掉件"。
所以 :func:`check_grasp_lock_sdf` 把它变成启动时的一条告警/报错。
"""
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
import yaml
#: 相对仓库根的默认配置路径。
DEFAULT_GRASP_LOCK_REL = "devices/sim/gazebo/config/grasp_lock.yaml"
#: 被 lock 的物体模型 SDF 的默认位置(用来做静态核对)。
DEFAULT_OBJECT_SDF_TMPL = "devices/sim/gazebo/models/{object}/model.sdf"
class GraspLockError(RuntimeError):
"""抓取锁配置缺失或写错。消息里必须给出修法(现场就靠这句话)。"""
@dataclass(frozen=True)
class GraspLockSpec:
"""抓取锁的一份完整描述。"""
enabled: bool
object: str
object_link: str
robot_model: str
robot_link: str
attach_topic: str
detach_topic: str
state_topic: str
publish_timeout_ms: int
def describe(self) -> str:
"""给启动日志用的一句话。"""
if not self.enabled:
return "抓取锁:关闭(仿真靠摩擦夹持,机械臂快速运动时可能滑件)"
return (
f"抓取锁:开 → {self.object}.{self.object_link} 锁到 "
f"{self.robot_model}.{self.robot_link}"
f"(attach={self.attach_topic} detach={self.detach_topic})"
)
def load_grasp_lock(
path: Optional[Path] = None, root: Optional[Path] = None
) -> GraspLockSpec:
"""读抓取锁配置。找不到就返回一份"关闭"的默认值(仿真仍能跑)。
**为什么文件缺失不报错**:抓取锁是仿真里的**补救措施**,不是必需品 ——
关掉它只是回到"靠摩擦夹持",多数轻动作仍然能跑。让仿真因为一个可选补救
措施起不来,是拿主流程给可选项陪葬。
但**文件存在而字段写错**时必须报错:那说明有人动过它、而且写错了。
"""
if path is None:
base = root if root is not None else Path.cwd()
path = Path(base) / DEFAULT_GRASP_LOCK_REL
path = Path(path)
if not path.is_file():
return GraspLockSpec(
enabled=False,
object="gauge",
object_link="body",
robot_model="",
robot_link="",
attach_topic="/sim/grasp_lock/attach",
detach_topic="/sim/grasp_lock/detach",
state_topic="/sim/grasp_lock/state",
publish_timeout_ms=3000,
)
try:
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
raise GraspLockError(
f"抓取锁配置 YAML 解析失败: {path}\n 原因: {exc}"
) from exc
if not isinstance(doc, dict):
raise GraspLockError(f"{path} 的顶层应该是映射(key: value 形式)。")
enabled = bool(doc.get("enabled", True))
def _text(key: str, default: str = "") -> str:
return str(doc.get(key, default) or "").strip()
spec = GraspLockSpec(
enabled=enabled,
object=_text("object", "gauge") or "gauge",
object_link=_text("object_link", "body") or "body",
robot_model=_text("robot_model"),
robot_link=_text("robot_link"),
attach_topic=_text("attach_topic", "/sim/grasp_lock/attach"),
detach_topic=_text("detach_topic", "/sim/grasp_lock/detach"),
state_topic=_text("state_topic", "/sim/grasp_lock/state"),
publish_timeout_ms=int(doc.get("publish_timeout_ms", 3000)),
)
if not enabled:
return spec
missing = [
k
for k in ("robot_model", "robot_link", "attach_topic", "detach_topic")
if not getattr(spec, k)
]
if missing:
raise GraspLockError(
f"{path} 开着(enabled: true)但缺字段: {', '.join(missing)}。\n"
f" robot_model 必须等于 launch 里 spawn 机器人的 -name(当前 rizon_sim);\n"
f" robot_link 必须是 Gazebo 里的**真名**(与固定关节相连的连杆会被\n"
f" sdformat 合并,手指只剩 sim_left_finger_mount)。真名查法:\n"
f" ros2 topic echo /sim/world_pose_info ← 看全部 child_frame_id\n"
f" 这两个名字写错的后果是**静默失效**:锁不触发、规照样滑掉,\n"
f" 而日志里只看得到「规掉了」。"
)
if spec.attach_topic == spec.detach_topic:
raise GraspLockError(
f"{path} 的 attach_topic 与 detach_topic 相同({spec.attach_topic})——\n"
f" 那样夹住和放开会走同一条消息,锁的状态完全随机。两个必须不同。"
)
# 锚点不能是"会跟着张爪动的"那根手指 —— 这是 2026-09-24 用一条假 E1 换来的。
if "finger" in spec.robot_link.lower():
raise GraspLockError(
f"{path} 的 robot_link 是 {spec.robot_link} —— **手指**。\n"
f" 被锁的工件会被刚性固定在手指上,而手指会随指令张/合:\n"
f" · S08 棘轮每轮都要\"松爪 → 回转 → 夹紧\",张爪时手指外移几十毫米,\n"
f" 而工件(规)还插在孔里 —— 两处约束打架,物理解算会把工件\n"
f" **从孔里拽出来**(实测:规尖跳 +17.5mm、偏摆 64°),下一步掉地。\n"
f" 日志里只看得到「通止规掉了(E1)」,看不出真因。\n"
f" 修法:填**不跟着张爪动**的连杆。仿真是法兰 `sim_link7`\n"
f" (夹爪本体 sim_grav_base_link 在 sdformat 里被合并进它了)。\n"
f" 真名查法:ros2 topic echo /sim/world_pose_info ← 列全部 child_frame_id"
)
if spec.publish_timeout_ms <= 0:
raise GraspLockError(
f"{path} 的 publish_timeout_ms 必须为正,收到 {spec.publish_timeout_ms}"
)
return spec
def bridge_target_links(spec: GraspLockSpec) -> List[str]:
"""把"锁的两端"整理成一条便于核对/记录的清单(给日志与自检用)。"""
return [f"{spec.object}.{spec.object_link}", f"{spec.robot_model}.{spec.robot_link}"]
def check_grasp_lock_sdf(
sdf_text: str, spec: GraspLockSpec, sdf_label: str = ""
) -> List[str]:
"""静态核对:物体 SDF 里的 DetachableJoint 插件是否与本配置一致。
为什么值得一查(与腕部 FT 的 check 同一个理由):
插件参数写错时 Gazebo **什么都不说** —— 插件照样加载(只要 child_model
找得到),assess 话题照样发,只是关节连不上。现象只有一个:
"规莫名其妙掉了",而那要等到 S07/S08 才显形、且看不到真因。
这里在**启动时**把它变成一句话。
返回告警列表(空 = 没问题);enabled=false 时永远返回空。
"""
if not spec.enabled:
return []
where = f"({sdf_label})" if sdf_label else ""
warnings: List[str] = []
if not sdf_text:
return [f"读不到物体 SDF{where},无法核对抓取锁插件"]
if "DetachableJoint" not in sdf_text:
warnings.append(
f"物体 SDF{where} 里没有 DetachableJoint 插件 —— 抓取锁不会生效。\n"
f" 配置说 enabled: true,但模型里没有插件时**不会报错**,\n"
f" 只是夹住的物体依旧会滑(现象=「规莫名掉了」,看不到真因)。\n"
f" 修法:把插件块补进 {spec.object}/model.sdf,参数照 grasp_lock.yaml。"
)
return warnings
if spec.robot_model and f"<child_model>{spec.robot_model}</child_model>" not in sdf_text:
warnings.append(
f"插件里的 <child_model> 不是 {spec.robot_model} —— "
f"它必须等于 launch 里 spawn 机器人的 -name。\n"
f" 对不上时 DetachableJoint 在 Configure 阶段直接失败,"
f"插件被卸载、{spec.state_topic} 也不会出现(这是唯一的线索)。"
)
if spec.robot_link and f"<child_link>{spec.robot_link}</child_link>" not in sdf_text:
warnings.append(
f"插件里的 <child_link> 不是 {spec.robot_link}。\n"
f" 注意这里要填 Gazebo 的**真名**:URDF 里与固定关节相连的连杆会被\n"
f" sdformat 合并(lump),例如手指只剩 sim_left_finger_mount。\n"
f" 名字写错时插件照样加载、attach 照样「成功」,但关节连到不存在的\n"
f" 连杆上 → 锁不生效。真名查法:ros2 topic echo /sim/world_pose_info"
)
for topic, key in ((spec.attach_topic, "attach_topic"), (spec.detach_topic, "detach_topic")):
if topic and f"<{key}>{topic}</{key}>" not in sdf_text:
warnings.append(
f"插件里的 <{key}> 不是 {topic}。\n"
f" 两侧话题名不一致时,锁的触发消息发到一个没人听的地址上 ——\n"
f" 不报错,只是永远不锁。"
)
return warnings
def _ign_topic_cmd(exe: str, topic: str, timeout_ms: int) -> List[str]:
"""拼一条 `ign topic` 发布命令(空消息)。
⚠️ ``-d``(duration 秒)不是可选的装饰 —— 少了它**大概率丢消息**:
CLI 建立 publisher 与网络里发现订阅者需要一点时间,一条 fire-and-forget
的消息在发现完成前就发完了。曾经写成 ``-w <秒>``(以为它等订阅者),
而这个版本的 ``ign topic`` **没有 -w** —— 参数被当成多余的位置参数:
"The following arguments were not expected: 3"
于是"上锁/解锁"永远失败,而现象和"锁没生效"完全一样(规照样掉)。
"""
return [
exe,
"topic",
"-t", topic,
"-m", "ignition.msgs.Empty",
"-p", "",
"-d", f"{max(0.5, timeout_ms / 1000.0):.2f}",
]
def _find_ign_exe(explicit: Optional[str] = None) -> Optional[str]:
"""找到 ign / gz 命令行。找不到返回 None(调用方决定是告警还是忽略)。"""
import shutil
if explicit:
return explicit
for name in ("ign", "gz"):
found = shutil.which(name)
if found:
return found
return None
def publish_lock_event(
spec: GraspLockSpec,
attach: bool,
exe: Optional[str] = None,
logger=None,
) -> bool:
"""发一条"上锁/解锁"消息。返回是否成功(失败只记日志,不抛)。
**为什么不抛异常**:这不是主流程的一步 —— 它是仿真里的补救措施。
发不出去时正确行为是"继续跑,但明确告警",而不是把整条装配流程打断:
现场宁可看到"锁没生效、规可能滑"的提示,也不要看到"因为锁发不出去,
装配根本没做"。
"""
if not spec.enabled:
return False
topic = spec.attach_topic if attach else spec.detach_topic
what = "上锁" if attach else "解锁"
exe_path = _find_ign_exe(exe)
if exe_path is None:
if logger is not None:
logger.warn(
f"抓取锁{what}失败:找不到 ign/gz 命令行(PATH 里没有)。\n"
f" 后果:夹住的物体在机械臂连续运动时可能滑掉(现象=「规掉了」)。"
)
return False
cmd = _ign_topic_cmd(exe_path, topic, spec.publish_timeout_ms)
try:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=max(2.0, spec.publish_timeout_ms / 1000.0 + 2.0),
)
except (OSError, subprocess.SubprocessError) as exc:
if logger is not None:
logger.warn(f"抓取锁{what}失败({topic}):{exc}")
return False
if proc.returncode != 0:
out = (proc.stdout or b"").decode("utf-8", "replace").strip()
if logger is not None:
logger.warn(
f"抓取锁{what}失败({topic},退出码 {proc.returncode}):{out[:200]}"
)
return False
if logger is not None:
logger.info(f"抓取锁{what}:{topic}")
return True
@@ -63,6 +63,14 @@ RViz/TF 与 Gazebo 就一致(详见 urdf_normalize.py 里那段说明)。
- 但**反馈绝不能取它**,否则 status 永远报"开口 0 mm"而夹爪明明开着。
适配器改用"与主关节直接相连、|multiplier| 最大的那个从动关节"反推开口
(见 GripperLayout._pick_feedback / aperture_from_feedback)。
5) **仿真里的夹持靠不住,需要一把"抓取锁"**。仿真的夹持力不是设出来的,
而是位置误差 × 接触刚度:规站着不动够用,机械臂一走连续轨迹(S01 快转
150°、pre_lift 60~100mm)就在指间打滑 —— 实测工具长度 0.243m → 0.285m、
规歪 62°,S07 随后拿到的几何全错。真机不会(46N 握力 + µ≈0.8 + 0.27kg)。
所以本节点在**确认夹住之后**用 DetachableJoint 把物体固定到手指上,
**指令要求张开时**解锁(详见 sim_grasp_lock.py;为什么不拿 is_gripped
当解锁条件,见 :meth:`_sync_grasp_lock`)。
"""
from __future__ import annotations
@@ -77,6 +85,7 @@ from arm_msgs.msg import GripperStatus
from arm_msgs.srv import GripperExecute
from sensor_msgs.msg import JointState
from .sim_grasp_lock import GraspLockSpec, publish_lock_event
from .sim_gripper import (
DEFAULT_CONTROLLER,
DEFAULT_CONTROLLERS_REL,
@@ -142,6 +151,13 @@ class SimGripperAdapter(Node):
self.declare_parameter("tolerance_mm", DEFAULT_GRASP_TOLERANCE_MM) # 夹住判据容差(毫米开口)
self.declare_parameter("default_speed_pct", 50.0)
self.declare_parameter("default_force_pct", 20.0)
# 仿真抓取锁(见 sim_grasp_lock.py)。默认**关**:参数化之后,launch 才是
# 唯一决定它开不开的地方 —— 单跑本节点做调试时不该莫名其妙去动 Gazebo。
self.declare_parameter("grasp_lock_enabled", False)
self.declare_parameter("grasp_lock_object", "gauge")
self.declare_parameter("grasp_lock_attach_topic", "/sim/grasp_lock/attach")
self.declare_parameter("grasp_lock_detach_topic", "/sim/grasp_lock/detach")
self.declare_parameter("grasp_lock_timeout_ms", 3000)
controller = str(self.get_parameter("controller").value or DEFAULT_CONTROLLER)
raw_ctrl = str(self.get_parameter("controllers_file").value or "")
@@ -214,6 +230,34 @@ class SimGripperAdapter(Node):
# 先把夹爪摆到默认全开,免得机器人一出场就是捏着的状态。
self._publish_target(self._target)
# 抓取锁:规格从参数来(launch 从 grasp_lock.yaml 派生),状态用三态:
# None = 不知道 / False = 已解锁 / True = 已上锁
# 之所以要 None 而不是 bool + 计数器:发布失败时要能**重试**,
# 而"发布失败"恰恰表现为"发出去没人听"(话题名错、进程起不来),
# 只有把"未确认"单独表示出来才可能重试。
self._lock = GraspLockSpec(
enabled=bool(self.get_parameter("grasp_lock_enabled").value),
object=str(self.get_parameter("grasp_lock_object").value or "gauge"),
object_link="",
robot_model="",
robot_link="",
attach_topic=str(self.get_parameter("grasp_lock_attach_topic").value or ""),
detach_topic=str(self.get_parameter("grasp_lock_detach_topic").value or ""),
state_topic="",
publish_timeout_ms=int(self.get_parameter("grasp_lock_timeout_ms").value),
)
self._locked: Optional[bool] = None
if self._lock.enabled:
self.get_logger().info(
f"抓取锁已启用:夹住后把 {self._lock.object} 用固定关节锁到手指上,"
f"张开时解锁(attach={self._lock.attach_topic})。\n"
" 它补的是 Gazebo 缺的那块物理(刚性接触没有静摩擦迟滞、也不模拟"
"指垫贴合)——\n"
" 真机 46N 握力 + µ≈0.8 本来就夹得住,仿真的位置控制夹持则会在"
"连续运动时打滑。"
)
self.create_timer(0.1, self._tick_status)
# --- 参数与描述 -------------------------------------------------------
@@ -311,7 +355,43 @@ class SimGripperAdapter(Node):
return msg
def _tick_status(self) -> None:
self._pub_status.publish(self._status_msg())
msg = self._status_msg()
self._pub_status.publish(msg)
self._sync_grasp_lock(bool(msg.is_gripped))
# --- 抓取锁(仿真专用)-------------------------------------------------
def _sync_grasp_lock(self, gripped: bool) -> None:
"""夹持状态 → 抓取锁。只在"确认夹住"时上锁,绝不自动解锁。
**为什么不在这里因为"判到没夹住"而解锁**:
本适配器的 ``is_gripped`` 是**推断值**(指令到合拢位 + 实际开口被顶住),
抓取锁要解决的恰恰是"规在指间打了滑"的情形 —— 打滑会让触觉接触
闪断、``is_gripped`` 跟着抖。若拿它当解锁条件,就会得到
"一抖就解锁 → 规真掉下去 → 更读不到夹持"的自毁循环。
解锁只认**指令**(open / 开口变大的 move),那才是松手的真实原因。
"""
spec = self._lock
if not spec.enabled or not gripped or self._locked is True:
return
if publish_lock_event(spec, True, logger=self.get_logger()):
self._locked = True
self.get_logger().info(
f"抓取锁:已把 {spec.object} 锁到手指上(此后机械臂怎么动都不会滑)。"
)
else:
# 失败不抛异常(锁只是补救措施),但要留痕:现象是"规掉了"而看不到真因。
self._locked = None
def _release_grasp_lock(self, why: str) -> None:
"""松开抓取锁。只在**指令明确要张开**时调用。"""
spec = self._lock
if not spec.enabled or self._locked is False:
return
if publish_lock_event(spec, False, logger=self.get_logger()):
self._locked = False
self.get_logger().info(f"抓取锁:已解锁({why})")
else:
self._locked = None
# --- 服务 -------------------------------------------------------------
def _make_handler(self, verb: str):
@@ -346,6 +426,7 @@ class SimGripperAdapter(Node):
cmd = request.command
try:
if verb == "open":
self._release_grasp_lock("收到 open 指令")
self._move_to_aperture(self.layout.stroke_mm)
note = f"全开({self.layout.stroke_mm:.1f} mm)"
elif verb == "close":
@@ -354,6 +435,16 @@ class SimGripperAdapter(Node):
elif verb == "move":
want = float(cmd.position)
clamped = self.layout.is_clamped(want)
# 「开口变大」才叫松手。判据用**实测开口**而不是上一次指令:
# 指令可以是"先夹到 30 再挪到 32"这种,比指令会误判成开爪。
state = self._fresh_state()
now_mm = (
self.layout.aperture_from_feedback(state)
if state is not None
else None
)
if now_mm is not None and want > now_mm + self._tolerance_mm:
self._release_grasp_lock(f"开口 {now_mm:.1f} → {want:.1f} mm")
self._move_to_aperture(want)
note = f"开口 → {want:.2f} mm"
if clamped:
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""仿真里"物体**现在到底在哪**"的唯一来源:``/world/<world>/pose/info``。
为什么要有这个模块(2026-09-24 的教训,别再让真值按名义位姿算)
----------------------------------------------------------------
真值路径(``perception.source=truth``)最初是**按 scene.yaml 的名义位姿**算目标点
与孔位的 —— 也就是它假设"物体没动过"。实测这个假设站不住:
1. 物体是"在名义位姿上方 spawn、再落下来"的,**落地就偏 2~3mm**(实测 2.6mm);
2. 工件骑在台面那 36mm 通槽上,静置中还会缓慢蠕动(实测十几分钟里又挪了 1.9mm)。
而 2mm 的位姿误差在 S05 的像素上约 5px,在 S06 对孔上就是**规尖(φ6)进不了
φ7 的孔** —— 现象是"S07 压到工件面、但孔没进去",接着 S08 一转就掉件。
现场看起来像"柔顺参数不对/夹持力不够",其实几何从一开始就指错了两毫米。
所以:**真值就该用实测位姿**,名义位姿只用来做"物体是不是被搬走了/掉下去了"的
告警。这件事被两个地方需要(真值感知的目标点、二阶段定位的孔位),
所以实现只放这一份(R5.8)。
⚠️ 实测位姿是 Gazebo 的**真值**(不是测量),所以"按实测位姿算"比"按配置算"
更接近真机上的视觉:真机也是量到哪就是哪。
"""
from __future__ import annotations
import math
from dataclasses import replace
from typing import Any, Dict, Iterable, Mapping, Optional, Tuple
from arm_sim_gazebo import sim_scene
#: 位姿(名 → ``(x, y, z, yaw)``,单位米/弧度)。缺的物体不在字典里。
LivePoses = Dict[str, Tuple[float, float, float, float]]
def yaw_from_quat_xyzw(quat: Iterable[float]) -> float:
"""四元数 ``(x, y, z, w)`` → 绕 Z 的 yaw [rad]。
四元数→矩阵**不在这里实现**:复用全仓库唯一那份 ``handeye_lib.quat_xyzw_to_R``
(它有回环测试钉着)。重复实现的下场见 ``sim_truth_perception`` 的模块 docstring。
"""
from arm_application.handeye_lib import quat_xyzw_to_R
R = quat_xyzw_to_R(list(quat))
return math.atan2(float(R[1, 0]), float(R[0, 0]))
def poses_from_tf(msg: Any) -> LivePoses:
"""``/world/<world>/pose/info``(``tf2_msgs/TFMessage``)→ 名 → ``(x,y,z,yaw)``。
每个模型的 ``child_frame_id`` 就是模型名(ros_gz_bridge 的约定)。
**纯函数**(只要求对象有 ``transforms``,不依赖 ROS 类型):这条解析错的表现是
"实测位姿永远是空的",于是真值退回名义位姿 —— 又是那个两毫米的错,
没有任何一处日志会提。所以它必须能脱离节点被钉住。
"""
out: LivePoses = {}
for tr in getattr(msg, "transforms", None) or []:
name = str(getattr(tr, "child_frame_id", "") or "").strip().lstrip("/")
if not name:
continue
t = tr.transform.translation
q = tr.transform.rotation
out[name] = (
float(t.x),
float(t.y),
float(t.z),
yaw_from_quat_xyzw((float(q.x), float(q.y), float(q.z), float(q.w))),
)
return out
def spec_with_live_poses(
spec: sim_scene.SceneSpec,
poses: Mapping[str, Tuple[float, float, float, float]],
names: Optional[Iterable[str]] = None,
) -> sim_scene.SceneSpec:
"""把场景**副本**里物体的位姿换成实测值;查不到实测位姿的物体保持原样。
为什么只换位姿、不换别的:``SceneSpec`` 里的几何(模型形状、孔环位置、
台面)都是配置里的事实,只有"物体摆在哪"是会变的。
``names`` 给定时只换这几个(真值只依赖工件,没必要动别的)。
返回**副本**(``dataclasses.replace``):调用方别把"实测位姿"写回配置,
否则名义位姿就永远消失了,而它正是判断"有没有被搬走"的基准。
"""
want = None if names is None else {str(n) for n in names}
out = []
changed = 0
for obj in spec.objects:
p = poses.get(obj.name)
if p is None or (want is not None and obj.name not in want):
out.append(obj)
continue
out.append(replace(obj, x=float(p[0]), y=float(p[1]), z=float(p[2]), yaw=float(p[3])))
changed += 1
if changed == 0:
return spec
return replace(spec, objects=out)
class LiveScenePose:
"""订阅世界模型位姿,随时给出"物体现在在哪"。仿真专有。
用法(两个节点都一样,所以放在这里而不是各写一份)::
live = LiveScenePose(self, topic) # topic 来自 scene.yaml 的 pose_info
spec_live = live.apply(spec, [obj_name]) # 换上位姿的副本
if live.ready: ... # 收到过帧没有
没配话题 / 没收到帧时 ``apply`` 原样返回 —— 调用方仍能用名义位姿干活,
但必须**说清楚**(``LiveScenePose.why_not``),否则"退回名义位姿"这件事
又会变成没有人知道的静默行为。
"""
def __init__(self, node: Any, topic: str, qos_depth: int = 10) -> None:
self._node = node
self._topic = str(topic or "").strip()
self._poses: LivePoses = {}
self._seen = 0
self._sub = None
if not self._topic:
return
from tf2_msgs.msg import TFMessage
self._sub = node.create_subscription(TFMessage, self._topic, self._on_msg, qos_depth)
def _on_msg(self, msg: Any) -> None:
got = poses_from_tf(msg)
if got:
self._seen += 1
self._poses.update(got)
@property
def topic(self) -> str:
return self._topic
@property
def ready(self) -> bool:
"""收到过至少一帧(一帧都没有说明话题名/桥接有问题,不是"物体没动")。"""
return self._seen > 0
def pose(self, name: str) -> Optional[Tuple[float, float, float, float]]:
return self._poses.get(name)
def apply(
self,
spec: sim_scene.SceneSpec,
names: Optional[Iterable[str]] = None,
) -> sim_scene.SceneSpec:
return spec_with_live_poses(spec, self._poses, names)
def why_not(self, name: str) -> str:
"""给"为什么还在用名义位姿"一个能直接贴进日志的句子。"""
if not self._topic:
return "scene.yaml 的 pose_info 未配(没有世界模型位姿话题)"
if not self.ready:
return f"话题 {self._topic} 一帧都没收到(桥接没起来?话题名/世界名对不上?)"
return f"实测位姿里没有物体 {name!r}(模型名与 scene.yaml 的 name 不一致?)"
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""把 Gazebo 里的模型**搬到指定位姿**(`/world/<world>/set_pose`)。
谁在用
------
* ``scripts/sim_load_gauge.py``:加载通止规前把场景物体复位;
* ``arm_sim_gazebo/sim_truth_perception.py``:启动时把真值依赖的工件复位
(见那里的 `_maybe_startup_reset`)。
**实现只有这一份**:这个请求字符串以前抄过一遍,而它有一个很坑的细节
(下面 `--reqtype` 的注释),第二份抄错的人不会知道。
为什么走命令行 `ign service` 而不是 ROS 服务桥
--------------------------------------------
仿真的场景加载本来就是用命令行 `ros_gz_sim create` 做的(见 `sim_scene.py`),
口径一致、少一个需要维护的服务桥;而且 `ros_gz_bridge` 对**服务**的桥接需要
额外配置,桥不上时的表现是"服务一直在、调用永远超时"。
"""
from __future__ import annotations
import math
import re
import subprocess
from typing import Optional, Sequence, Tuple
#: Gazebo 侧的服务等待超时 [ms]。
SERVICE_TIMEOUT_MS = 3000
#: 命令行整体超时 [s](要大于上面那个,否则会被外面先掐掉)。
CMD_TIMEOUT_S = 10.0
def world_from_gz_topic(topic: str) -> Optional[str]:
"""从 ``/world/<world>/pose/info`` 里取出 world 名;取不到返回 ``None``。
world 名必须与 ``worlds/assembly.world`` 的文件名一致,而 scene.yaml 里
已经写着那条话题了 —— 所以**不要**再单独配一个 world 名参数:
两份写法迟早不一致,而症状是"服务调用超时"(看起来像 Gazebo 没起来)。
"""
m = re.fullmatch(r"/world/([^/]+)/pose(?:/info)?/?", (topic or "").strip())
return m.group(1) if m else None
def yaw_to_quat(yaw_rad: float) -> Tuple[float, float, float, float]:
"""绕 Z 的 yaw [rad] → 四元数 ``(x, y, z, w)``。
只支持绕 Z:场景里的物体位姿本来就是"平面位姿 + yaw"(``SceneObject``
也只有 yaw)—— 这样写反而不会被"顺手支持一下全姿态"拖进来一堆没测过的路径。
"""
return (0.0, 0.0, math.sin(float(yaw_rad) / 2.0), math.cos(float(yaw_rad) / 2.0))
def _run(cmd: Sequence[str], timeout: float = CMD_TIMEOUT_S) -> Tuple[int, str]:
"""跑一条外部命令,返回 ``(退出码, 合并输出)``。**不抛异常**(调用方自己判)。"""
try:
p = subprocess.run(list(cmd), capture_output=True, text=True, timeout=timeout, check=False)
return p.returncode, (p.stdout or "") + (p.stderr or "")
except FileNotFoundError:
return 127, f"找不到可执行文件:{cmd[0]}"
except subprocess.TimeoutExpired:
return 124, f"命令超时({timeout}s):{' '.join(cmd)}"
def set_model_pose(
world: str,
name: str,
pos: Sequence[float],
quat: Sequence[float],
timeout_s: float = CMD_TIMEOUT_S,
) -> Tuple[bool, str]:
"""把模型 ``name`` 搬到世界位姿 ``pos`` / ``quat``(四元数 xyzw)。
返回 ``(成功?, 输出)``。**失败只返回 False,不抛异常**:调用方拿到的场景
可能只是"少了一个可选物体",因为一个失败的复位让整个流程起不来不值得。
注意 ``--reqtype`` 必须写 ``ignition.msgs.Pose``:本环境实测写
``gz.msgs.Pose`` 会报 "Unable to create request",一个字母的差别、
报错信息完全没提原因。
"""
req = (
f'name: "{name}", '
f"position: {{x: {float(pos[0]):.6f}, y: {float(pos[1]):.6f}, z: {float(pos[2]):.6f}}}, "
"orientation: {"
f"x: {float(quat[0]):.8f}, y: {float(quat[1]):.8f}, "
f"z: {float(quat[2]):.8f}, w: {float(quat[3]):.8f}"
"}"
)
code, out = _run(
[
"ign", "service",
"-s", f"/world/{world}/set_pose",
"--reqtype", "ignition.msgs.Pose",
"--reptype", "ignition.msgs.Boolean",
"--timeout", str(int(SERVICE_TIMEOUT_MS)),
"--req", req,
],
timeout=timeout_s,
)
ok = code == 0 and "data: true" in out
return ok, out.strip()
def set_model_pose_yaw(
world: str,
name: str,
pos: Sequence[float],
yaw_rad: float,
timeout_s: float = CMD_TIMEOUT_S,
) -> Tuple[bool, str]:
"""``set_model_pose`` 的平面版:只有 yaw。"""
return set_model_pose(world, name, pos, yaw_to_quat(yaw_rad), timeout_s)
@@ -94,6 +94,36 @@ class Operating:
part_object: str = "workpiece"
@dataclass(frozen=True)
class PoseInfo:
"""Gazebo 世界模型位姿回读的桥接参数 + "物体被撞走"的判据。
为什么要有它:``perception.source=truth`` 是**纯几何**的 —— 它按本文件的
名义位姿算孔位,**不去问 Gazebo 物体现在到底在哪**。物体一旦被碰走,
"真值"就开始撒谎,而每一处现象都指向别的原因(详见 scene.yaml 里
``pose_info`` 那段注释)。这个结构就是让仿真能把"名义 vs 实际"对上。
"""
gz_topic: str
ros_topic: str
ros_type: str = "tf2_msgs/msg/TFMessage"
gz_type: str = "gz.msgs.Pose_V"
#: 部件相对名义位姿的平移超过它 [m] 就算"被搬走了" → 告警(**但仍然发点**,
#: 因为真值几何已经跟着实测位姿走,见 sim_live_pose)。
drift_tol_m: float = 0.002
#: 同上,姿态 [deg]。
drift_tol_deg: float = 2.0
#: 平移超过它才**停发**真值点 [m]。这个量级说明物体掉了/被搬走了,
#: 场景前提没了(装配没有意义)。2mm 那个量级天天都有(spawn 落地 +
#: 工件在通槽上蠕动),拿它当停发线会把监视器变成"每轮都误报的假故障"。
drift_stop_m: float = 0.02
#: 启动时把被盯的物体**实际搬回**名义位姿(仿真专有,默认关)。
#: 为什么需要、为什么只在启动窗口内做,见 ``sim_truth_perception`` 的
#: ``_maybe_startup_reset``(一句话:spawn 落地会偏 2~3mm,那不是"被撞走",
#: 但看门狗分不出来;而运行中的漂移必须报错、不能悄悄搬回去)。
reset_on_start: bool = False
@dataclass
class SceneSpec:
"""scene.yaml 的解析结果。"""
@@ -105,6 +135,8 @@ class SceneSpec:
objects: List[SceneObject] = field(default_factory=list)
# 特征名 → (下限, 上限) 像素门槛。没写 = 不设门槛(只报告不判定)。
gates_px: Dict[str, Tuple[float, float]] = field(default_factory=dict)
# 世界模型位姿回读。**没写 = 不做漂移监视**(老场景文件仍然能读)。
pose_info: Optional[PoseInfo] = None
source: Optional[Path] = None
@@ -229,10 +261,69 @@ def load_scene(path: Optional[Path] = None, root: Optional[Path] = None) -> Scen
operating=operating,
objects=objects,
gates_px=_parse_gates(p, doc.get("gates_px")),
pose_info=_parse_pose_info(p, doc.get("pose_info")),
source=p,
)
def _parse_pose_info(path: Path, raw: object) -> Optional[PoseInfo]:
"""解析 pose_info。**没写就返回 None**(= 不做漂移监视)。
与 gates_px 不同,这一段是**可选**的:老场景文件没有它也必须能读。
但只要写了,就必须写全 —— 少了话题名会得到一个"订阅了不存在的话题、
永远没有数据"的监视器,那比没有监视更糟(看起来"监视器没报警")。
"""
if raw is None:
return None
if not isinstance(raw, dict):
raise SceneError(
f"{path} 的 pose_info 不是映射。期望形如:\n"
f" pose_info:\n"
f" gz_topic: /world/assembly/pose/info\n"
f" ros_topic: /sim/world_pose_info"
)
missing = [k for k in ("gz_topic", "ros_topic") if not str(raw.get(k, "")).strip()]
if missing:
raise SceneError(
f"{path} 的 pose_info 缺字段 {missing}。\n"
f" 这两个话题名必须都写:gz_topic 是 Gazebo 侧的 "
f"/world/<world>/pose/info,ros_topic 是桥过来之后的名字。"
)
try:
tol_m = float(raw.get("drift_tol_m", 0.002))
tol_deg = float(raw.get("drift_tol_deg", 2.0))
stop_m = float(raw.get("drift_stop_m", 0.02))
except (TypeError, ValueError) as exc:
raise SceneError(f"{path} 的 pose_info 容差不是数字: {exc}") from exc
if stop_m <= tol_m:
# 停发线必须**明显大于**告警线,否则"告警"永远和"停发"一起出现,
# 而现实里 2~3mm 的偏差天天有(spawn 落地 + 工件在通槽上蠕动)——
# 那会让每一轮开局都被假故障拦死,现场学会忽略告警,监视器就废了。
raise SceneError(
f"{path} 的 pose_info.drift_stop_m={stop_m} 必须大于 drift_tol_m={tol_m}。\n"
f" drift_tol_m 是**告警**线(几何已按实测算,不影响发点),"
f"drift_stop_m 是**停发**线(物体掉了/被搬走了)。"
)
reset = raw.get("reset_on_start", False)
if not isinstance(reset, bool):
# 不做 `bool(reset)` 强转:YAML 里 `reset_on_start: "false"` 是**真值**,
# 而这类错误的表现是"开关关了却没关",排查时没人会去怀疑引号。
raise SceneError(
f"{path} 的 pose_info.reset_on_start 不是布尔量(收到 {reset!r})。\n"
f" 写 true / false;不要加引号(\"false\" 在 YAML 里是真值)。"
)
return PoseInfo(
gz_topic=str(raw["gz_topic"]).strip(),
ros_topic=str(raw["ros_topic"]).strip(),
ros_type=str(raw.get("ros_type") or "tf2_msgs/msg/TFMessage").strip(),
gz_type=str(raw.get("gz_type") or "gz.msgs.Pose_V").strip(),
drift_tol_m=tol_m,
drift_tol_deg=tol_deg,
drift_stop_m=stop_m,
reset_on_start=reset,
)
def _parse_gates(path: Path, raw: object) -> Dict[str, Tuple[float, float]]:
"""解析 gates_px。写成 {名字: [下限, 上限]}。
@@ -35,6 +35,24 @@ torch/ultralytics,而且这台机器**离线**装不上,于是 perception_no
* 目标在相机背后 / 相机查不到 TF 时**不发假点**(和 perception_node 的
"没有可用深度就不发点"同一条规矩)。宁可让伺服看门狗超时,也不给它一个
幻影目标 —— 后者会让机械臂朝错误方向走,而且看起来像"标定坏了"。
* **物体被撞走了就不再发点**。真值感知的整个前提是"物体就在 scene.yaml 写的位置",
前提破了,算出来的就是**假测量**。所以本节点自己订阅 ``/world/<world>/pose/info``
(scene.yaml 的 ``pose_info`` 段)盯着部件的实际位姿,偏离名义位姿超过容差就停下
并**指名报错**。
为什么必须由这一侧盯(2026-09-24 的教训):``perception.source=truth`` 是纯几何的,
它从不问 Gazebo 物体现在在哪。实测工件掉进台面通槽、落到地面之后,"真值"开始撒谎,
而现象全都指向别处(二阶段报"两孔间距 169mm 不像 k1/k2"、S07 一路下压不接触),
看起来像检测/标定/阈值坏了 —— 真正的原因只有一个,却没有任何一处日志说出来。
**谁假设的,谁负责查。**
* **开局那一次例外**(scene.yaml 的 ``pose_info.reset_on_start``):物体是"在名义位姿
上方 spawn、再落下来"的,落稳后实测偏 2.6mm(越过 2mm 容差)—— 那不是"被撞走",
但看门狗分不出来,于是每轮开局都可能被这条假故障拦死。所以在**开局窗口内**
(``STARTUP_RESET_WINDOW_S``)把它 set_pose 回名义位姿,只做一次;
过了窗口再漂就是运行中被撞,必须报错停下、**绝不**搬(见
``startup_reset_decision`` 与 ``_maybe_startup_reset``)。
* 不自己算四元数→旋转矩阵(复用 ``handeye_lib.quat_xyzw_to_R``)。
这段换算以前在 4 个 launch 文件里各抄了一遍、错了 3 处,症状是
"总能到附近、永远差一点"。数学实现只能有一份。
* 不自己算四元数→旋转矩阵(复用 ``handeye_lib.quat_xyzw_to_R``)。
这段换算以前在 4 个 launch 文件里各抄了一遍、错了 3 处,症状是
"总能到附近、永远差一点"。数学实现只能有一份。
@@ -45,7 +63,7 @@ from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Sequence, Tuple
from typing import Dict, Optional, Sequence, Tuple
import numpy as np
import rclpy
@@ -54,8 +72,9 @@ from rclpy.node import Node
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from rclpy.time import Time
from tf2_ros import Buffer, TransformListener
from tf2_msgs.msg import TFMessage
from arm_sim_gazebo import sim_scene
from arm_sim_gazebo import sim_live_pose, sim_pose_service, sim_scene
#: 与 perception_node / servo_node 一致:点话题用 BEST_EFFORT + KEEP_LAST(5)。
#: 匹配不上 QoS 的后果是"话题在、消息发了、订阅方一条也收不到",
@@ -69,6 +88,12 @@ POINT_QOS = QoSProfile(
#: 目标点离相机光心太近(或跑到背后)就不发:z<=0 在相机系里是"背后"。
MIN_CAM_Z_M = 0.01
#: 启动复位的**时间窗口** [s]:只在这段时间内把工件搬回名义位姿。
#: 过了这段时间还漂 = 运行中被撞走/被压走,那时**绝不能**悄悄搬回去
#: (机器人可能正握着它,搬回去等于把现场证据抹掉并让真值开始撒谎),
#: 必须照旧报错停下。整个理由见 ``_maybe_startup_reset``。
STARTUP_RESET_WINDOW_S = 20.0
class TruthError(RuntimeError):
"""真值目标解不出来(场景 / 模型 / 参数有问题)。
@@ -78,6 +103,42 @@ class TruthError(RuntimeError):
"""
#: ``startup_reset_decision`` 的决定:不动 / 再等等 / 现在搬 / 放弃。
RESET_SKIP = "skip"
RESET_WAIT = "wait"
RESET_NOW = "reset"
RESET_GIVE_UP = "give_up"
def startup_reset_decision(
now_s: float,
deadline_s: Optional[float],
window_s: float,
pose_known: bool,
any_drift: bool,
) -> Tuple[str, Optional[float]]:
"""开局"要不要把物体搬回名义位姿"的判据 → ``(决定, 新的截止时刻)``。
**纯函数**:写错的表现两头都很糟 ——
* 判成"运行中也搬":把"谁把它撞歪了"的现场抹掉,甚至把工件从爪里拽走,
现象会变成"孔位对不上/下压不接触",比原故障更难查;
* 判成"永远等":开局那 2.6mm 落地偏差每轮都把真值点拦死,
S04 报「一个视觉目标点都没收到」,看起来像真值节点根本没起来。
所以它必须能脱离节点被钉住(``test_sim_truth_perception.py``)。
"""
dl = float(deadline_s) if deadline_s is not None else float(now_s) + float(window_s)
if now_s > dl:
return RESET_GIVE_UP, dl
if not pose_known:
# 还没收到 pose/info 帧:此时"有没有漂移"根本无从判断,
# 等下去(窗口到点会自己变成 give_up)。
return RESET_WAIT, dl
if not any_drift:
return RESET_SKIP, dl
return RESET_NOW, dl
@dataclass(frozen=True)
class TruthTarget:
"""真值目标:一个**世界系**点,以及它是怎么算出来的(给人看)。"""
@@ -190,6 +251,56 @@ def matrix_from_transform(tr) -> np.ndarray:
return T
def pose_deviation(
obj: sim_scene.SceneObject,
p: Sequence[float],
quat: Sequence[float],
) -> Tuple[float, float, float]:
"""实际位姿相对名义位姿的偏差 → ``(平移 [m], 姿态夹角 [deg], 实测 yaw [rad])``。
**纯函数**:这条判据搞错了的表现是"监视器静默失声" ——
要么永远不报(物体明明被撞走了却没人说),要么天天误报(现场学会忽略它)。
两种情况都比没有监视更糟,所以它必须能脱离节点被钉住。
"""
from arm_application.handeye_lib import quat_xyzw_to_R
d = math.dist((float(obj.x), float(obj.y), float(obj.z)), [float(v) for v in p[:3]])
R_now = quat_xyzw_to_R([float(v) for v in quat])
yaw_now = math.atan2(float(R_now[1, 0]), float(R_now[0, 0]))
# 姿态用**整段旋转差**的夹角,不只比 yaw:板被碰一下往往是"翘起来夹着转了一点",
# 只比 yaw 会漏掉翘起,而翘起恰恰最影响孔的成像位置。
c, s = math.cos(float(obj.yaw)), math.sin(float(obj.yaw))
R_ref = np.array([[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]])
cosang = (float(np.trace(R_ref.T @ R_now)) - 1.0) / 2.0
ang = math.degrees(math.acos(max(-1.0, min(1.0, cosang))))
return (d, ang, yaw_now)
def pose_drift(
obj: sim_scene.SceneObject,
p: Sequence[float],
quat: Sequence[float],
tol_m: float,
tol_deg: float,
) -> Optional[str]:
"""偏差超过容差时的**说明文字**;在容差内返回 ``None``(= 没漂移)。
给日志/告警用的人话版;数值判据在 :func:`pose_deviation`(同一个实现,
免得两处各算一遍距离)。
"""
d, ang, yaw_now = pose_deviation(obj, p, quat)
if d <= tol_m and ang <= tol_deg:
return None
return (
f"名义 (x,y,z)=({obj.x:.4f},{obj.y:.4f},{obj.z:.4f}) "
f"yaw={math.degrees(float(obj.yaw)):.1f}°\n"
f" 实际 (x,y,z)=({p[0]:.4f},{p[1]:.4f},{p[2]:.4f}) "
f"yaw={math.degrees(yaw_now):.1f}°\n"
f" 偏差 |Δp|={d * 1000:.1f}mm、姿态差 {ang:.1f}°"
f"(容差 {tol_m * 1000:.1f}mm / {tol_deg:.1f}°)"
)
class SimTruthPerception(Node):
"""把真值目标点按固定频率发到 ``visual_servo/filtered_point``。"""
@@ -214,9 +325,15 @@ class SimTruthPerception(Node):
self.declare_parameter("publish_hz", 20.0)
"""发布频率:不必跟着相机帧率,伺服 100Hz 控制会自己插值/外推。"""
self.declare_parameter("pose_info_topic", "")
"""世界模型位姿话题(``tf2_msgs/TFMessage``)。
空 = 用 scene.yaml 的 ``pose_info.ros_topic``;场景里也没配 = 真值**按名义
位姿**算(几何会偏,且没有"物体被搬走"的告警)。"""
scene_file = str(self.get_parameter("scene_file").value or "").strip()
self._scene_file = Path(scene_file) if scene_file else None
offset = self.get_parameter("offset_m").value or [0.0, 0.0, 0.0]
self._offset = offset = self.get_parameter("offset_m").value or [0.0, 0.0, 0.0]
# 长度的校验在 target_point_world 里(纯函数,有测试钉着),这里不重复。
self._camera_frame = str(self.get_parameter("camera_frame").value or "").strip()
if not self._camera_frame:
@@ -231,6 +348,9 @@ class SimTruthPerception(Node):
# 目标点在启动时算一次就够:场景是静态的(工件被推动后位置会变,
# 但那种情况下"真值"本身也就失效了 —— 此时应该重跑 gen_sim_handeye
# 或重启,而不是悄悄跟着一个被撞歪的位姿走)。
# ⚠️ 这句话在 2026-09-24 之前只是**注释**:工件真的被碰走了,没有任何人
# 发现(详见 scene.yaml 的 pose_info 段)。现在由 _setup_drift_watch
# 把它变成**运行时的判据**:一旦对上不,立刻停发并指名报错。
self._spec, self._target = load_truth_target(
self._scene_file,
str(self.get_parameter("target_object").value or ""),
@@ -244,6 +364,17 @@ class SimTruthPerception(Node):
TransformListener(self._tf, self, spin_thread=True)
self._sent = 0
self._warned_tf = 0
self._warned_drift = 0
self._t_start: Optional[float] = None
# 目标点按**实测**位姿算(见模块 docstring 与 sim_live_pose),
# 位姿没变就不重算(算一次要读模型 SDF,20Hz 下不能每拍都读)。
self._target_live: Optional[TruthTarget] = None
self._target_key: Optional[Tuple[float, ...]] = None
self._warned_nominal = 0
# 启动复位(scene.yaml 的 pose_info.reset_on_start):见 _maybe_startup_reset。
self._reset_done = False
self._reset_deadline: Optional[float] = None
self._setup_drift_watch()
hz = max(1.0, min(100.0, float(self.get_parameter("publish_hz").value)))
self.create_timer(1.0 / hz, self._tick)
@@ -260,7 +391,254 @@ class SimTruthPerception(Node):
)
# ------------------------------------------------------------------
# 漂移监视:真值感知的"前提自检"
# ------------------------------------------------------------------
def _setup_drift_watch(self) -> None:
"""订阅世界模型位姿,盯住"被真值当作几何基准"的物体现在在哪。
两件事一起做,因为它们是同一份数据:
* **量测**:给真值目标/孔位提供**实测**位姿(``sim_live_pose``)——
这才是真值该用的几何(见模块 docstring);
* **告警**:实测位姿相对 ``scene.yaml`` 的名义位姿偏了多少。
超过 ``drift_tol_m`` **告警但继续发**(几何已经跟着实测走,
没有幻影目标);超过 ``drift_stop_m`` 才**停发**并指名报错
(那个量级说明物体掉了/被搬走了,场景前提没了)。
为什么告警与停发要分开(2026-09-24 实测):
物体 spawn 落地就偏 2.6mm、骑在通槽上还会缓慢再挪 1.9mm ——
这个量级**天天都会出现**,而它正好压在"2mm"这条线上。用同一条阈值
既停发又告警,结果是"每轮开局都可能被假故障拦死",
而现场的正确反应是"忽略这条告警" —— 监视器一旦被学会忽略就废了。
只盯**工件**:它是二阶段孔位与伺服目标的几何基准。
通止规刻意不盯 —— 它本来就要被夹走、被搬动,那正是流程的一部分。
"""
pi = self._spec.pose_info
topic = str(self.get_parameter("pose_info_topic").value or "").strip()
if not topic and pi is not None:
topic = pi.ros_topic
# 只盯工件:它是二阶段孔位与伺服目标的几何基准。
want = self._spec.operating.part_object
self._nominal_obj = next((o for o in self._spec.objects if o.name == want), None)
self._tol_m = float(pi.drift_tol_m if pi is not None else 0.002)
self._tol_deg = float(pi.drift_tol_deg if pi is not None else 2.0)
self._stop_m = float(getattr(pi, "drift_stop_m", 0.02) if pi is not None else 0.02)
# 启动复位要用 world 名(服务路径里),而 world 名从 gz_topic 推出来 ——
# 不另配一个参数:两份写法迟早不一致,症状是"服务调用超时"。
self._reset_world = (
sim_pose_service.world_from_gz_topic(pi.gz_topic) if pi is not None else None
)
self._reset_on_start = bool(pi.reset_on_start) if pi is not None else False
self._live = sim_live_pose.LiveScenePose(self, topic)
if self._nominal_obj is None:
self.get_logger().warn(
f"scene.yaml 的 operating.part_object={want!r} 不在 objects 里 —— "
f"实测位姿无从取得(只能按名义位姿算,几何会偏)。"
)
if not topic:
self.get_logger().info(
"没有世界模型位姿话题(scene.yaml 的 pose_info 未配)—— "
"真值将**按名义位姿**算目标点与孔位(物体一动就偏,实测偏 2~3mm "
"就会让规尖进不了孔),而且没有「物体被搬走」的告警。"
)
return
self.get_logger().info(
f"已订阅 {topic}:真值目标按**实测**位姿算;与该物体在 scene.yaml 的"
f"名义位姿相差超过 {self._tol_m * 1000:.1f}mm / {self._tol_deg:.1f}° 告警,"
f"超过 {self._stop_m * 1000:.0f}mm 停发。"
)
if self._reset_on_start:
if self._reset_world is None:
self.get_logger().error(
f"pose_info.reset_on_start=true,但从 gz_topic "
f"{pi.gz_topic!r} 里认不出 world 名 —— **启动复位不会执行**。\n"
f" 期望形如 /world/<world>/pose/info,world 名 = "
f"worlds/assembly.world 的文件名。\n"
f" 后果:spawn 落地的 2~3mm 偏差会被当成"
f"「场景被动过」,告警一直刷。"
)
else:
self.get_logger().info(
f"启动复位已开:开局({STARTUP_RESET_WINDOW_S:.0f}s 内)会把被盯的物体"
f"搬回名义位姿(set_pose /world/{self._reset_world}/set_pose)。"
)
# ------------------------------------------------------------------
# 实测位姿 → 目标点
# ------------------------------------------------------------------
def _live_deviation(self) -> Optional[Tuple[float, float, str]]:
"""实测位姿相对名义位姿的偏差 ``(d [m], ang [deg], 说明)``;没有实测返回 None。"""
obj = getattr(self, "_nominal_obj", None)
if obj is None:
return None
p = self._live.pose(obj.name)
if p is None:
return None
d, ang, _ = pose_deviation(obj, p[:3], sim_pose_service.yaw_to_quat(p[3]))
reason = (
pose_drift(obj, p[:3], sim_pose_service.yaw_to_quat(p[3]), self._tol_m, self._tol_deg)
or ""
)
return (d, ang, reason)
def _current_target(self) -> Tuple[TruthTarget, bool]:
"""按实测位姿算目标点(位姿没变就复用)。返回 ``(目标, 是否按实测算的)``。
实测位姿拿不到时**退回名义位姿**并把这个事实报给调用方 ——
静默退回是这条链上最贵的错(几何偏两毫米、没有任何日志提起)。
"""
obj = getattr(self, "_nominal_obj", None)
p = self._live.pose(obj.name) if obj is not None else None
if obj is None or p is None:
return self._target, False
key = tuple(round(float(v), 7) for v in p)
if self._target_key == key and self._target_live is not None:
return self._target_live, True
spec_live = self._live.apply(self._spec, [obj.name])
live_obj = pick_object(spec_live, obj.name)
t = target_point_world(spec_live, live_obj, self._offset)
self._target_key = key
self._target_live = t
return t, True
# ------------------------------------------------------------------
# 启动复位:把"落地的 2~3mm"与"被撞走"分开
# ------------------------------------------------------------------
def _maybe_startup_reset(self) -> None:
"""开局把被盯的物体**实际搬到**名义位姿(只做一次,且有时间窗)。
为什么需要(2026-09-24 实测,别再当玄学):
物体是"在名义位姿上方 spawn、再落下来"的,落稳后实测偏 **2.6mm**
((0.5175, 0.0008) vs 名义 (0.5200, 0.0000)),正好越过 2mm 容差 ——
它不是"被撞走",但看门狗**分不出来**,于是每一轮开局都可能被这条
假故障拦死:S04 等不到目标点、报「一个视觉目标点都没收到」。
而把它 set_pose 回名义位姿之后,它**停在那儿不动**(实测)——
说明名义位姿本身就是稳定平衡点,差的只是 spawn 那一下的落点。
为什么只在窗口内做、且**只做一次**:
运行中漂移的唯一合法解释是"被机器人撞了/压了"。那时悄悄搬回去等于
抹掉现场证据(而现象会变成"孔位对不上/下压不接触",更难查),
还可能把工件从爪里拽出来。所以过了窗口就照旧报错停下。
判据本身在纯函数 ``startup_reset_decision`` 里(有单测钉着),
这里只负责"按决定去调服务、并把话说明白"。
"""
if self._reset_done or not self._reset_on_start or self._reset_world is None:
return
now = self.get_clock().now().nanoseconds * 1e-9
dev = self._live_deviation()
decision, deadline = startup_reset_decision(
now_s=now,
deadline_s=self._reset_deadline,
window_s=STARTUP_RESET_WINDOW_S,
pose_known=self._live.ready,
any_drift=bool(dev is not None and (dev[2] or dev[0] > self._tol_m)),
)
self._reset_deadline = deadline
if decision == "wait":
return
if decision == "skip":
self._reset_done = True # 本来就在位(或还没量到足够信息):不必搬
return
if decision == "give_up":
# 窗口内没成功(比如 set_pose 服务不通)→ 放弃并说清楚,
# 之后就是告警/停发的正常行为(真值几何已经跟着实测算,不影响发点)。
self._reset_done = True
self.get_logger().warn(
f"启动复位窗口({STARTUP_RESET_WINDOW_S:.0f}s)内没能把物体搬回名义位姿 —— "
f"放弃复位;真值几何按实测位姿算(不受影响),"
f"但告警会一直刷「与名义位姿不一致」。"
)
return
self._reset_done = True
obj = getattr(self, "_nominal_obj", None)
if obj is None:
return
reason = (dev[2] if dev else "") or ""
ok, out = sim_pose_service.set_model_pose_yaw(
self._reset_world, obj.name, (obj.x, obj.y, obj.z), float(obj.yaw)
)
if ok:
self.get_logger().warn(
f"启动复位:{obj.name!r} 不在名义位姿"
f"({reason.splitlines()[-1].strip() if reason else ''})"
f"—— 已 set_pose 回 ({obj.x:.4f},{obj.y:.4f},{obj.z:.4f}) "
f"yaw={math.degrees(float(obj.yaw)):.1f}°。\n"
f" 这是**开局**的正常现象(spawn 落地会偏 2~3mm),不是故障;"
f"复位后实测位姿等于名义位姿。"
)
else:
self.get_logger().error(
f"启动复位失败:{obj.name!r} 没能 set_pose 回名义位姿 —— {out[:200]}\n"
f" 影响有限:真值几何按实测位姿算,仍能发点;但告警会一直刷。"
)
def _tick(self) -> None:
# 前提自检的**活性检查**:订阅了话题却一帧都没收到,等于没有监视 ——
# 而那正是"以为有保护、实际裸奔"。所以这里必须把这种情况说出来。
if self._live.topic and not self._live.ready:
if self._t_start is None:
self._t_start = self.get_clock().now().nanoseconds * 1e-9
elif self.get_clock().now().nanoseconds * 1e-9 - self._t_start > 8.0:
self.get_logger().warn(
"盯物体位姿的话题**一帧都没收到** —— 真值只能退回**名义位姿**"
"(几何会偏,实测偏 2~3mm 就能让规尖进不了孔)。\n"
" 常见原因:gazebo_sim.launch.py 里的 pose_info_bridge 没起来,"
"或 scene.yaml 的 pose_info.gz_topic 与 world 名对不上\n"
" (必须等于 /world/<world>/pose/info,world 名 = "
"worlds/assembly.world 的文件名)。",
throttle_duration_sec=10.0,
)
# 先给"开局落地偏差"一次纠偏机会(见 _maybe_startup_reset)。
self._maybe_startup_reset()
dev = self._live_deviation()
if dev is None and self._nominal_obj is not None and self._live.ready:
# 话题有帧,就是没有这个模型 → 名字对不上,说出来。
self.get_logger().warn(
f"实测位姿里没有物体 {self._nominal_obj.name!r}:"
f"{self._live.why_not(self._nominal_obj.name)}",
throttle_duration_sec=10.0,
)
elif dev is not None:
d, ang, reason = dev
if d > self._stop_m:
# 这个量级说明物体掉了/被搬走了:场景前提没了,**不发点**。
self._warned_drift += 1
if self._warned_drift in (1, 5, 30) or self._warned_drift % 300 == 0:
self.get_logger().error(
f"仿真物体 {self._nominal_obj.name!r} 已经不在 scene.yaml 说的地方了 —— "
f"**停止发真值点**(偏了 {d * 1000:.0f}mm,超过 "
f"{self._stop_m * 1000:.0f}mm 这条线)。\n"
f" {reason}\n"
f" ⚠ 为什么停:物体掉了/被搬走之后,装配就没有意义了 ——"
f"再发点等于指挥机械臂去装一个不存在的工件。\n"
f" 修法:把 {self._nominal_obj.name!r} 放回 scene.yaml 的位置"
f"(真机现场就改 scene.yaml),或重启仿真让它按场景重新 spawn。\n"
f" 排查提示:台面沿 X 有一道 36mm 通槽(|y| ≤ 18mm);"
f"工件若跨槽时被压翻就会掉下去。"
)
return
if reason:
# 2~3mm 这个量级**天天都有**(spawn 落地 + 通槽上缓慢蠕动),
# 而几何已经跟着实测走,所以只告警、**继续发点**。
self.get_logger().warn(
f"物体 {self._nominal_obj.name!r} 与 scene.yaml 的名义位姿不一致 —— "
f"真值几何已按**实测**位姿算,不受影响;只是提醒你场景被动过。\n"
f" {reason}\n"
f" (超过 {self._stop_m * 1000:.0f}mm 才停发)",
throttle_duration_sec=30.0,
)
target, live = self._current_target()
if not live and self._warned_nominal < 3:
self._warned_nominal += 1
self.get_logger().warn(
f"真值目标暂时按**名义位姿**算({self._live.why_not(target.object_name)})—— "
f"物体一动几何就偏,实测偏 2~3mm 就会让规尖进不了孔。"
)
try:
tr = self._tf.lookup_transform(self._camera_frame, self._world_frame, Time())
except Exception as exc: # noqa: BLE001 —— tf2 的异常类型很杂,统一当"还没有"
@@ -274,14 +652,14 @@ class SimTruthPerception(Node):
throttle_duration_sec=5.0,
)
return
p = apply_transform(matrix_from_transform(tr), self._target.world_xyz)
p = apply_transform(matrix_from_transform(tr), target.world_xyz)
if p[2] < MIN_CAM_Z_M:
# 目标在相机背后或贴到镜头上:不发!发出去伺服会朝反方向走。
self.get_logger().warn(
f"真值目标不在相机前方(相机系 z={p[2] * 1000:.0f}mm < "
f"{MIN_CAM_Z_M * 1000:.0f}mm)—— 不发点。"
f"世界系点 ({self._target.world_xyz[0]:.3f}, "
f"{self._target.world_xyz[1]:.3f}, {self._target.world_xyz[2]:.3f})"
f"世界系点 ({target.world_xyz[0]:.3f}, "
f"{target.world_xyz[1]:.3f}, {target.world_xyz[2]:.3f})"
f" 不在 {self._camera_frame} 视野里;先让臂摆到能看到它的位置。",
throttle_duration_sec=5.0,
)
@@ -0,0 +1,159 @@
"""仿真腕部力扭矩(wrist force/torque)的配置与桥接规则。
这一层只做两件事,别的都不做:
1. 从 ``devices/sim/gazebo/config/wrist_ft.yaml`` 读话题与关节名
2. 生成 ros_gz_bridge 的参数(gz 消息 → ROS 消息)
**为什么不需要适配器节点**(与触觉不同):
触觉必须有 ``sim_tactile_adapter``,因为要做的语义工作很多 —— 轴向映射、
基线相减、单位换算、接触超时、把 ``Contacts`` 折算成 contact 标志。
腕部 FT 不需要任何这些:Gazebo 的 force_torque 直接给的就是**牛顿**,
坐标系是 ``child`` 连杆系(工具竖直时 z 即轴向),而 ros_gz_bridge
**原生支持** gz.msgs.Wrench → geometry_msgs/msg/WrenchStamped(实测确认)。
于是仿真的输出与真机 ``/<sn>/ft_sensor_wrench`` **同名同类型同单位**,
应用层一行分支都不用写。
少一层就少一个"配错了不报错"的地方,所以这里刻意不加节点。
与真机的对应:
真机 /<sn>/ft_sensor_wrench geometry_msgs/WrenchStamped(法兰系,牛顿)
仿真 /sim/ft_sensor_wrench geometry_msgs/WrenchStamped(link7 系,牛顿)
← 名字由 site.yaml 的 robot.arms[sim].sn 决定
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
#: 相对仓库根的默认配置路径。
DEFAULT_WRIST_FT_REL = "devices/sim/gazebo/config/wrist_ft.yaml"
class WristFtConfigError(RuntimeError):
"""腕部 FT 配置缺失或写错。消息里必须给出修法(现场就靠这句话)。"""
@dataclass(frozen=True)
class WristFtSpec:
"""腕部 FT 的一份完整描述。"""
topic: str
joint: str
ros_type: str
gz_type: str
publish_hz: float
def load_wrist_ft(
path: Optional[Path] = None, root: Optional[Path] = None
) -> WristFtSpec:
"""读腕部 FT 配置。找不到或写错时抛 WristFtConfigError,消息里给出修法。"""
if path is None:
base = root if root is not None else Path.cwd()
path = Path(base) / DEFAULT_WRIST_FT_REL
path = Path(path)
if not path.is_file():
raise WristFtConfigError(
f"腕部 FT 配置不存在: {path}\n"
f" 它声明了仿真腕部力扭矩的话题名与挂载关节(桥接从它派生)。\n"
f" 缺了它,S07 的接触判据在仿真里永远没有数据源。\n"
f" 确认仓库根下存在 {DEFAULT_WRIST_FT_REL}。"
)
try:
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
raise WristFtConfigError(
f"腕部 FT 配置 YAML 解析失败: {path}\n 原因: {exc}"
) from exc
if not isinstance(doc, dict):
raise WristFtConfigError(f"{path} 的顶层应该是映射(key: value 形式)。")
missing = [
k for k in ("topic", "joint", "ros_type", "gz_type") if not str(doc.get(k, "")).strip()
]
if missing:
raise WristFtConfigError(
f"{path} 缺字段: {', '.join(missing)}。\n"
f" topic 必须与 arm.sim.sensors.xacro 里 wrist_ft_topic 的默认值\n"
f" 完全一致,也要与应用层按 sn 推出的名字一致 —— 对不上时桥接器只是\n"
f" 订阅了一个不存在的话题,不报错,而 S07 会一直判不到接触。"
)
try:
hz = float(doc.get("publish_hz", 100.0))
except (TypeError, ValueError) as exc:
raise WristFtConfigError(
f"{path} 的 publish_hz 不是数字: {doc.get('publish_hz')!r}"
) from exc
if hz <= 0.0:
raise WristFtConfigError(f"{path} 的 publish_hz 必须为正,收到 {hz}")
return WristFtSpec(
topic=str(doc["topic"]).strip(),
joint=str(doc["joint"]).strip(),
ros_type=str(doc["ros_type"]).strip(),
gz_type=str(doc["gz_type"]).strip(),
publish_hz=hz,
)
def bridge_args(spec: WristFtSpec) -> List[str]:
"""生成 ros_gz_bridge parameter_bridge 的话题规则。
只做**传输**:gz 类型 → ROS 类型,话题名两侧保持同名。
这条规则的终点就是应用层真正订阅的那个话题,所以话题名必须一次写对 ——
桥接器没有"改名"能力,写错只会得到"话题上没数据"。
"""
return [f"{spec.topic}@{spec.ros_type}[{spec.gz_type}"]
def check_wrist_ft_sensor(
urdf_text: str, spec: WristFtSpec
) -> List[str]:
"""描述级静态检查:URDF 里到底有没有那个腕部 FT 传感器。
为什么需要(与 ``check_tactile_collisions`` 同一个理由):
Gazebo 对**挂错关节**的传感器不报错 —— 传感器会被静默丢弃,
话题永远没有数据,现象是"S07 判不到接触"。光看 launch 日志完全看不出来
(桥接器和 Gazebo 都说一切正常)。这里在启动时就把它指出来。
返回告警列表(空 = 没问题);**不抛异常** —— 缺了它仿真仍能跑,
只是 S07 的判据要退回触觉兜底,那是降级不是崩溃。
"""
warnings: List[str] = []
if not urdf_text:
return ["URDF 文本为空,无法核对腕部 FT 传感器"]
if f'reference="{spec.joint}"' not in urdf_text:
near = [ln for ln in urdf_text.splitlines() if "gazebo reference=" in ln and "joint" in ln]
warnings.append(
f"URDF 里没有 <gazebo reference=\"{spec.joint}\"> —— "
f"腕部 FT 传感器挂错关节了,它会被 Gazebo 静默丢弃、话题永远没数据。\n"
f" 期望挂在 {spec.joint}(最后一个 revolute 关节:它下游挂着整个腕部以下"
f" 的载荷,这才是工程意义上的腕部 FT)。\n"
f" 注意不能挂 fixed 关节(如 flange_joint)——物理引擎不把它当独立关节"
f" 求解,传感器永远没输出且不报错。\n"
f" 当前 URDF 里的 <gazebo reference> 关节有: {', '.join(sorted(set(near))) or '(无)'}"
)
if spec.topic not in urdf_text:
warnings.append(
f"URDF 里找不到腕部 FT 的话题 {spec.topic} —— 传感器的话题名与配置不一致。\n"
f" 两侧必须一致:config 的 topic、arm.sim.sensors.xacro 的 wrist_ft_topic、\n"
f" 以及应用层按 sn 推出的 /{{sn}}/ft_sensor_wrench(仿真 sn = sim)。"
)
if "force_torque" not in urdf_text:
warnings.append(
"URDF 里没有任何 type=\"force_torque\" 传感器 —— 腕部 FT 不会被创建。"
)
return warnings
@@ -59,6 +59,20 @@ class HoleTruth:
#: 两孔的像素半径(环的物理半径按透视缩放;Hough 路径给的是它自己的估计,
#: 真值路径给它一个**可信的**值 —— 上下游只把它当"尺度参考"用)
hole_radii: List[float] = field(default_factory=list)
#: 两孔**环心的世界坐标**(米),与 ``hole_points`` 一一对应、同序。
#:
#: 为什么真值路径要把三维也带出来(2026-09-24 的教训):
#: 原来只给像素,调用方(stage2)再拿**深度图**反投影回三维。仿真里
#: 那一步是错的,而且错法很隐蔽:孔是**贯通孔**,孔位正下方就是台面
#: 通槽 —— 深度相机在孔位看到的是**槽底/地面**(实测 0.62m),
#: 而工件面其实只有 0.21m。拿 0.21m 的像素尺度去乘 0.62m 的深度,
#: 三角化出的 k1/k2 间距变成 169mm(真值 58mm),被间距门槛丢掉。
#: 真值路径**本来就知道**孔在世界系的精确位置,没有理由再绕一圈深度图。
hole_world: List[Tuple[float, float, float]] = field(default_factory=list)
#: 工件**上表面中心**的世界坐标(粗居中用;同样是"真值就该直接用")。
part_center_world: Optional[Tuple[float, float, float]] = None
#: 工件上表面的世界 z(= 二阶段的对孔基准面)。
part_top_z: Optional[float] = None
#: 工件在画面里占的框(孔检测的 ROI 用;也是"工件中心"的来源)
bbox: Tuple[int, int, int, int] = (0, 0, 0, 0)
#: 给人看的说明(进了日志才知道这个数是哪来的)
@@ -154,9 +168,11 @@ def locate_holes(
pts: List[Tuple[float, float]] = []
rad: List[float] = []
holes_world: List[Tuple[float, float, float]] = []
for ring in rings:
wx, wy = _object_world_xy(obj, ring.x, ring.y)
got = project_point(T_cam_world, intr, (wx, wy, float(obj.z) + ring.z))
wz = float(obj.z) + float(ring.z)
got = project_point(T_cam_world, intr, (wx, wy, wz))
if got is None:
return HoleTruth(
ok=False,
@@ -165,6 +181,7 @@ def locate_holes(
u, v, z = got
pts.append((u, v))
rad.append(float(intr[0]) * float(ring.radius) / z)
holes_world.append((wx, wy, wz))
# k1/k2 的左右(上下)规则与 Hough 路径一致:水平对按 x,竖直对按 y。
(p, q) = pts
@@ -173,11 +190,24 @@ def locate_holes(
if pts[0][key] > pts[1][key]:
pts = [pts[1], pts[0]]
rad = [rad[1], rad[0]]
holes_world = [holes_world[1], holes_world[0]]
bbox = object_bbox_px(T_cam_world, intr, sim_scene.world_boxes(spec, obj, root), image_shape)
boxes = sim_scene.world_boxes(spec, obj, root)
bbox = object_bbox_px(T_cam_world, intr, boxes, image_shape)
if bbox is None:
return HoleTruth(ok=False, why="工件整体投影不出画面框(角点都在相机背后)")
# 工件上表面中心与上表面 z:从各碰撞体的世界 AABB 取(AABB 是轴对齐的,
# 只有 yaw 的物体上它正好等于"外接矩形",对"粗居中"这个用途是合适的)。
part_center: Optional[Tuple[float, float, float]] = None
part_top: Optional[float] = None
if boxes:
xs = [float(v) for b in boxes for v in (b[0], b[1])]
ys = [float(v) for b in boxes for v in (b[2], b[3])]
zs = [float(v) for b in boxes for v in (b[4], b[5])]
part_top = max(zs)
part_center = (0.5 * (min(xs) + max(xs)), 0.5 * (min(ys) + max(ys)), part_top)
dist_px = math.hypot(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1])
note = (
f"{obj.name}(模型 {obj.model}) 环心距 {dist_px:.1f}px "
@@ -188,6 +218,9 @@ def locate_holes(
why="",
hole_points=[(float(pts[0][0]), float(pts[0][1])), (float(pts[1][0]), float(pts[1][1]))],
hole_radii=[float(rad[0]), float(rad[1])],
hole_world=holes_world,
part_center_world=part_center,
part_top_z=part_top,
bbox=bbox,
note=note,
)
@@ -103,11 +103,57 @@ def _prepare(context, *args, **kwargs):
world_path, _world_origin = resolve_world(world, root, fallback=default_world(root))
world_path = str(world_path)
# 腕部 FT 的话题名先读出来,喂给 xacro —— 这个话题名有三处要对上
# (配置、URDF 里的 <topic>、应用层按 sn 推出的 /{sn}/ft_sensor_wrench),
# 让配置当唯一源头,xacro 不再自己写一份。
from arm_sim_gazebo.sim_wrist_ft import (
WristFtConfigError,
bridge_args as wrist_ft_bridge_args,
check_wrist_ft_sensor,
)
from arm_sim_gazebo.sim_wrist_ft import load_wrist_ft
try:
wft = load_wrist_ft(root=root)
except WristFtConfigError as exc:
raise RuntimeError(f"\n[gazebo_sim] 仿真腕部 FT 配置有问题:\n{exc}\n") from exc
# 抓取锁:仿真里唯一能保证"夹住的东西不会在机械臂连续运动时滑掉"的机制,
# 说明见 devices/sim/gazebo/config/grasp_lock.yaml 与 sim_grasp_lock.py。
from arm_sim_gazebo.sim_grasp_lock import (
DEFAULT_OBJECT_SDF_TMPL,
GraspLockError,
check_grasp_lock_sdf,
)
from arm_sim_gazebo.sim_grasp_lock import load_grasp_lock
try:
glock = load_grasp_lock(root=root)
except GraspLockError as exc:
raise RuntimeError(
f"\n[gazebo_sim] 仿真抓取锁配置有问题:\n{exc}\n"
f" (只想先跑起来的话,把 grasp_lock.yaml 的 enabled 改成 false)\n"
) from exc
# 静态核对:物体 SDF 里到底有没有那个插件、参数对不对得上。
# 差一处就**静默失效**(现象只是"规莫名掉了"),所以必须在这里说清楚。
glock_warnings: List[str] = []
if glock.enabled:
_obj_sdf = root / DEFAULT_OBJECT_SDF_TMPL.format(object=glock.object)
try:
_sdf_text = _obj_sdf.read_text(encoding="utf-8")
except OSError:
_sdf_text = ""
glock_warnings = check_grasp_lock_sdf(
_sdf_text, glock, sdf_label=str(_obj_sdf)
)
urdf, report = prepare_urdf(
xacro_path=default_xacro(root),
controllers_file=default_controllers(root),
load_gripper=load_gripper != "false",
with_sensors=with_sensors != "false",
wrist_ft_topic=wft.topic,
)
wname = world_name(world_path)
missing_base = check_base_plugins(Path(world_path))
@@ -120,7 +166,10 @@ def _prepare(context, *args, **kwargs):
LogInfo(msg=f"[gazebo_sim] 仓库根: {root}"),
LogInfo(msg=f"[gazebo_sim] 世界: {world_path} (world 名: {wname})"),
LogInfo(msg=f"[gazebo_sim] URDF 归一化: {report.summary()}"),
LogInfo(msg=f"[gazebo_sim] {glock.describe()}"),
]
for _w in glock_warnings:
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 抓取锁检查: {_w}"))
if missing_base:
actions.append(
LogInfo(
@@ -191,6 +240,43 @@ def _prepare(context, *args, **kwargs):
)
)
# --- 3b) 世界模型位姿回读:物体被撞走时唯一能指名报错的数据源 ------------
# 为什么值得一条独立桥接:`perception.source=truth` 是**纯几何**的,
# 它按 scene.yaml 的**名义位姿**算孔位,从不问 Gazebo 物体现在在哪。
# 工件被碰走(实测:掉进台面通槽落到地面)之后,"真值"开始撒谎,
# 而现象全都指向别处(S05 报"两孔间距 169mm"、S07 一路下压不接触)。
# 这一条把 gz 的"每个模型实际位姿"搬进 ROS,让 sim_truth_perception
# 能把它和 scene.yaml 对上、指名报"工件不在它该在的地方"。
# 具体参数(话题名/类型/漂移容差)在 scene.yaml 的 pose_info 段。
from arm_sim_gazebo.sim_scene import load_scene as _load_scene
try:
_spec = _load_scene(None, root)
except Exception as exc: # noqa: BLE001 —— 场景读不出来要说清是场景的问题
raise RuntimeError(f"\n[gazebo_sim] 场景配置有问题:\n{exc}\n") from exc
if _spec.pose_info is not None:
_pi = _spec.pose_info
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="pose_info_bridge",
output="screen",
arguments=[f"{_pi.gz_topic}@{_pi.ros_type}[{_pi.gz_type}"],
# gz 的话题名固定是 /world/<world>/pose/info(命名空间由 gz 决定,
# 应用侧改不了),ros_topic 只是"到 ROS 这一侧叫什么"。
# ros_gz_bridge 没有改名能力,所以这里用 remap 把名字对到
# scene.yaml 声明的那一个 —— 两侧对不上的表现是
# "桥接器起来了但一个消息都没有",那是个假故障。
remappings=[(_pi.gz_topic, _pi.ros_topic)],
)
)
actions.append(
LogInfo(
msg=f"[gazebo_sim] 世界模型位姿回读: {_pi.gz_topic} → {_pi.ros_topic}"
)
)
# --- 4) 相机:桥接 → 适配器 → (世界相机)补 TF --------------------------
# 只在挂了传感器时才做。--no-sensors 时跳过是必要的:适配器会去订阅
# 永远不存在的 gz 话题,它**不会报错**,只是静默没输出 —— 而"相机没图"
@@ -357,6 +443,26 @@ def _prepare(context, *args, **kwargs):
)
)
# 4e) 腕部 FT:只需桥接,**不需要适配器** ------------------------------
# 与触觉不同:触觉要轴向映射/基线/单位换算/contact 折算,所以有适配器;
# 腕部 FT 的 gz 输出本来就是牛顿、坐标系就是 child(link7) 系,而
# ros_gz_bridge **原生支持** gz.msgs.Wrench → WrenchStamped(实测确认)。
# 于是仿真产出的就是真机同名的 /<sn>/ft_sensor_wrench(同名同类型同单位),
# 应用层一行分支都不用写。少一层节点就少一个"配错了不报错"的地方。
if with_sensors != "false":
for _w in check_wrist_ft_sensor(urdf, wft):
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 腕部 FT 检查: {_w}"))
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="wrist_ft_bridge",
output="screen",
arguments=wrist_ft_bridge_args(wft),
)
)
# --- 5) spawn 机器人 -----------------------------------------------------
# -world 必须给对:world 名写错时 Gazebo **不报错**,只是实体没被创建,
# 表现成"launch 一切正常但 RViz 里什么都没有"。
@@ -533,6 +639,14 @@ def _prepare(context, *args, **kwargs):
"mimics": mimics,
"robot_description": urdf,
"namespace": "/gripper",
# 抓取锁(仿真专用,见 sim_grasp_lock.py):
# 夹住后把物体固定到手指上,避免"位置控制夹持 +
# 连续轨迹"导致的打滑掉件。
"grasp_lock_enabled": bool(glock.enabled),
"grasp_lock_object": str(glock.object),
"grasp_lock_attach_topic": str(glock.attach_topic),
"grasp_lock_detach_topic": str(glock.detach_topic),
"grasp_lock_timeout_ms": int(glock.publish_timeout_ms),
},
{"use_sim_time": True},
],
@@ -0,0 +1,240 @@
"""sim_grasp_lock.py 的测试:配置读取、与物体 SDF 的静态核对、触发命令拼装。
为什么这个模块值得单独一套测试:它管的**全是静默失效**。
抓取锁不生效时 Gazebo 什么都不说 —— 插件没写进模型、child_model 名字不对、
话题名两侧不一致,全都表现为同一个现象:"规莫名其妙掉了"(要等到 S07/S08、
而且日志里看不到真因)。所以这里把"能不能发现"逐条钉住。
三条重点(都是真实踩到的):
* 插件参数与配置**必须两侧一致**,而名字有好几个(child_model/child_link/
attach_topic/detach_topic),漏一个就是静默失效 —— 所以 check 要逐项查,
而不是只查"插件在不在"。
* child_link 必须是 Gazebo 的**真名**:URDF 里与固定关节相连的连杆会被
sdformat 合并(lump),手指那样的名字在 Gazebo 里根本不存在。
* 配置文件缺失**不该报错**(锁是可选的补救措施),但文件在、字段写错
必须报错(那说明有人动过它并且写错了)。
全部不依赖 ROS、不依赖 Gazebo。
"""
from __future__ import annotations
import pytest
import yaml
from arm_sim_gazebo.sim_grasp_lock import (
DEFAULT_GRASP_LOCK_REL,
GraspLockError,
GraspLockSpec,
bridge_target_links,
check_grasp_lock_sdf,
load_grasp_lock,
)
# ---------------------------------------------------------------------------
# 配置读取
# ---------------------------------------------------------------------------
def _write(tmp_path, doc) -> "object":
p = tmp_path / "grasp_lock.yaml"
p.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
return p
def _ok_doc() -> dict:
return {
"enabled": True,
"object": "gauge",
"object_link": "body",
"robot_model": "rizon_sim",
"robot_link": "sim_left_finger_mount",
"attach_topic": "/sim/grasp_lock/attach",
"detach_topic": "/sim/grasp_lock/detach",
"state_topic": "/sim/grasp_lock/state",
"publish_timeout_ms": 3000,
}
def test_repo_config_loads_and_describes():
"""仓库里真实的那份配置必须能读、且 describe() 不空。"""
spec = load_grasp_lock(root=__import__("pathlib").Path(__file__).resolve().parents[3])
assert spec.enabled in (True, False)
assert spec.object
assert "抓取锁" in spec.describe()
if spec.enabled:
assert spec.robot_model and spec.robot_link
assert spec.attach_topic != spec.detach_topic
def test_missing_file_is_disabled_not_error(tmp_path):
"""文件不在 → 返回"关闭",而不是抛。锁是可选的补救措施,不该拦住仿真。"""
spec = load_grasp_lock(tmp_path / "nope.yaml")
assert spec.enabled is False
def test_enabled_requires_names(tmp_path):
"""开着但缺 robot_model/robot_link → 必须抛,且消息里说清后果。"""
doc = _ok_doc()
doc.pop("robot_link")
with pytest.raises(GraspLockError) as ei:
load_grasp_lock(_write(tmp_path, doc))
msg = str(ei.value)
assert "robot_link" in msg
# 必须解释"写错会静默失效",否则现场只会去改阈值
assert "静默失效" in msg
def test_attach_and_detach_must_differ(tmp_path):
"""两个话题相同 = 上锁解锁走同一条消息 → 状态随机。必须拦住。"""
doc = _ok_doc()
doc["detach_topic"] = doc["attach_topic"]
with pytest.raises(GraspLockError) as ei:
load_grasp_lock(_write(tmp_path, doc))
assert "attach_topic" in str(ei.value)
def test_finger_anchor_is_rejected_and_explains_the_yank(tmp_path):
"""锚点不许是**手指**:张爪时会把被锁的工件从孔里拽出来(2026-09-24 实测)。
症状只有一句"通止规掉了(E1)",看不到真因 —— 所以必须在**读配置时**就拦住,
并且把"为什么"和"改成什么"写进消息里。
"""
doc = _ok_doc()
doc["robot_link"] = "sim_left_finger_mount"
with pytest.raises(GraspLockError) as ei:
load_grasp_lock(_write(tmp_path, doc))
msg = str(ei.value)
assert "sim_left_finger_mount" in msg
assert "sim_link7" in msg, "必须给出正确的锚点,否则现场还得自己猜"
assert "拽" in msg or "拉出" in msg, "必须说清物理后果(工件被拽出孔)"
def test_bad_yaml_reports_path(tmp_path):
"""YAML 语法错 → 消息里带路径(现场一眼知道改哪个文件)。"""
p = tmp_path / "grasp_lock.yaml"
p.write_text("enabled: [true\n", encoding="utf-8")
with pytest.raises(GraspLockError) as ei:
load_grasp_lock(p)
assert "grasp_lock.yaml" in str(ei.value)
def test_disabled_skips_required_fields(tmp_path):
"""enabled: false 时不该因为缺字段而报错 —— 关了就是关了。"""
doc = _ok_doc()
doc["enabled"] = False
doc.pop("robot_model")
doc.pop("robot_link")
spec = load_grasp_lock(_write(tmp_path, doc))
assert spec.enabled is False
# ---------------------------------------------------------------------------
# 与物体 SDF 的核对
# ---------------------------------------------------------------------------
def _sdf(**over) -> str:
"""一份"长得像"插件块的 SDF 文本(只关心被查的那几个字段)。"""
vals = {
"child_model": "rizon_sim",
"child_link": "sim_left_finger_mount",
"attach_topic": "/sim/grasp_lock/attach",
"detach_topic": "/sim/grasp_lock/detach",
}
vals.update(over)
return (
"<sdf><model name='gauge'><link name='body'/></model>\n"
"<plugin filename='ignition-gazebo-detachable-joint-system' "
"name='ignition::gazebo::systems::DetachableJoint'>\n"
f" <child_model>{vals['child_model']}</child_model>\n"
f" <child_link>{vals['child_link']}</child_link>\n"
f" <attach_topic>{vals['attach_topic']}</attach_topic>\n"
f" <detach_topic>{vals['detach_topic']}</detach_topic>\n"
"</plugin></sdf>"
)
def _spec(**over) -> GraspLockSpec:
base = dict(
enabled=True,
object="gauge",
object_link="body",
robot_model="rizon_sim",
robot_link="sim_left_finger_mount",
attach_topic="/sim/grasp_lock/attach",
detach_topic="/sim/grasp_lock/detach",
state_topic="/sim/grasp_lock/state",
publish_timeout_ms=3000,
)
base.update(over)
return GraspLockSpec(**base)
def test_consistent_sdf_has_no_warning():
assert check_grasp_lock_sdf(_sdf(), _spec()) == []
def test_missing_plugin_is_reported():
"""模型里没有插件 → 必须报,而且要说清"不会报错,只是规照样掉"。"""
warns = check_grasp_lock_sdf("<sdf><model name='gauge'/></sdf>", _spec())
assert len(warns) == 1
assert "DetachableJoint" in warns[0]
assert "不会报错" in warns[0]
def test_wrong_child_model_is_reported():
"""child_model 与 spawn 的 -name 不一致 → 插件 Configure 直接失败。"""
warns = check_grasp_lock_sdf(_sdf(child_model="rizon"), _spec())
assert any("<child_model>" in w for w in warns)
def test_wrong_child_link_is_reported_and_explains_lumping():
"""child_link 用了 URDF 名(Gazebo 里被 lump 掉的真名不存在)→ 必须报。"""
warns = check_grasp_lock_sdf(_sdf(child_link="sim_left_finger"), _spec())
msg = "\n".join(warns)
assert "<child_link>" in msg
# 解释"要填真名、真名怎么查",否则现场只会照抄 URDF
assert "合并" in msg and "world_pose_info" in msg
def test_topic_mismatch_is_reported():
warns = check_grasp_lock_sdf(_sdf(attach_topic="/attachable/attach"), _spec())
assert any("attach_topic" in w for w in warns)
def test_disabled_lock_never_warns():
"""关掉时不该报 —— 没插件是预期状态,不该刷告警让人忽略真告警。"""
assert check_grasp_lock_sdf("<sdf/>", _spec(enabled=False)) == []
def test_empty_sdf_is_reported():
warns = check_grasp_lock_sdf("", _spec())
assert warns and "读不到" in warns[0]
def test_target_links_lists_both_ends():
links = bridge_target_links(_spec())
assert links == ["gauge.body", "rizon_sim.sim_left_finger_mount"]
def test_default_rel_path_matches_repo_layout():
"""默认路径必须真的是仓库里那份(改了目录而忘了改常量 = 锁永远静默关闭)。"""
assert DEFAULT_GRASP_LOCK_REL.endswith("config/grasp_lock.yaml")
def test_topic_cmd_publishes_with_duration_not_wait():
"""`ign topic` 的参数必须带 -d(duration),**不能**带 -w。
2026-09-24 踩到:写成了 ``-w <秒>``(以为它"等订阅者"),而这个版本的
ign topic 没有 -w —— 参数变成多余的位置参数:
"The following arguments were not expected: 3"
于是上锁/解锁永远失败,而现象和"锁根本没生效"一模一样(规照样掉),
只看日志根本分不出来。这里把命令行钉死。
"""
from arm_sim_gazebo.sim_grasp_lock import _ign_topic_cmd
cmd = _ign_topic_cmd("ign", "/sim/grasp_lock/attach", 3000)
assert "-d" in cmd
assert "-w" not in cmd
assert cmd[cmd.index("-t") + 1] == "/sim/grasp_lock/attach"
assert cmd[cmd.index("-m") + 1] == "ignition.msgs.Empty"
# duration 必须是正的浮点秒数
assert float(cmd[cmd.index("-d") + 1]) > 0.0
@@ -0,0 +1,90 @@
"""sim_pose_service.py 的测试:把模型 set_pose 到指定位姿的那一层。
为什么这层要单独测:它唯一的实现细节(`--reqtype ignition.msgs.Pose`)
**错一个字母就只是"服务调用失败"**,而报错信息完全不提原因;而调用方
(场景加载脚本、真值感知的启动复位)都把它当"可选步骤"处理 ——
于是它坏掉的表现是"物体没被复位",没有任何一处日志指名它。
这里只测**纯函数**(world 名解析、yaw→四元数):真正调 `ign` 需要 Gazebo,
放在 `test_check_sim_scene.py` 那种"要环境"的地方没意义(离线机器跑不了)。
"""
from __future__ import annotations
import math
import pytest
from arm_sim_gazebo.sim_pose_service import (
set_model_pose,
set_model_pose_yaw,
world_from_gz_topic,
yaw_to_quat,
)
@pytest.mark.parametrize(
"topic,want",
[
("/world/assembly/pose/info", "assembly"),
("/world/assembly/pose", "assembly"),
(" /world/assembly/pose/info ", "assembly"),
("/world/assembly/pose/info/", "assembly"),
("/world/sim_room/pose/info", "sim_room"),
],
)
def test_world_from_gz_topic(topic, want):
assert world_from_gz_topic(topic) == want
@pytest.mark.parametrize(
"topic",
["", None, "assembly", "/world/pose/info", "/world/assembly/other", "world/assembly/pose/info"],
)
def test_world_from_gz_topic_rejects(topic):
"""认不出来必须返回 None(调用方据此**跳过**复位并说清楚),不能猜。
猜错的后果是"服务名拼错了",而 `ign service` 对不存在的服务只会超时 ——
现象就变成"复位偶发失败",没人会连到 world 名上。
"""
assert world_from_gz_topic(topic) is None
def test_yaw_to_quat_matches_rotation():
for yaw in (0.0, 0.3, -1.2, math.pi):
x, y, z, w = yaw_to_quat(yaw)
# 绕 Z 的单位四元数 → 旋转矩阵,第一列必须是 (cos yaw, sin yaw, 0)
c, s = math.cos(yaw), math.sin(yaw)
R = [
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
]
assert R[0][0] == pytest.approx(c, abs=1e-12)
assert R[1][0] == pytest.approx(s, abs=1e-12)
assert R[0][1] == pytest.approx(-s, abs=1e-12)
assert (x, y) == (0.0, 0.0)
def test_set_model_pose_yaw_is_the_planar_wrapper(monkeypatch):
"""平面版必须是同一个实现(不是抄一份):这里钉住它转发的内容一样。"""
seen = []
def fake(world, name, pos, quat, timeout_s=0.0): # noqa: ANN001
seen.append((world, name, tuple(pos), tuple(quat)))
return True, "data: true"
monkeypatch.setattr("arm_sim_gazebo.sim_pose_service.set_model_pose", fake)
ok, _ = set_model_pose_yaw("assembly", "workpiece", (0.52, 0.0, 0.40), math.pi / 2)
assert ok
assert seen == [("assembly", "workpiece", (0.52, 0.0, 0.40), yaw_to_quat(math.pi / 2))]
def test_set_model_pose_reports_failure_without_raising():
"""失败只返回 False + 输出:调用方要能"降级继续",不能因为复位失败就崩。
这里用一个不存在的 world(服务不存在)走真命令行路径 —— 只要环境里有
`ign` 就会走到"超时/失败",没有 `ign` 则返回 127,两种都必须是 (False, 原因)。
"""
ok, out = set_model_pose("no_such_world_xyz", "nope", (0, 0, 0), (0, 0, 0, 1), timeout_s=2.0)
assert ok is False
assert out # 必须带原因,不能返回空字符串
+121
View File
@@ -318,6 +318,127 @@ def test_load_scene_records_source_path(tmp_path):
assert load_scene(root=tmp_path).source == p
# ---------------------------------------------------------------------------
# pose_info(世界模型位姿回读)—— 可选段,写错必须当场拦住
# ---------------------------------------------------------------------------
def test_load_scene_without_pose_info_is_fine(tmp_path):
"""**老场景文件没有这一段也必须能读**:它是可选的,缺了只是不做漂移监视。"""
_write_scene(tmp_path, [_obj()])
assert load_scene(root=tmp_path).pose_info is None
def test_load_scene_parses_pose_info(tmp_path):
_write_scene(tmp_path, [_obj()])
p = tmp_path / "devices/sim/gazebo/config/scene.yaml"
doc = yaml.safe_load(p.read_text(encoding="utf-8"))
doc["pose_info"] = {
"gz_topic": "/world/assembly/pose/info",
"ros_topic": "/sim/world_pose_info",
}
p.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
pi = load_scene(root=tmp_path).pose_info
assert pi is not None
assert pi.gz_topic == "/world/assembly/pose/info"
assert pi.ros_topic == "/sim/world_pose_info"
# 类型有默认值:ros_gz_bridge 只认这一对,改了就"桥接器起来但没消息"
assert pi.ros_type == "tf2_msgs/msg/TFMessage"
assert pi.gz_type == "gz.msgs.Pose_V"
assert (pi.drift_tol_m, pi.drift_tol_deg) == (0.002, 2.0)
def test_load_scene_pose_info_missing_topic_raises(tmp_path):
"""只写一半话题名 = 订阅一个不存在的话题 = **永远不会报警**的监视器。
那比没有监视更糟(人会以为有保护),所以必须当场报错,而不是取默认值。
"""
_write_scene(tmp_path, [_obj()])
p = tmp_path / "devices/sim/gazebo/config/scene.yaml"
doc = yaml.safe_load(p.read_text(encoding="utf-8"))
doc["pose_info"] = {"gz_topic": "/world/assembly/pose/info"}
p.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
with pytest.raises(SceneError) as e:
load_scene(root=tmp_path)
assert "ros_topic" in str(e.value)
def test_load_scene_pose_info_not_mapping_raises(tmp_path):
_write_scene(tmp_path, [_obj()])
p = tmp_path / "devices/sim/gazebo/config/scene.yaml"
doc = yaml.safe_load(p.read_text(encoding="utf-8"))
doc["pose_info"] = ["/world/assembly/pose/info"]
p.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
with pytest.raises(SceneError) as e:
load_scene(root=tmp_path)
assert "pose_info 不是映射" in str(e.value)
def test_pose_info_reset_on_start_defaults_off(tmp_path):
"""**默认不搬物体**:老场景文件不写这一项时,行为必须与以前完全一样。
这个开关是"开局把物体 set_pose 回名义位姿",默认开的话等于替所有老场景
改了语义(而且是在运行中搬物体),风险远大于收益。
"""
_write_scene(tmp_path, [_obj()])
p = tmp_path / "devices/sim/gazebo/config/scene.yaml"
doc = yaml.safe_load(p.read_text(encoding="utf-8"))
doc["pose_info"] = {
"gz_topic": "/world/assembly/pose/info",
"ros_topic": "/sim/world_pose_info",
}
p.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
assert load_scene(root=tmp_path).pose_info.reset_on_start is False
def test_pose_info_reset_on_start_must_be_bool(tmp_path):
"""`reset_on_start: "false"` 在 YAML 里是**真值** —— 必须当场拦,不能强转。
这类错误的表现是"开关关了却没关":排查时没人会想到去怀疑引号。
"""
_write_scene(tmp_path, [_obj()])
p = tmp_path / "devices/sim/gazebo/config/scene.yaml"
doc = yaml.safe_load(p.read_text(encoding="utf-8"))
doc["pose_info"] = {
"gz_topic": "/world/assembly/pose/info",
"ros_topic": "/sim/world_pose_info",
"reset_on_start": "false",
}
p.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
with pytest.raises(SceneError) as e:
load_scene(root=tmp_path)
assert "reset_on_start" in str(e.value)
def test_shipped_scene_has_reset_on_start(tmp_path):
"""仓库自带场景**开着**启动复位:不开的话开局 2.6mm 落地偏差会拦住真值点。
实测数据与完整理由写在 scene.yaml 那段注释和
``sim_truth_perception._maybe_startup_reset`` 里。
"""
from arm_sim_gazebo.sim_scene import repo_root
spec = load_scene(None, repo_root())
assert spec.pose_info is not None
assert spec.pose_info.reset_on_start is True
def test_shipped_scene_world_name_matches_the_world_file():
"""scene.yaml 里 pose_info.gz_topic 的 world 名必须与真实 world 文件名一致。
不一致的后果是**静默的**:桥接器订阅一个不存在的话题,永远没有消息,
漂移监视形同不存在 —— 而它正是为了防"静默失效"才加的。
这条测试是那两个名字之间唯一的联系。
"""
from arm_sim_gazebo.sim_scene import repo_root
root = repo_root()
spec = load_scene(None, root)
assert spec.pose_info is not None, "仓库自带的 scene.yaml 应当配了 pose_info"
worlds = sorted((root / "devices/sim/gazebo/worlds").glob("*.world"))
assert worlds, "worlds 目录里一个 .world 都没有?"
world = worlds[0].stem
assert spec.pose_info.gz_topic == f"/world/{world}/pose/info"
# ---------------------------------------------------------------------------
# Bounds
# ---------------------------------------------------------------------------
@@ -30,11 +30,18 @@ import pytest
from arm_sim_gazebo import sim_scene
from arm_sim_gazebo.sim_truth_perception import (
MIN_CAM_Z_M,
RESET_GIVE_UP,
RESET_NOW,
RESET_SKIP,
RESET_WAIT,
STARTUP_RESET_WINDOW_S,
TruthError,
apply_transform,
load_truth_target,
matrix_from_transform,
pick_object,
pose_drift,
startup_reset_decision,
target_point_world,
)
@@ -253,3 +260,105 @@ def test_offset_length_is_checked_not_guessed(tmp_path):
def test_camera_backside_guard_is_positive():
"""背后判据用 z<=0,且阈值是个正数(写成 0 会把贴镜头的点也发出去)。"""
assert MIN_CAM_Z_M > 0.0
# ---------------------------------------------------------------------------
# 漂移判据(真值感知的"前提自检")
# ---------------------------------------------------------------------------
def _obj_at(x=0.52, y=0.0, z=0.40, yaw=0.0):
return sim_scene.SceneObject(name="workpiece", model="workpiece", x=x, y=y, z=z, yaw=yaw)
def _quat_z(yaw_deg: float):
h = math.radians(yaw_deg) / 2.0
return (0.0, 0.0, math.sin(h), math.cos(h))
def test_pose_drift_quiet_when_the_part_is_where_the_scene_says():
"""在位 → 必须**完全安静**。误报会让现场学会忽略这个监视,比没有更糟。"""
assert pose_drift(_obj_at(), (0.52, 0.0, 0.40), _quat_z(0.0), 0.002, 2.0) is None
# 容差边界内也算安静(1mm / 1°)
assert pose_drift(_obj_at(), (0.521, 0.0, 0.40), _quat_z(1.0), 0.002, 2.0) is None
def test_pose_drift_reports_the_case_we_actually_hit():
"""2026-09-24 的真实故障:工件掉进台面通槽、落到地面(z≈0)还转了半圈。
这一拍必须报,而且要把**名义/实际/偏差**都写出来 —— 现场看到这句话就该
立刻去检查工件,而不是去调检测阈值。
"""
msg = pose_drift(_obj_at(), (0.626, -0.034, 0.0), _quat_z(-54.0), 0.002, 2.0)
assert msg is not None
assert "名义" in msg and "实际" in msg
# 掉下去约 0.4m(连同 XY 偏移合计 415mm),这个量级必须一眼看到
assert "415.2mm" in msg
assert "-54.0°" in msg
def test_pose_drift_sees_tilt_not_just_yaw():
"""姿态差用**整段旋转差**:板被碰一下常常是"翘起来夹着转一点"。
只比 yaw 会漏掉纯翘起 —— 而翘起同样把孔在图上挪走。
这里绕 x 轴翘 5°、yaw 不变:必须报。
"""
h = math.radians(5.0) / 2.0
quat_tilt = (math.sin(h), 0.0, 0.0, math.cos(h))
assert pose_drift(_obj_at(), (0.52, 0.0, 0.40), quat_tilt, 0.002, 2.0) is not None
# 而只翘 1°(在 2° 容差内)应当安静
h1 = math.radians(1.0) / 2.0
assert pose_drift(
_obj_at(), (0.52, 0.0, 0.40), (math.sin(h1), 0.0, 0.0, math.cos(h1)), 0.002, 2.0
) is None
def test_pose_drift_measures_a_part_with_nonzero_yaw():
"""名义 yaw 不是 0 时(现场摆歪了)也要比得准:转回同一角度 = 安静。"""
obj = _obj_at(yaw=math.radians(30.0))
assert pose_drift(obj, (0.52, 0.0, 0.40), _quat_z(30.0), 0.002, 2.0) is None
assert pose_drift(obj, (0.52, 0.0, 0.40), _quat_z(0.0), 0.002, 2.0) is not None
# ---------------------------------------------------------------------------
# 启动复位判据(把"spawn 落地的 2~3mm"和"被撞走"分开)
# ---------------------------------------------------------------------------
def test_startup_reset_only_fires_inside_the_window():
"""**运行中绝不搬物体**:过了窗口一律 give_up,交给漂移看门狗报错。
这是这一整套里最要紧的一条:在运行中悄悄把物体搬回去,等于把
"谁把它撞歪了"的证据抹掉,还可能把工件从爪里拽走。
"""
d, _ = startup_reset_decision(
now_s=100.0, deadline_s=20.0, window_s=20.0, pose_known=True, any_drift=True
)
assert d == RESET_GIVE_UP
def test_startup_reset_waits_until_it_knows_the_pose():
"""还没收到 pose/info 帧就不能判断"要不要搬" —— 必须等,不能瞎搬。"""
d, _ = startup_reset_decision(
now_s=1.0, deadline_s=None, window_s=20.0, pose_known=False, any_drift=True
)
assert d == RESET_WAIT
def test_startup_reset_is_silent_when_the_part_is_in_place():
"""物体本来就在名义位姿 → 什么都不做(没有漂移就没有理由碰它)。"""
d, _ = startup_reset_decision(
now_s=1.0, deadline_s=None, window_s=20.0, pose_known=True, any_drift=False
)
assert d == RESET_SKIP
def test_startup_reset_fires_for_the_settle_case_we_actually_hit():
"""2026-09-24 的真实开局:落稳后偏 2.6mm(越过 2mm 容差)→ 搬回名义位姿。"""
d, dl = startup_reset_decision(
now_s=1.0, deadline_s=None, window_s=20.0, pose_known=True, any_drift=True
)
assert d == RESET_NOW
assert dl == pytest.approx(21.0) # 截止时刻 = 首次判定时刻 + 窗口
def test_startup_reset_window_is_bounded_and_positive():
"""窗口必须有界:`None`/0 会让"要不要在运行中搬物体"变成不可控。"""
assert STARTUP_RESET_WINDOW_S > 0.0
assert STARTUP_RESET_WINDOW_S <= 120.0
@@ -293,3 +293,65 @@ def test_object_bbox_px_clips_to_image():
x1, y1, x2, y2 = bbox
assert 0 <= x1 <= x2 <= IMAGE[1] - 1
assert 0 <= y1 <= y2 <= IMAGE[0] - 1
# ------------------------------------------------- 真值三维(2026-09-24 新增)
def test_locate_holes_also_returns_exact_world_geometry(tmp_path):
"""真值必须**同时**给三维 —— 调用方不该再去反投影。
为什么钉这条:仿真里孔是贯通孔、孔位正下方就是台面通槽,深度相机在孔位
看到的是槽底/地面(0.62m)而非工件面(0.21m)。stage2 原先就拿那些像素去
查深度图反投影,于是 58mm 的孔距算成 169mm、被间距门槛丢掉,
现象是"S05 每拍都丢、最后超时",看起来像检测坏了。
真值本来就精确知道孔在世界系哪儿 —— 这条测试保证它一直带出来。
"""
spec = _build_repo(tmp_path)
res = locate_holes(_cam_looking_down((0.50, 0.0, 0.632)), INTR, IMAGE,
spec=spec, root=tmp_path)
assert res.ok, res.why
assert len(res.hole_world) == 2
# 环的局部 z=0.010,物体 z=0.40 → 世界 z=0.410
for (x, y, z) in res.hole_world:
assert z == pytest.approx(0.410, abs=1e-9)
assert y == pytest.approx(0.0, abs=1e-9)
# 世界系孔距必须**正好**是 58mm(不像像素那样有透视缩放)
d = math.dist(res.hole_world[0], res.hole_world[1])
assert d == pytest.approx(2 * RING_X, rel=1e-9)
# 上表面中心与上表面 z:板厚 12mm,底面 z=0.40
assert res.part_top_z == pytest.approx(0.412, abs=1e-9)
assert res.part_center_world is not None
assert res.part_center_world[2] == pytest.approx(0.412, abs=1e-9)
assert res.part_center_world[:2] == pytest.approx((0.50, 0.0), abs=1e-9)
def test_world_geometry_follows_yaw(tmp_path):
"""物体带 yaw 时,真值三维必须跟着转 —— 否则"真值"是错的。
只比像素会漏掉这类错:像素上转 90° 的两孔与没转的两孔**看起来都合理**,
但世界坐标差了一整条孔距。
"""
_build_repo(tmp_path)
cfg = tmp_path / "devices/sim/gazebo/config/scene.yaml"
doc = yaml.safe_load(cfg.read_text(encoding="utf-8"))
doc["objects"][0]["pose"]["yaw"] = math.pi / 2
cfg.write_text(yaml.safe_dump(doc, allow_unicode=True), encoding="utf-8")
spec = sim_scene.load_scene(root=tmp_path)
res = locate_holes(_cam_looking_down((0.50, 0.0, 0.632)), INTR, IMAGE,
spec=spec, root=tmp_path)
assert res.ok, res.why
# 两个孔都转到 y 方向:|Δy| = 58mm,|Δx| ≈ 0
dx = abs(res.hole_world[0][0] - res.hole_world[1][0])
dy = abs(res.hole_world[0][1] - res.hole_world[1][1])
assert dx == pytest.approx(0.0, abs=1e-9)
assert dy == pytest.approx(2 * RING_X, rel=1e-9)
def test_locate_holes_without_two_rings_has_no_world_geometry(tmp_path):
"""失败时不能留下半份三维 —— 那样调用方会拿着空列表去当坐标用。"""
spec = _build_repo(tmp_path, rings=((0.0, 0.0),))
res = locate_holes(_cam_looking_down((0.50, 0.0, 0.632)), INTR, IMAGE,
spec=spec, root=tmp_path)
assert not res.ok
assert res.hole_world == []
assert res.part_center_world is None
assert res.part_top_z is None