Files
ros_contorl/docs/FRAMEWORK_RULES.md
T
arm_dev c1cd862eea 真机落地前的仿真补齐:夹持/腕力/真值位姿,让仿真能验证 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。
2026-09-28 10:17:06 +08:00

815 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 框架规则(`docs/FRAMEWORK_RULES.md`)
> **这份文档给两类读者看:**
> - **改这个仓库的 AI**:开工前先读,逐条遵守。这些不是风格建议,是**硬规矩** ——
> 违反它们的改动会在真机上以"不报错但结果是错的"方式失败,而这正是本仓库
> 花最多代价在防的事。
> - **review 人**:每条例规都有编号(`R1`…),可以直接引用,例如
> "这条违反 R7.2"。
>
> 配套:`.cursor/rules/*.mdc` 是给 Cursor 的常驻规则(自动注入),内容是本文件的
> 压缩版;本文件是**唯一权威**,两者冲突时以本文件为准。
>
> 规则是可以改的 —— 但必须改这里,而不是在代码里偷偷绕开。改规则时写清**为什么**。
---
## 0. 三条元规则
其余所有规则都是从这三条推出来的。拿不准某件事该怎么做时,回到这三条。
### M1. 静默失效是头号敌人
本仓库遇到的大部分问题都不报错:**话题在、图像有、TF 查得到、日志干净,结果是错的。**
所以:
- 任何"检查 / 校验 / 验收"都必须**能真的失败**。写完先问一句:
"我故意把它弄坏,这条检查会红吗?" 不会红 = 这条检查等于没有(见 `R5.2`)。
- 宁可**当场报错**,也不要"宽容降级"。降级会把"没检查"伪装成"检查通过"。
真实案例:位姿解析函数格式对不上,于是永远返回空、调用方按设计"跳过位姿核对",
结果位姿检查从来没跑过,而日志只说"跳过"(见 `R5.4`)。
- 校验代码自己也会静默失效。凡是"检查 X 是否为空"的地方,都要问:
"它会不会被填成一个**假值**?"(真实案例:`sdformat` 用 `__default__` /
`__default_topic__` 填缺失字段,见 `R4.6`)
### M2. 单一数据源
同一个事实**只允许写一处**,其余全部派生。
- 能算出来的不要抄(内参由视场与图幅算、相机位姿从 world 文件解析)。
- 必须抄的(如仿真侧要提前知道像素门槛)**必须有人守着**:
用脚本交叉核对真机配置,不一致就判失败(`R2.6`)。
- 同一份事实出现两处时,一定有一处会先过期。过期的那处不会报错。
### M3. 口径必须显式
**单位、坐标系、符号、参考基准**必须写下来,并且**只在一个地方换算**。
- 毫米还是米?相对零还是相对基线?压下去为正还是为负?
这些都不写清就会"数值看起来合理、语义是错的"。
- 换算只做一次,放在适配层(相机适配器、触觉适配器),下游不许再猜。
---
## 1. 配置与数据
### R1.1 分层优先级固定:命令行 > `site.yaml` > 包内默认
`config/site.yaml` 是**站点数据**(SN/IP/相机序列号/锁高/权重路径),**不进 git**;
包内 `config/*.yaml` 是默认值,被 site 覆盖。
**禁止**在代码里硬编码站点值。真实反例:15 处硬编码的 `Rizon4-063989`
(见 `R8` 的清单),换一台臂就会静默连错机器。
#### R1.1.1 含臂名的一级配置分两类,别混
Flexiv 官方把 SN 编进了关节名(`Rizon4-063989_joint1`),所以"含 SN 的文件"
天然分成两类。**新增文件时必须先归类**,归错类就是下一颗哑弹:
| 类 | 特征 | 换臂时 | 例子 |
|----|------|--------|------|
| **跟随当前臂** | 描述"这台臂的接口/结构",只可能有一个正确值 | **必须改**,否则控制器报关节不存在 | `arm_control/config/controllers.yaml`、`named_poses.yaml`、`arm_moveit_config/config/moveit_controllers.yaml`、`arm_moveit_config/srdf/arm.srdf`、`arm_bringup/config/bringup.yaml`、`arm_description/config/joint_names.yaml`、`arm_application/config/servo.yaml`、`xunhuan_trajectory.yaml` |
| **按臂各留一份** | 记录某台臂的**实测结果**,多台共存是对的 | 不用改,该**新增**一份(如 `named_poses_91.yaml`) | `config/site.yaml`、`named_poses_89/91.yaml`、`arm_application/config/dual_grippers.yaml`、`handeye/waypoints.yaml` |
第一类**一定要显式检查**,因为漏改的症状有两种,而且都能伪装成别的问题:
- 控制器起不来 → `Joint 'Rizon4-063989_joint1' not found`(这条还算好查);
- URDF 换了名字、SRDF 没换 → MoveIt 规划成功但**机械臂不动**(难查得多)。
所以有一把专用尺子:
```bash
python3 scripts/check_arm_prefixes.py # 一致则 0,不一致逐条列出 file:line + 修法
python3 scripts/check_arm_prefixes.py --list # 只看当前臂的应然前缀,以及它来自哪儿
```
**换臂的完整动作**(少一步就会出上面那种症状):
1. 改 `config/site.yaml`(`robot.sn` + `robot.robots[]`);
2. `source scripts/env.sh --arm <新臂>`;
3. `python3 scripts/check_arm_prefixes.py` —— **必须输出 OK**;
4. `bash scripts/build.sh`(让 `install/` 同步)。
> ⚠️ 第 3 步为什么不能省:这个脚本的 `_SN_TOKEN` 正则第一版写成
> `\bRizon\d+-\d{6}\b`,结果 SRDF 里 24 处硬编码**一个都没报**(`_joint1` 的
> 下划线属于 `\w`,`\b` 不成立)。检查器的漏洞和配置的漏洞长得一模一样,
> 所以它自己也必须有负向测试(见 R5.2)。
> ⚠️ `ROBOT_SN` / `ROBOT_ARM` 环境变量会**覆盖** `site.yaml` 的 `robot.sn`
> (为了支持"这个终端临时用另一台臂")。排查"明明改了配置却没生效"时,
> 先 `echo $ROBOT_SN` —— `check_arm_prefixes.py --list` 会直接告诉你值从哪来。
### R1.2 新增 `site.yaml` 键必须二选一:**接线** 或 **明确告警**
声明了却没接线的键是最难查的一类问题:用户按文档改了 `site.yaml`,行为毫无变化,
也不报错。所以每个新键都要么真的接线,要么在启动时**告警**(`wired: false` +
`unwired_warnings()` 那套做法)。
> ⚠️ 现状:`wired:` 目前只是 `manifest.yaml` 里的**注释性约定**,
> `arm_interfaces.manifest` 并不解析它。所以"未接线"的保障实际来自
> `sim_site.unwired_warnings()` 这类**代码里的告警**,不是清单。
> 加键时不要指望清单替你守法。
### R1.3 设备一律走 `devices/<kind>/<brand>/`,并带 `manifest.yaml`
```
devices/<kind>/<brand>/
├── manifest.yaml # id/label/kind/vendor/version + assets + driver + capabilities + models + config_schema
├── driver/ # 与厂商 SDK 对接的实现(可替换)
└── models/<型号>/ # 该品牌的型号专属文件
```
- `kind` 用固定的那几个(`robots` / `grippers` / `cameras` / `sensors` / `sim`),
目录名要与 `arm_interfaces.KIND_FOLDERS` 一致。
- **换品牌 = 新增一个目录**,不是改现有目录。上层按 `manifest.yaml` 选型,
不认具体品牌名。
- `assets[].required: true` 的物料缺失时,框架会报错并给出**格式与命名要求**
(不只是"文件不存在")。
### R1.4 权重一律走 `models/`,规则在 `models/README.md`
- 二进制(`.pt/.onnx/.engine`)**不进 git**;`.labels.yaml`(文本标签)**要进**。
- 转换链:`models/staging/` 放待转换的原始文件 → `weights.sh convert` →
自动改名归位到 `models/<分组>/` 并固化标签。
- `models/**/best.pt` 这类**悬空引用**(配置指向不存在的文件)必须修掉或改成实际存在的,
查法:`weights.sh doctor`。
### R1.5 什么入库、什么不入库
| 入库 | 不入库 | 理由 |
|------|--------|------|
| `src/` `scripts/` `docs/` `devices/` `config/site.example.yaml` | `config/site.yaml` | 站点数据含本机 IP/序列号 |
| `models/README.md`、`models/**/*.labels.yaml` | `models/**/*.{pt,onnx,engine}` 等 | 二进制体积大、每台机器重转 |
| `handeye/poses/` `handeye/results/` | `handeye/tmp/` | 示教点与标定结果是用真机时间换来的**资产**,要能回滚 |
`config/site.yaml` 改了以后**必须**重跑 `python3 scripts/gen_site_example.py`,
否则模板会漂移(有一条 parity 测试会红)。
---
## 2. 几何、单位与坐标系
### R2.1 单位一律米与弧度
SDF / URDF / 几何计算里不允许出现毫米。用毫米建模的 mesh 必须先缩放。
**例外且必须显式**:真机 RealSense 的深度图是 `16UC1` **毫米**(见 `R2.3`)。
### R2.2 相机的 `frame_id` 必须是**光学系**
ROS 的 `image` / `camera_info` 必须用光学约定(Z 朝前 / X 朝右 / Y 朝下,REP-103/105),
不能用相机本体系(X 朝前 / Y 朝左 / Z 朝上)。两者差 `rpy=(-90°, 0, -90°)`。
盖错 frame 的后果:`tf2` 查询**能查到、不报错**,但反投影出的三维点整体转错,
而单目预览完全看不出来。
### R2.3 深度:真机是毫米,Gazebo 是米 —— 必须真换算
| 来源 | 编码 | 单位 |
|------|------|------|
| 真机 RealSense aligned_depth | `16UC1` | 毫米,`0` = 无读数 |
| Gazebo 深度相机 | `32FC1` | 米 |
直接改个名字转发,应用侧按毫米解释会整体差 1000 倍。换算只做一次,
放在 `sim_camera_adapter`。
### R2.4 `rpy` 的复合顺序是 `Rz·Ry·Rx`
不是 `Ry·Rz·Rx`。单轴旋转看不出来,多轴组合才暴露。
**URDF 相机的视线是本体 X 轴**("X 朝前"约定)。所以"先俯 50° 再偏航 180°"
会把视线翻到**背面**。D435 就这么翻过:背对台面只看得见地面,什么都不报,
**而且几何自洽**(点确实落在真实地面上)。
### R2.5 Gazebo 发的 `camera_info` 不可信
Fortress 恒发 `fx=fy=277, cx=160, cy=120`(它的默认 60° 视场 + 半分辨率主点),
与真正渲染用的视场无关。内参必须由 `cameras.yaml` 的 `horizontal_fov` 与图幅**算出**,
并覆盖 gz 发的那份。
### R2.6 像素门槛与**图幅**绑定
`hole_detect.yaml` 的门槛(`hough_min_radius`、`hole_distance_min`)是**像素**量,
只在特定分辨率下有意义。改图幅要同时改三处(`cameras.yaml`、SDF、
必要时重算真机门槛),启动时会比对并告警。
### R2.7 位姿检查要有容差,且容差要说明理由
物体的落座微动是正常的。卡到 `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. 触觉与力
### R3.1 力的语义是"相对静止基线的增量"
真机 Orisys 输出的是形变任意单位(非牛顿),且基线会漂移,所以全链路
(含下游阈值)都以**相对基线的增量**为语义。仿真必须做同样的事:
不减基线,下游阈值就对不上。
基线要慢跟踪(只在确认无接触时更新),否则换姿态后的残余会被当成接触。
### R3.2 `fnormal` 压下去为正,且 clamp 到非负
两指符号一致。这条是**实测**结论(`config/tactile.yaml` 里有实测记录),
不是从 URDF 推导出来的。
### R3.3 轴向映射必须实测,不许推导
哪个轴是"指腹法向"要用实验确定(夹一块大平面薄板等稳态)。猜错不会报错,
只会让所有力的语义静默错位。实测方法与结果记在 `config/tactile.yaml`。
用小方块做这个实验是错的:点/边接触会让法向偏、摩擦混进来。
### R3.4 接触布尔必须用**超时**判据
Gazebo 的接触消息是**事件式**的:接触时发,离开时**一条都不发**。
写成"收到=接触 / 没收到=不管",离开工件后 `contact` 会永远停在 `True`。
判据 = "最近 `contact_timeout_s` 内收到过消息"。
### R3.5 仿真与真机的力的量级不同,`force_scale` 是必须确认的口径
仿真输出 = Gazebo 牛顿 × `force_scale`。`force_scale = 1.0` 时输出牛顿,
**不能**直接套用真机那套阈值(250/600)。位置控制器是"刚性地压进去"的,
实测能到几百。
选错口径不报任何错:阈值只是"不触发"或"一碰就炸",看起来像算法问题。
### R3.6 视触觉的**图像**在仿真里只能是占位
仿真没有弹性体成像,深度图/力矢量图只能是空/NaN。话题与消息结构要保留
(下游不用改代码),但要打一次明确日志说明它不是真实数据。
---
## 4. 仿真(Gazebo)
### R4.1 仿真与真机**共用同一套 `site.yaml` 参数**
任何"仿真专用参数"都是未来"仿真里调好的值搬到真机不生效"的隐患。
只允许**物理引擎自身的**参数(重力、步长)放在 `sim.*` 下。
### R4.2 `world` 里声明任何一个 `<plugin>` 都会挤掉默认系统
声明了任何插件后,Gazebo **不再**加载默认的 Physics / UserCommands / SceneBroadcaster。
少了 UserCommands 的后果最阴:`/world/<名>/create` 服务不存在 → 模型根本没进 Gazebo,
而 `create` **仍然打印** `OK creation of entity`。
所以这些系统必须在 world 里**显式声明**(含 `contact-system`、`forcetorque-system`)。
### R4.3 插件与资源的搜索路径必须在 `ros2 launch` **之前**设好
conda 激活不会往 `LD_LIBRARY_PATH` 加东西,而 gz 的插件搜索路径需要
`$CONDA_PREFIX/lib`。症状是 `couldn't find shared library` 而库其实就在那儿。
**不要绕过 `scripts/sim_env.sh` 直接 `ros2 launch`。**
### R4.4 上游描述(`flexiv_description` 等)**不许直接改**
第三方包一律在**启动时归一化**,不在上游文件里下手:
- 缺 `<inertial>` / 零质量 link → NaN 惯性、物体消失
- `<mimic>` 标签被 `sdformat` **丢弃** → 夹爪"控制器 active 但手指不动"
- 无质量的根 link(world link)会**连整棵子树被静默丢掉**
改上游还有个额外代价:下次 `fetch_flexiv.sh` 会把你的改动覆盖掉。
### R4.5 场景物体走 `scene.yaml`,不进 world
`world` 是"环境"(地面/台面/相机/系统插件),`scene.yaml` 是"这一批活做什么"。
动 world 的风险面太大(`R4.2`)。
模型约定:**原点在底面中心**、单位米、目录名 = `<model name>` = `model.config`
里的 `<name>` = 推理类别名。
### R4.6 模型/场景校验必须逐**碰撞体**、三维判
- 不能拿总包围盒判重叠:正常装配里本来就有嵌套(规立在规座方孔里),
粗判会把正常嵌套报成碰撞,逼着人把检查关掉。
- 恰好贴合不算重叠(规的台肩正好坐在规座顶面上)。
- 变换必须用"中心 + 半宽":对 min/max 取绝对值再对称化会把偏移量吃掉、
盒子凭空变大。
- 校验要用**从 SDF 解析出来的**几何,不能在 `scene.yaml` 里再抄一份尺寸。
### R4.7 建模:碰撞体用简单几何,视觉可以加"只给算法看"的细节
`<collision>` 决定物理,用 box/cylinder;`<visual>` 决定相机看见什么,
可以加亮环、色块这类只服务于检测的东西。
工件的碰撞体是方孔、视觉是圆环,**这是刻意的**:真机的 HoughCircles 靠亮环找孔,
只按碰撞体建视觉会让孔检测在仿真里永远失败,而那看起来像算法问题。
> 由此产生的约定:`visual` 的**名字有意义**(`insert_*_ring` / `center_bore`),
> `sim_scene.features_from_workpiece` 按名字前缀读特征。改名字要同步改那个函数。
### R4.8 夹爪/被夹物体的接触刚度要按"位置控制"的实际行为调
位置控制器会"刚性地压进去",默认刚性接触会把物体弹飞。需要放软时要**写明理由**
(照抄现成参数前先想清楚你的物体是不是也要被夹)。
### R4.9 不用"有图像 / 有话题 / TF 查得到"验收
这些只能证明"链路通"。验收必须是对**物理量**的断言:
| 工具 | 判据 |
|------|------|
| `scripts/check_sim_cameras.py` | 深度反投影必须命中已知平面(地面 z=0 / 台面 z=0.40) |
| `scripts/check_sim_scene.py` | 摆放自洽 + 像素门槛与真机一致 + 成像几何达标 + 手外相机**完整**看得见工件 |
| `scripts/check_sim_gripper.py` | 命令开口 mm → 两指尖在 `world` 里的**实际间距**变化同样的 mm;两侧行程相等;指尖坐标在 `--verbose` 里打出来(判错时可以回看它到底取了哪两个点) |
### R4.10 停仿真用 `scripts/stop_sim_gz.sh`,不要 `pkill -f`
`pkill -f` 会先匹配到**调用它的那个 shell**(它的命令行里含同样的字符串),
表现是"命令秒返回、什么都没停"。
**更重要的是:残留进程比"没停干净"严重得多**(R4.11 解释为什么)。
脚本走两条路:进程组(主)+ 模式扫描(兜底),杀完**复查残留**并非零退出。
### R4.11 仿真栈必须能"整组停掉":残留会伪造出"代码写错了"的现场
实测(2026-09-24)踩到的两个残留,症状与"夹爪换算写错了"**一模一样**:
| 残留的进程 | 它干了什么 | 现象 |
|-----------|-----------|------|
| `sim_gripper_adapter`(旧会话的) | 与新适配器**同时**订阅 `/joint_states`、同时发布 `/gripper/status`;服务调用被随机分给其中一个 | 验收读到"开口恒为 0 mm、指尖间距 0.03 mm"(真实 127 mm) |
| `static_transform_publisher`(5 个孤儿) | 发 **latched** 的 `/tf_static`,把旧相机外参一直按在新仿真的 TF 树上 | TF 查询返回互相矛盾的数据 |
所以定下三条:
1. 仿真必须整栈起在**自己的会话/进程组**里(`setsid`,PGID 记在 `log_sim/sim.pgid`),
停止时 `kill -TERM -PGID` 一次带走 —— 不依赖任何"进程名模式表";
2. 模式扫描只当兜底,且除点名表外要有**兜底规则**(命令行含 `<仓库>/install_sim/`
就算仿真进程),这样"以后新增节点忘了改表"不会再留孤儿;
3. **启动前预检 + 验收第一步查重名节点**:宁可拒绝启动,也不给一个
"看起来能跑、数据是错的"仿真。
### R4.12 验收工具自己也会说谎:判据要挑"与实现无关的物理量"
`check_sim_gripper.py` 的第 3 层先后给过**两个错误结论**,都是脚本自己的问题:
| 写出来的判据 | 为什么会错 |
|-------------|-----------|
| 指尖间距差(米)与期望(毫米)比较 | 漏了 ×1000:127 mm 显示成 0.13 mm,**恰好看起来像夹爪没张开** |
| "两指中点漂移"判对称 | tip frame 固连在**会转动**的指垫上、走的是弧线,中点必然位移 16 mm;而左右行程其实相等(52.56 / 52.56 mm) |
推论:
- 单位换算**只在一个函数里做**(`_dist_mm`),别处不许再乘 1000;
- 判据要挑**与机构无关**的量("两侧各走了多远"),别挑带方向的量再投影;
- `--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. 代码与测试
### R5.1 纯逻辑要抽成纯函数,才可能被钉住
把"数据变换"从 ROS 节点里抽出来(`sim_cameras`、`sim_tactile`、`sim_scene`),
因为静默失效点的错误**不会在运行时暴露**:轴向映射反了照样"有力、会响应、
松开归零"。抽出来才有单测可写。
### R5.2 每条"检查/校验"都要有能证明它有效的测试
尤其是**负向**测试:故意造出坏数据,断言检查会报错。
只测"好数据通过"的测试,无法区分"检查有效"和"检查恒真"。
### R5.3 不许把"降级跳过"写成"通过"
调用方必须把降级当作**告警**打印出来并说明原因。真实反例见 `R5.4`。
### R5.4 解析外部输出:先确认真实格式,再写解析器
外部工具(`ign`、`sdformat`、厂商 SDK)的文本输出**没有稳定契约**,
而且格式与直觉不一致是常态。
先用真实输出跑一遍看清楚了再写解析;解析失败要返回"未知"而不是"零"。
真实反例:按 `x: N` 键值对写解析器,而 Fortress 实际输出是方括号两行,
于是位姿检查从来没跑过。
### R5.5 删除 / 重构前先做全仓库引用扫描
不靠印象。反例:一个 `assets_root()` 函数指向从未存在的 `assets/` 目录、
零调用者,长期没人发现(见 `R8`)。
### R5.6 不要在中文路径下 `colcon build`
ROS 构建对非 ASCII 路径会出各种问题。工作目录保持 ASCII(推荐 `~/ros2`)。
### R5.7 真机与仿真的构建产物必须隔离
`build/ install/ log/` 是真机;`build_sim/ install_sim/ log_sim/` 是仿真
(`scripts/build_sim.sh`)。两套并存是刻意的:同一份源码,两个环境的依赖路径不同,
共用 `install/` 会互相覆盖。
**但两者的"生效方式"不一样,这一点比隔离本身更容易坑人**:
| 环境 | 构建 | 产物形态 | 改了源码之后 |
|------|------|----------|--------------|
| 真机 | `build.sh`(`--symlink-install`) | **软链** | 立即生效 |
| 仿真 | `build_sim.sh`(**有意不加** `--symlink-install`:仿真 conda 的 setuptools 旧,不认 `--editable`) | **拷贝** | **必须重新构建**,否则跑的是旧拷贝 |
所以"在仿真里验证一个改动"之前,先确认 `install_sim/` 里的那份和源码一致:
```bash
diff src/arm_application/arm_application/<文件> \
install_sim/arm_application/lib/python3*/site-packages/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 里
各写一份,退化分支(180°/90°)的公式**各不相同** —— 也就是"同一个手眼 TF,
不同 launch 发出来不是同一个",而四种都能"看起来正常"。
规矩:
- 换算/解析只留一处(本例:`handeye_lib.read_transform_yaml`);
- 加一条**守护测试**扫描调用方,禁止它们再出现私有实现:
见 `test_handeye_lib_roundtrip.py::test_launch_files_do_not_reimplement_the_quaternion_math`;
- 测试要覆盖**分支点**(本例用 180°/270° 这些会让公式退化的角度),而不是只测一般值。
### R5.9 自检/探头里的"期望类型"必须与调用处同源
S00 自检曾把 `arm_jtc` 的期望类型写成 `arm_msgs/action/FollowJointTrajectory`,
而流程真正调用的是 `ros2_control` 的 `control_msgs/action/FollowJointTrajectory` ——
于是自检**永远**说"类型不对",而人只会去怀疑服务本身。
规矩:期望类型/话题名/服务名不要在两处各写一遍字面量,取同一个来源
(常量或构造函数);自检报"类型不对"时要说清**期望什么、实际什么**。
### R5.10 环境缺依赖就 `skip`,不要让"红"变成常态
仿真环境没装 `onnx`(只有真机 conda 装了),14 个用例长期报红。
长期红的结果是**没有人再看测试结果** —— 真出问题时也看不出来("狼来了")。
规矩:缺依赖用 `pytest.importorskip(...)` 并写清原因;只有"本环境本该能过"的
用例才允许失败。
---
## 6. 文档与提交
### R6.1 文档与实现不一致时:以实现为准,**立刻**修文档
文档里"已就绪 / 未接线"必须**如实**标注。看到 ✘ 不要凭印象改成 ✔。
### R6.2 每份文档要有"读法"和"这份文档记录什么事实"的说明
本仓库的文档面向"明天就要动手的人",所以要写清:先读哪节、哪些是硬事实、
哪些是踩过的坑、怎么验证。
### R6.3 结论性数字必须能复现
写进文档的性能/几何数字(如"台面命中 11.9%")要么给出命令,要么给出脚本名。
### R6.4 提交信息:中文,讲**为什么**,附实测验收数据
格式:`<阶段号> <一句话结果>` + 正文说明"问题是什么、为什么这么改、怎么验证的"。
修静默失效时,把"症状"写进去 —— 那是后来人搜索时唯一能匹配上的东西。
### R6.5 注释、docstring、提示信息一律用中文
现场操作的人读中文。所以本仓库(`src/arm_*/**`、`scripts/**`)里:
- **注释、docstring、`print`/日志/异常里给人看的句子 = 中文**
- **标识符、API 名、话题名、参数名、字段名、文件名、命令名 = 保留英文原文**,
写在中文句子里时用双反引号包起来,例如 ``SITE_ROS2_ROOT``、``devices/<kind>/<brand>/``
- 单位、十六进制、协议报文、CRC 值、寄存器地址、命令示例**原样保留**
**例外:`src/flexiv_*/` 是厂商上游代码,不许改、也不许翻译**(见 R4.4)。
只改文本的提交,必须能自证"没碰到逻辑"。做法:把新旧两份源码都解析成 AST,
删掉 docstring、把字符串常量抹平、把 f-string 只留"插值了几次 + 插值是什么",
再比对 —— 必须完全相同。这条检查会抓出"顺手改名""顺手调整语句顺序""漏掉一个
f-string 插值"这类问题;而"为了中文语序把两个插值换个显示顺序"属于允许的改动。
工具:`python3 scripts/check_text_only.py`(基准是 `git HEAD`,所以要在提交前跑)。
### R6.6 行尾一律 LF;验脚本要**真跑**,不能只看 `bash -n`
行尾由 `.gitattributes` 兜住(`* text=auto eol=lf`,厂商上游 `-text` 不动)。
为什么专门立一条:`scripts/run_hole_detect.sh` 和 `scripts/run_dual_gripper.sh`
曾经是 CRLF,于是 bash 把行尾的 `\r` 当成标识符的一部分,
`case "$1" in\r`、`set -eo pipefail\r` 全部变成非法记号 ——
**文件在、五处文档说能用、命令一敲就死**,而报错信息里的 `\r` 根本看不出来。
更坑的是:`bash -n` 对其中一个还返回 OK,实际执行立刻死在第 6 行。
所以"静态检查通过"不等于"脚本能用":
- 验语法:`bash -n`(必要但不充分,CRLF 会漏报)
- 验能用:`bash <脚本> --help`
- 验行尾:`file <文件>`,或看 `.gitattributes` 有没有覆盖到
### R6.7 验收命令必须是**真跑过**的
写进文档的任何命令,都要当场执行一遍再写。理由是这个仓库已经踩过两次:
- `run_hole_detect.sh` / `run_dual_gripper.sh` 是 CRLF,五处文档让用户跑,
一敲就死(R6.6);
- `bash -n` 对其中一个还报 OK。
推论:
- 文档里的命令**复制粘贴要能跑**,别凭记忆写;
- 外链先确认目标文件存在(改完文档跑一次链接检查);
- 文档说"已就绪"的能力,要能指出**用哪条命令**证明它已就绪(R6.1)。
指不出来的,就老实标 ❌。
### R6.8 真机与仿真的 `install/` 产物会互相污染(已发现,待修)
`colcon build` 会把**当时激活的那个 python 的绝对路径**写进每个 console script 的
shebang。于是:
```
$ head -1 install/arm_interfaces/lib/arm_interfaces/device_check
#!/home/cc/anaconda3/envs/ros_gz_sim/bin/python3.11 ← 仿真环境的 python
```
`.gitattributes` 管不到这件事,`install/` 也不入库,所以它只能靠**规矩**和**检查**:
```bash
# 真机 install/ 里有多少入口脚本的 shebang 指向仿真环境(正常应当是 0)
grep -l ros_gz_sim install/*/lib/*/* | wc -l
```
当前实测:`install/` 是 **31 / 59**,`install_sim/` 是 31 / 55(后者是对的)。
即真机产物是在仿真 conda 环境里构建出来的,违反 R5.7。
**为什么现在没炸**:两个 conda 环境的 Python 依赖当前**完全相同**
(`pyrealsense2` / `serial` / `cv2` / `numpy` / `torch` / `ultralytics` / `tensorrt` 都在),
所以指错了也照样 import 成功。这是一颗**哑弹**:哪天某个环境多装/少装一个包,
真机栈就会以"模块找不到"的形式炸,而人是不会想到去查 shebang 的。
**修法**:在真机 conda 环境里重建 `install/`:
```bash
source scripts/env.sh # 确认 python3 指向 ros_flexiv
./scripts/build.sh # 只重建 arm_* 与 flexiv_description
grep -l ros_gz_sim install/*/lib/*/* | wc -l # 应当变成 0
```
**预防**:构建脚本开头应当**断言**当前 conda 环境与目标一致(待补)。
### R6.9 硬退出(`os._exit` / `sys.exit`)前必须先把 traceback 打出来
节点入口一度直接 `os._exit(1)`,现场现象是"**退出码 1,日志里什么都没有**",
只能靠猜。规矩:
```python
try:
...
except Exception:
traceback.print_exc() # 先落日志
os._exit(1) # 再硬退
```
同理,`main()` 里凡是"exit 非 0 却没有任何输出"的路径,都算缺陷。
### R6.10 `ros2 launch` 的退出码**恒为 0**,判成败必须另有依据
实测(Humble):子进程 `exit 3`,`ros2 launch` 仍返回 **0**。所以任何
"`ros2 launch …` 之后看 `$?`"的脚本都会把**失败记成成功**,在自动化里极其危险。
规矩:
- 节点把结论**落盘**(`gauge_assembly` 写 `--result-file`,默认 `/tmp/gauge_assembly_result.json`),
脚本/CI 读文件判成败,不读 `$?`;
- "没有结论文件"要**当成失败**并说明(节点在写结论前就退出了),不能当成"没跑";
- 落盘要先于发话题(进程一退,话题里的最后一条就没了)。
另一个同类现象:**流程节点退出不会带走常驻节点**(如 `static_transform_publisher`),
launch 会一直挂着、终端不回提示符。要显式挂 `OnProcessExit → Shutdown`。
---
## 7. 改动前的检查清单
改任何东西之前/之后,逐条过一遍:
| # | 检查 | 对应规则 |
|---|------|----------|
| 1 | 我要改的这个事实,仓库里有几处在写?其余的是派生的吗? | M2 |
| 2 | 我加/改的配置键,真的接线了吗?没接线会告警吗? | R1.2 |
| 3 | 涉及单位/坐标系/符号吗?换算只做了一次吗?写下来了吗? | M3、R2 |
| 4 | 我加的检查,故意弄坏数据它会红吗?有负向测试吗? | M1、R5.2 |
| 5 | 解析外部输出前,我看过真实输出了吗? | R5.4 |
| 6 | 我改的是上游第三方文件吗?(应该走归一化/适配层) | R4.4 |
| 7 | 改完跑了验收工具吗(`check_sim_cameras` / `check_sim_scene`)? | R4.9 |
| 8 | 文档里的状态标注与实现还一致吗? | R6.1 |
| 9 | 提交信息里有"为什么"和实测数据吗? | R6.4 |
| 10 | 我写进文档的命令,自己跑过一遍吗? | R6.7 |
| 11 | 这次构建是在**目标环境**里做的吗?(`head -1 install/*/lib/*/*`) | R6.8、R5.7 |
| 12 | 我碰的文件属于"跟随当前臂"还是"按臂各留一份"?改完跑 `check_arm_prefixes.py` 了吗? | R1.1.1 |
| 13 | 我验收用的判据,是"与被测实现无关的物理量"吗?单位只在一处换算了吗? | R4.12 |
| 14 | 我起过仿真/长跑进程吗?停干净了吗(`stop_sim_gz.sh --list` 应为空)? | R4.10、R4.11 |
---
## 8. 历史反例清单(别再犯)
按"症状"排序 —— 因为出问题时你是先看到症状,再回来查这张表。
| 症状 | 真实原因 | 规则 |
|------|----------|------|
| 三维点整体偏半米,但单目预览正常 | 用了 gz 发的 `camera_info`(恒为 60° 视场的默认值) | R2.5 |
| 深度差 1000 倍 / `frombuffer` 读出垃圾 | 把 gz 的 `32FC1` 米直接当毫米用 | R2.3 |
| 反投影的点转到别处,`tf2` 却能查到 | `frame_id` 盖成了相机本体系 | R2.2 |
| 相机背对目标,只看得见地面 | `rpy` 复合顺序理解错 / 视线是本体 X 轴 | R2.4 |
| 模型"根本没进 Gazebo",但 `create` 打印成功 | world 里声明了插件,挤掉了 UserCommands | R4.2 |
| `gz_ros2_control` 报找不到共享库,但 `ldd` 全齐 | 插件搜索路径没含 `$CONDA_PREFIX/lib` | R4.3 |
| 物体一 spawn 就消失 | 缺 `<inertial>` / 零质量 link → 惯性 NaN | R4.4 |
| 夹爪控制器 active 但手指不动 | `<mimic>` 被 `sdformat` 丢弃 | R4.4 |
| 世界相机与它的话题一起消失 | 挂在无质量的 world link 上,整棵子树被丢 | R4.4 |
| `contact` 离开工件后永远是 `True` | 接触消息是事件式的,没有"离开"事件 | R3.4 |
| 力的语义全错,但"有力、会响应、松开归零" | 轴向映射猜的,没实测 | R3.3 |
| 明明夹住了,`fnormal` 恒为 0 | 基线把真实接触吃掉了(基线更新条件写错) | R3.1 |
| 阈值永不触发或一碰就炸 | 仿真牛顿与真机任意单位混用(`force_scale` 没标定) | R3.5 |
| 位姿检查从来没跑过,日志只说"跳过" | 解析器格式对不上,降级把"没检查"伪装成通过 | M1、R5.4 |
| 校验代码永远通过 | `sdformat` 用 `__default__` / `__default_topic__` 填了缺失字段 | M1 |
| 报出根本不存在的碰撞 | 包围盒变换对 min/max 取了绝对值、把偏移吃掉 | R4.6 |
| 正常嵌套被判成重叠,只好把检查关掉 | 拿总包围盒判重叠,没逐碰撞体三维判 | R4.6 |
| 孔检测在仿真里根本不可能通过 | 仿真分辨率与真机不同,像素门槛失效 | R2.6 |
| 仿真"通过"、真机不认 | 像素门槛抄了两份,漂移了 | M2、R2.6 |
| 脚本说"已停止",进程还在跑 | 进程模式表漏项(漏过 `robot_state_publisher`) | M1、R4.10 |
| `pkill -f gz` 秒返回、什么都没停 | 它先匹配到调用它的 shell | R4.10 |
| 换了台臂却连错机器 | 硬编码的 `Rizon4-063989` 默认值 | R1.1 |
| 换了台臂,MoveIt 说规划成功、机械臂不动 | SRDF/controllers 还是旧 SN 的关节名(跟随臂的一级配置漏改) | R1.1.1 |
| 前缀检查器"全部通过",其实一处都没查 | 正则用 `\b` 收尾,而关节名是 `Rizon4-063989_joint1` | R5.2、R1.1.1 |
| `assets_root()` 指向从未存在的 `assets/` | 零调用者的死代码,没人发现 | R5.5 |
| 脚本报 `$'\r': 未找到命令`、`set: pipefail: 无效的选项名` | 脚本是 CRLF 行尾,`\r` 被当成标识符的一部分 | R6.6 |
| `bash -n` 说没问题,真跑立刻死 | `bash -n` 对 CRLF 会漏报 —— 静态检查≠能用 | R6.6 |
| 代码里一半英文 docstring 一半中文注释 | 没有统一口径,谁写谁定 | R6.5 |
| 真机栈哪天突然报"模块找不到",明明装过 | `install/` 的入口脚本 shebang 指向**仿真环境**的 python(哑弹) | R6.8 |
| 照着文档敲命令,命令根本不存在 | 写文档时没真跑过 | R6.7 |
| 夹爪明明开着,`status` 一直报"开口 0 mm" | 反馈取了"虚拟宽度关节",它在 Gazebo 里根本不动 | R4.11、坑 12 |
| 验收说指尖只张开 0.13 mm(其实 127 mm) | 验收脚本自己漏了 ×1000(米 vs 毫米) | R4.12 |
| 服务/话题/TF 全在,但读数全是乱的 | 上一轮的 `sim_gripper_adapter` / `static_transform_publisher` 还活着 | R4.11 |
| 命令"开 20 mm",两指实际 21.75 mm,夹不住工件 | 上游 `<mimic>` 是线性近似,开口必须走正运动学反解 | R4.4、坑 12 |
| 残留进程把旧相机外参按在新仿真上 | `static_transform_publisher` 发的是 latched `/tf_static` | R4.11 |
| 同一个手眼 TF,不同 launch 发出来不一样 | 四元数换算被抄了四份,退化分支公式各不相同 | R5.8 |
| 自检永远说"服务类型不对",而服务明明是好的 | 期望类型写成了适配器的类型,流程实际调的是 `control_msgs` 那份 | R5.9 |
| 节点退出码 1,日志里一个字都没有 | 入口 `os._exit()` 把异常吞了 | R6.9 |
| 每次 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 |
---
## 9. 这份文档怎么用(给 AI 的具体指令)
改这个仓库时:
1. 先读第 0 节,记住三条元规则。
2. 按第 7 节的清单逐条过。
3. 出问题时先查第 8 节(按症状查)。
4. 要在代码里"绕开"某条规则时,**先改这份文档**并写清理由 ——
绕开而不改文档,下一个人会按文档改回去。