Files
ros_contorl/docs/FRAMEWORK_RULES.md
arm_dev 6baf989595 真机侧补 stop_real.sh 与域隔离:残留的 mock 栈会把仿真/真机的 TF 盖成"静止"
实测(2026-09-28)在仿真里上料失败,一路查到 IK / TF / 时钟上,全是被冤枉的。
根因是两个真机侧一直缺的东西:

1) **真机侧没有停止脚本**。仿真侧早有 stop_sim_gz.sh(R4.10/R4.11:残留会伪造出
   "代码写错了"的现场),真机侧只有 run_real.sh 里两行 pkill。而真机栈里有一族
   **独立于 ros2_control_node 存活**的进程 —— 上一轮栈的主体早没了,只剩
   real_trajectory_adapter / named_pose_server / arm_status_node /
   joint_state_publisher 四条,两条 pkill 一条都匹配不到。
   危害不是占资源而是**冒名顶替**:残留的 adapter 照样订阅 /trajectory、
   残留的 joint_state_publisher 照样发 /joint_states(mock 的静态关节角)。
   现象极具误导性:控制器 feedback 与 reference 完全一致(控制器没坏),
   TF 却显示末端一动不动。

   新增 stop_real.sh + lib/real_procs.sh:扫描 = 点名表 + `install/` 前缀兜底,
   按 ROS_DOMAIN_ID 判别真机/仿真(两边有同名的 robot_state_publisher,
   否则停真机会把正在跑的仿真杀一半),杀完复查残留并非零退出。
   run_real.sh 启动前的清理改用它,不再各写一份会漂移的模式表。
   真机侧**有意不**用仿真的"进程组"法:真机栈是前台交互跑的,没有独立进程组,
   它的 PGID 就是调用它的 shell —— 记下来再 kill 会杀掉自己的终端。

2) **真机侧不强制 ROS_DOMAIN_ID**。sim_env.sh 早就强制 77 并写明"隔离不能靠
   『环境里恰好没设』",真机侧没有对应保护:先 source 过 sim_env.sh 的 shell 里
   再起真机栈,真机栈就落到**仿真的域**上,节点名撞车、/joint_states 与 /tf
   各有两个来源互相盖。补 lib/ros_domain.sh(真机 0 / 覆盖用 REAL_ROS_DOMAIN_ID),
   env.sh、run_real.sh、run_sim.sh 三处共用一份实现 —— 成对强制,否则一侧的
   强制只是把运气挪到了另一侧。

   run_sim.sh 特别容易漏:它名为 sim,用的却是**真机的 install/**、节点名与真机
   完全一样,而且当时不管域;这次也钉到真机域 0。run_sim_gz.sh 文件头那句
   "两者互不影响"是错的,一并改掉。

验证:stop_real.sh --list 在有仿真在跑时为空(REAL_PROC_INCLUDE_SIM=1 能看到
并按"域 77"标出);伪装一个 install/arm_control/lib/arm_control/arm_status_node
的孤儿进程能被点名。新增 6 项单测钉住域的四种输入 + 一个不许误报。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:25:16 +08:00

50 KiB
Raw Permalink Blame History

框架规则(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 规划成功但机械臂不动(难查得多)。

所以有一把专用尺子:

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.10b 停真机用 scripts/stop_real.sh(真机侧原本没有对应物)

R4.10/R4.11 只覆盖了仿真。真机侧长期只有 run_real.sh 里两行 pkill:

pkill -f '/controller_manager/ros2_control_node'
pkill -f 'arm_bringup.*stack.launch'

而真机栈里有一族独立于这两条存活的进程。2026-09-28 实测抓到的一次: 上一轮栈的主体早就没了,只剩

real_trajectory_adapter / named_pose_server / arm_status_node / joint_state_publisher

四条(都在 install/arm_control/lib/arm_control/ 下),上面两条 pkill 一条都匹配不到。 危害与 R4.11 同源 —— 冒名顶替:残留的 adapter 照样订阅 /trajectory、 残留的 joint_state_publisher 照样发 /joint_states(mock 的静态关节角), 新一轮的请求被 DDS 负载均衡到残留的那个。

后来这四条甚至跑到仿真的域上,把 Gazebo 那边的 TF 盖成"静止", 现场表现为"仿真上料失败、臂摆不到竖直位姿"(详见 R4.13 的实测记录)。

所以真机侧补齐,规则与仿真侧对齐:

  1. 扫描实现只有一份(scripts/lib/real_procs.sh),run_real.sh 与 stop_real.sh 共用 —— 不再各写一份会漂移的模式表;
  2. 扫描 = 点名表 + <仓库>/install/ 前缀兜底(以后新增节点忘了改表也能扫到);
  3. 按 ROS_DOMAIN_ID 判别真机与仿真:两边有同名节点 (robot_state_publisher、static_transform_publisher),只按名字扫的话 停真机会顺手把正在跑的仿真杀一半;
  4. 杀完复查残留并非零退出(漏项要变成看得见的告警)。

⚠️ 真机侧故意不用仿真的"进程组"法:真机栈是前台交互跑的(要能 Ctrl-C), 没有独立进程组,它的 PGID 就是调用它的那个 shell —— 记下来再 kill 会把你自己的 终端杀掉。

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 里原本写着:

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、代理变量。

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

真机这一侧也必须同样强制(2026-09-28 实测,反方向又踩了一次): 当时真机侧只有 scripts/env.sh,它不管 ROS_DOMAIN_ID。于是

先 source 过 sim_env.sh 的 shell(ROS_DOMAIN_ID=77 还 export 着) 再起真机栈 → 真机栈落到仿真的域上。

现象极具误导性:控制器 feedback 与 reference 完全一致(控制器没坏), 但 TF 显示末端一动不动 —— 顺着现象查会一路查到 IK、TF、时钟上去,全是无辜的。 真正的原因是同一话题上有两个来源(真机 + 残留的 mock 栈)互相盖。

规则因此补一句:"强制设自己的值"要成对做。只在一侧强制,另一侧就变成了 "靠环境里恰好没设",隔离照样是运气。现在两侧都有,且都要喊出来:

入口 强制到 覆盖用
scripts/sim_env.sh(仿真) 77 SIM_ROS_DOMAIN_ID
scripts/env.sh、run_real.sh、run_sim.sh(真机/mock) 0 REAL_ROS_DOMAIN_ID

实现只有一份:scripts/lib/ros_domain.sh 的 ros_domain_force_real。 run_sim.sh 特别容易漏 —— 它名为 sim,但用的是真机的 install/、节点名与 真机完全一样,所以它属于"真机域"那一列。


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/ 里的那份和源码一致:

diff src/arm_application/arm_application/<文件> \
     install_sim/arm_application/lib/python3*/site-packages/arm_application/<同名>

