Files
ros_contorl/docs/FRAMEWORK_RULES.md
T
arm_dev bda2fa7129 通止规装配:配置段进 site.yaml + 中文手册 + 成败判定「不看 $?」
配置(site.yaml → gauge_assembly:,38 键全部带中文注释)
------------------------------------------------------
- 键名/单位/类型/交叉矛盾由 gauge_config.py 校验:拼错键名报错并给最相近的候选,
  单位后缀(_deg/_mm/_m/_s/_pct/_delta)逐条对表,`contact_safe_delta ≤
  contact_tangential_delta` 这类矛盾启动就拦(R1.2:没接线的键不许静默存在)。
- 借机清掉 4 个**没接线**的键(require_preview / track_*_s / hover_m:前两个是
  stage1 那层的、后两个是 servo 那层的,放在这里等于第二份真相),
  并把 contact_speed_m_s / coarse_timeout_s / 夹爪力速 真的接上。
- site.example.yaml 由 site.yaml 生成(gen_site_example.py --check 通过)。

成败判定:`ros2 launch` 的退出码**恒为 0**
------------------------------------------
实测(Humble):子进程 exit 3,`ros2 launch` 仍返回 0。用 `$?` 判成败的脚本会把
"装配失败"记成"成功"——没人会去查一个说成功的脚本。改成:

- 节点把结论**落盘**(--result-file,默认 /tmp/gauge_assembly_result.json,原子写、
  先落盘再发话题),任何"没跑到收尾"的退出也会补一份 ok=false;
- 脚本读结论文件判成败:0=成功、1=失败、**文件不存在也算失败**并说清;
- 新增 test_gauge_result_gate.py:把脚本里那段判成败的 Python 抽出来,
  对成功/失败/干跑/缺文件/坏 JSON 五种输入断言退出码 —— 判据本身也有测试。

顺带修掉两个"看着像玄学"的现象
------------------------------
- **流程打完了终端不回提示符**:launch 里的静态 TF 发布者是常驻的,主节点退出
  带不走它 —— 挂 OnProcessExit → Shutdown。
- **"已装 1/2 孔"**:结果里 hole_index 是 0 基当前孔号,不是"装完几个",
  新增 holes_done 给人和脚本看。

文档(全部中文,含"怎么调参/还没做")
------------------------------------
- 新增 docs/GAUGE_ASSEMBLY.md:状态机全图、每步判据表、E1 掉件处理(**按实现**:
  掉件后该孔作废、换下一个孔;最后一个孔掉件则序号被夹住=重试本孔)、
  话题契约、调参顺序、验收命令、踩过的坑。
- FRAMEWORK_RULES 新增 R5.8(同一份数学只许一份 + 守卫测试)、R5.9(自检的
  期望类型与调用处同源)、R5.10(缺依赖 skip,别留常年红)、R6.9(硬退出前打
  traceback)、R6.10(ros2 launch 退出码恒 0)+ R5.7 补真机/仿真"生效方式不同"
  (真机软链即改即生效,仿真拷贝必须重编)+ 5 条症状反例、.cursor/rules 同步。
- WORKSPACE / INTERFACES / SITE_CONFIG / HANDBOOK / 两份 README 索引与决策表补齐。

验证(本机真跑)
----------------
  pytest(仿真环境)  436 passed, 14 skipped(缺 onnx 跳过)
  干跑 --sim --dry-run  → 脚本退出码 0,结论 ok=true  已装 2/2 孔
  不干跑 --sim          → 脚本退出码 1,S00 拦住,未发任何运动指令

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

680 lines
36 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/` 会互相覆盖。
**但两者的"生效方式"不一样,这一点比隔离本身更容易坑人**:
| 环境 | 构建 | 产物形态 | 改了源码之后 |
|------|------|----------|--------------|
| 真机 | `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/<同名>
```
不一致时的现象是"我明明改了,行为却和没改一样",而且**没有任何提示** ——
最容易被误判成"我的改动没效果"。
### 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 |
| 装配失败了,脚本/CI 却报成功 | `ros2 launch` 的退出码恒为 0,脚本看了 `$?` | R6.9 |
| 流程明明打完了,终端一直不回到提示符 | launch 里的静态 TF 发布者是常驻的,主节点退出不会带走它 | R6.9 |
---
## 9. 这份文档怎么用(给 AI 的具体指令)
改这个仓库时:
1. 先读第 0 节,记住三条元规则。
2. 按第 7 节的清单逐条过。
3. 出问题时先查第 8 节(按症状查)。
4. 要在代码里"绕开"某条规则时,**先改这份文档**并写清理由 ——
绕开而不改文档,下一个人会按文档改回去。