Files
ros_contorl/docs/FRAMEWORK_RULES.md
T
arm_dev 6922b18187 仿真夹爪适配器:服务接口对齐真机,开口按正运动学换算成真毫米
为什么
------
应用层只认 arm_msgs/srv/GripperExecute(开口毫米),真机与仿真必须同一套接口。
但 GN01 的 URDF 里 <mimic> 是**线性近似**:直接按线性系数下发 finger_width_joint,
"命令开 20 mm"在 Gazebo 里量出来是 21.75 mm —— 抓不住工件,而且看起来像算法问题。

实测(2026-09-24,Gazebo 内量两指尖间距):
  线性下发 20 mm → 实际 21.74 mm(偏差 1.74 mm,超 2 mm 抓取容差的前半步)
  走 FK 反解     → 实际 20.00 mm(≤0.5 mm)

怎么改
------
- 新增 sim_gripper_kin.py:解析 URDF 正运动学,算出"主关节值 ↔ 真实开口 mm"的
  单调曲线,反解用二分。曲线非单调/零行程时**主动报错**,不静默退化成线性。
- sim_gripper.py:开口换算全部走该曲线;容差统一改成毫米。
- sim_gripper_adapter.py:反馈改取**从动关节**(见下);is_gripped 按毫米判定。
- 新增 check_sim_gripper.py:三层验收(服务/开口一致/指尖真实几何行程 + 对称性)。

踩到的坑
--------
- finger_width_joint 挂在无质量的 unused_finger_width_link 上,与手指**没有机械
  连接**:命令到 0.100,它的 /joint_states 恒为 1.4e-08,6 个从动关节才准到
  0.7854。反馈若取它,/gripper/status 会永远报"开口 0 mm"。
- 残留进程会伪造出"代码写错了"的现场:旧 sim_gripper_adapter 与新实例抢话题,
  验收读到"指尖间距 0.03 mm"(真实 127 mm);孤儿 static_transform_publisher
  用 latched /tf_static 把旧相机外参按在新仿真上。
  → 新增 scripts/lib/sim_procs.sh;run_sim_gz.sh 用 setsid 独立进程组,
    PGID 记进 log_sim/sim.pgid,stop 时整组 kill;启动前预检拒绝带病启动。
- 验收脚本自己也错过两次(把 TF 的米当毫米比 → 127 mm 显示成 0.13 mm;
  用会转动指垫的中点漂移判对称 → 必然位移 16 mm)。判据改成"两侧各走多远",
  单位只在 _dist_mm 里换算一次。

验证
----
  python3 -m pytest src/arm_sim_gazebo/test/ -q          → 307 passed
  python3 scripts/check_sim_gripper.py                   → 三层全过
     第 2 层:请求/反馈开口最大偏差 < 0.5 mm
     第 3 层:期望行程 vs 实际行程、左右行程差,均 < 1.5 mm
  bash scripts/stop_sim_gz.sh --list                     → 整组列出(含 static tf)

文档
----
- 仿真手册:新增 §4.4(开口 mm 怎么换算)、坑 12(宽度关节不动、
  反馈必须取从动关节);停止一节改写成进程组语义。
- FRAMEWORK_RULES:新增 R4.11(残留进程会伪造现场)、R4.12(验收判据要挑
  与实现无关的物理量);R4.9 表补夹爪;清单补 13/14 两条。
- HANDBOOK:新增 §3.4「在仿真里开合夹爪」,补 §4.4 的两条新教训。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:46:09 +08:00

592 lines
31 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`)。
---
## 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` 要把**原始物理量**(指尖坐标)打出来,判错时能回看;
- 结论不对时,先怀疑判据,再怀疑被测对象 —— 上面两条都是先怀疑了代码。
---
## 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/` 会互相覆盖。
---
## 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 环境与目标一致(待补)。
---
## 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 |
---
## 9. 这份文档怎么用(给 AI 的具体指令)
改这个仓库时:
1. 先读第 0 节,记住三条元规则。
2. 按第 7 节的清单逐条过。
3. 出问题时先查第 8 节(按症状查)。
4. 要在代码里"绕开"某条规则时,**先改这份文档**并写清理由 ——
绕开而不改文档,下一个人会按文档改回去。