不一致时的现象是"我明明改了,行为却和没改一样",而且没有任何提示 —— 最容易被误判成"我的改动没效果"。

实测代价(2026-09-24):修好 verticalize 的静默跳过后忘了重编 install_sim/, 又白跑一轮 —— 两轮的日志一字不差,看起来像"这个改法根本无效",于是开始怀疑 改错了地方。判别只要一条命令:

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/ 也不入库,所以它只能靠规矩和检查:

# 真机 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/:

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,日志里什么都没有", 只能靠猜。规矩:

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、stop_real.sh --list 都应为空)? R4.10、R4.10b、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
控制器 feedback 与 reference 完全一致,TF 却显示末端一动不动 同样的话题上有两个来源(残留的 joint_state_publisher 发的静态 mock 关节角把它盖住了)—— 别去查 IK/TF/时钟 R4.10b、R4.13
真机栈和仿真"互相干扰"、节点名撞车 真机侧当时不强制域,ROS_DOMAIN_ID=77 从跑过仿真的 shell 里漏了进来 R4.13
命令"开 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
回转那一步每轮都比指令少走 45°,攒到 78 轮后转不回来 速度/加减速限制让"快回转"实际走不满整步;余量必须 ≥ 这点亏欠,否则关节均位逐轮外漂 R2.10
第一孔装得好好的,第二孔的 S07 下探 35mm 判不到接触 第一孔拧出后规还插在孔里,换孔的斜线把它从孔里拽出来 → 锁被拽断、规掉在台面("工具上已经没规了");提出与横move必须先后有序 R2.11

9. 这份文档怎么用(给 AI 的具体指令)

改这个仓库时:

  1. 先读第 0 节,记住三条元规则。
  2. 按第 7 节的清单逐条过。
  3. 出问题时先查第 8 节(按症状查)。
  4. 要在代码里"绕开"某条规则时,先改这份文档并写清理由 —— 绕开而不改文档,下一个人会按文档改回去。