# 框架规则(`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///`,并带 `manifest.yaml` ``` devices/// ├── 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` 里声明任何一个 `` 都会挤掉默认系统 声明了任何插件后,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` 等)**不许直接改** 第三方包一律在**启动时归一化**,不在上游文件里下手: - 缺 `` / 零质量 link → NaN 惯性、物体消失 - `` 标签被 `sdformat` **丢弃** → 夹爪"控制器 active 但手指不动" - 无质量的根 link(world link)会**连整棵子树被静默丢掉** 改上游还有个额外代价:下次 `fetch_flexiv.sh` 会把你的改动覆盖掉。 ### R4.5 场景物体走 `scene.yaml`,不进 world `world` 是"环境"(地面/台面/相机/系统插件),`scene.yaml` 是"这一批活做什么"。 动 world 的风险面太大(`R4.2`)。 模型约定:**原点在底面中心**、单位米、目录名 = `` = `model.config` 里的 `` = 推理类别名。 ### R4.6 模型/场景校验必须逐**碰撞体**、三维判 - 不能拿总包围盒判重叠:正常装配里本来就有嵌套(规立在规座方孔里), 粗判会把正常嵌套报成碰撞,逼着人把检查关掉。 - 恰好贴合不算重叠(规的台肩正好坐在规座顶面上)。 - 变换必须用"中心 + 半宽":对 min/max 取绝对值再对称化会把偏移量吃掉、 盒子凭空变大。 - 校验要用**从 SDF 解析出来的**几何,不能在 `scene.yaml` 里再抄一份尺寸。 ### R4.7 建模:碰撞体用简单几何,视觉可以加"只给算法看"的细节 `` 决定物理,用 box/cylinder;`` 决定相机看见什么, 可以加亮环、色块这类只服务于检测的东西。 工件的碰撞体是方孔、视觉是圆环,**这是刻意的**:真机的 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///`` - 单位、十六进制、协议报文、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 就消失 | 缺 `` / 零质量 link → 惯性 NaN | R4.4 | | 夹爪控制器 active 但手指不动 | `` 被 `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,夹不住工件 | 上游 `` 是线性近似,开口必须走正运动学反解 | 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 行程,必然有一头贴死("让余量"也腾不出空间);真因在配置里 | 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. 要在代码里"绕开"某条规则时,**先改这份文档**并写清理由 —— 绕开而不改文档,下一个人会按文档改回去。