悬停走完后常数前馈会往 +X 过补约 1.3mm、插不进,贴面改成笛卡尔锁 XY、只降 Z 的厂家 MoveL;D435 交回也不再杀栈,只等法兰停稳 0.5 秒。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-15 10:23:52 +08:00
parent 37b85aa09b
commit 5c7966d8cd
16 changed files with 1024 additions and 117 deletions
+8 -4
View File
@@ -592,8 +592,8 @@ servo:
lock_hold_sec: 0.7 # 旧死区驻留(混合模式不再用它进二阶段) lock_hold_sec: 0.7 # 旧死区驻留(混合模式不再用它进二阶段)
# 混合模式必填。交回 D405 的等待不再用它,见 d435_handback_sec。 # 混合模式必填。交回 D405 的等待不再用它,见 d435_handback_sec。
lock_still_sec: 3.0 lock_still_sec: 3.0
# MoveL 后杀栈冷启动回来,再叠这么久才交回 D405。流畅度先不管,停稳优先。 # 进程内 MoveL 到位后,法兰 XY 连续停稳这么久才交回 D405。
d435_handback_sec: 3.0 d435_handback_sec: 0.5
# 框心对准并且法兰停稳这么久,才精拍一次小孔关键点、进对孔。实时点到此为止。 # 框心对准并且法兰停稳这么久,才精拍一次小孔关键点、进对孔。实时点到此为止。
wrist_align_sec: 0.5 wrist_align_sec: 0.5
lock_still_move_m: 0.015 # 15 mm;超过就重计时,避免"晃一下刚好满 3 秒" lock_still_move_m: 0.015 # 15 mm;超过就重计时,避免"晃一下刚好满 3 秒"
@@ -770,9 +770,13 @@ hole_detect:
align_speed: 0.014 # 对准段笛卡尔速度 [m/s](原0.03,放缓防冲) align_speed: 0.014 # 对准段笛卡尔速度 [m/s](原0.03,放缓防冲)
move_speed: 0.022 # 升降/粗居中段速度 [m/s](原0.05,放缓防震规) move_speed: 0.022 # 升降/粗居中段速度 [m/s](原0.05,放缓防震规)
hole_visual_only: true hole_visual_only: true
# 本分支实验:true=悬停拍一次并走完到孔,下降后再补 XY;main 保存点应保持 false。 # 本分支实验:true=悬停拍一次并走完到孔;main 保存点应保持 false。
hole_keypoint_once: true hole_keypoint_once: true
final_gap_mm: 2.0 # 对准后贴面轻降目标:规尖悬在孔面上方这么高 [mm](不接触) # true=悬停走完后一条厂家 MoveL 贴面。对照观察高补交时改 false。
hole_once_lock_xy_descend: true
# 0=笛卡尔锁 XY 只降 Z。k>0 才沿 -look 加前馈。
once_ff_look_per_m: 0.0
final_gap_mm: 2.0 # 对准后贴面目标:规尖离面 [mm]。CLI --final-gap 覆盖。
observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm], observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm],
# 方便看规尖偏没偏。必须 ≥ final_gap_mm。 # 方便看规尖偏没偏。必须 ≥ final_gap_mm。
# 左臂89:悬停间隙约 75mm(0.320−0.223−0.022)⇒ 大约下降 60mm。 # 左臂89:悬停间隙约 75mm(0.320−0.223−0.022)⇒ 大约下降 60mm。
+11 -4
View File
@@ -579,8 +579,9 @@ servo:
lock_hold_sec: 0.7 # 旧死区驻留(混合模式不再用它进二阶段) lock_hold_sec: 0.7 # 旧死区驻留(混合模式不再用它进二阶段)
# 混合模式必填。交回 D405 的等待不再用它,见 d435_handback_sec。 # 混合模式必填。交回 D405 的等待不再用它,见 d435_handback_sec。
lock_still_sec: 3.0 lock_still_sec: 3.0
# MoveL 后杀栈冷启动回来,再叠这么久才交回 D405。流畅度先不管,停稳优先。 # 进程内 MoveL 到位后,法兰 XY 连续停稳这么久才交回 D405。
d435_handback_sec: 3.0 # 旧 3s 是杀栈冷启动的垫时间;不杀栈后只留机械停稳。晃了把数加大,不许再杀栈。
d435_handback_sec: 0.5
# 框心对准并且法兰停稳这么久,才精拍一次小孔关键点、进对孔。实时点到此为止。 # 框心对准并且法兰停稳这么久,才精拍一次小孔关键点、进对孔。实时点到此为止。
wrist_align_sec: 0.5 wrist_align_sec: 0.5
lock_still_move_m: 0.015 # 15 mm;超过就重计时,避免"晃一下刚好满 3 秒" lock_still_move_m: 0.015 # 15 mm;超过就重计时,避免"晃一下刚好满 3 秒"
@@ -759,9 +760,15 @@ hole_detect:
# 对小孔关键点:只用像素射线交到 workpiece_surface_z_m,不读深度图。 # 对小孔关键点:只用像素射线交到 workpiece_surface_z_m,不读深度图。
hole_visual_only: true hole_visual_only: true
# 本分支实验(exp/hole-keypoint-once):true=悬停拍一次,框心→k1 一次走完 # 本分支实验(exp/hole-keypoint-once):true=悬停拍一次,框心→k1 一次走完
# (40mm 上限),下降后再补交平面漂的 XY。main 保存点是 false(多走多拍)。 # (40mm 上限)。main 保存点是 false(多走多拍)。
hole_keypoint_once: true hole_keypoint_once: true
final_gap_mm: 2.0 # 对准后贴面轻降目标:规尖悬在孔面上方这么高 [mm](不接触) # true=悬停走完后一条厂家 MoveL 贴面(不补拍)。对照 37b85aa 改回 false。
hole_once_lock_xy_descend: true
# 贴面相对悬停:沿 -look 走 k×剩余下降 [m/m]。0=笛卡尔锁 XY 只降 Z。
# 0.018 在框心改平面交点后两条链路都 +1.3mm X 过补,插不进。
once_ff_look_per_m: 0.0
final_gap_mm: 2.0 # 对准后贴面目标:规尖离面 [mm](不接触)。笛卡尔锁 XY
# 时法兰 Z = 工具长 + 工件面 + 本值。CLI --final-gap 覆盖。
observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm], observe_gap_mm: 15.0 # --no-descent 时的观测下降:只降到离面这么高 [mm],
# 方便看规尖偏没偏。必须 ≥ final_gap_mm。 # 方便看规尖偏没偏。必须 ≥ final_gap_mm。
# 左臂89:悬停间隙约 73.5mm(0.320−0.2245−0.022)⇒ 观测下降约 58mm。 # 左臂89:悬停间隙约 73.5mm(0.320−0.2245−0.022)⇒ 观测下降约 58mm。
+9 -5
View File
@@ -72,17 +72,21 @@ bash ~/ros2/scripts/run_all.sh --arm 89 --init-z 0.35 --hover-z 0.34
一阶段默认 **D405 实时推理**,D435 待命。YOLO-pose 这一路不关。连续 一阶段默认 **D405 实时推理**,D435 待命。YOLO-pose 这一路不关。连续
`D405_MISS_BEFORE_D435_S`(0.5 s)没有 conf≥`perception.wrist_handoff_conf`(0.5) `D405_MISS_BEFORE_D435_S`(0.5 s)没有 conf≥`perception.wrist_handoff_conf`(0.5)
的目标,才用顶视 D435 **拍一张**,把点用眼在手外 T 变到基座(不再乘法兰)。 的目标,才用顶视 D435 **拍一张**,把点用眼在手外 T 变到基座(不再乘法兰)。
这一段运动是厂家 `MoveL`(SDK `vel` 0.3 m/s、`acc` 1.5 m/s²):脚本先停真机控制栈(感知不停),XY 对准该点, 这一段运动是进程内厂家 `MoveL`(不杀 `run_real.sh`):当前厂家 `tcp_pose` 加
工具尖高度保持当前,再冷启动 `run_real.sh --no-stop`。冷启动回来之后才开始计 ROS 相对 XY,`keep_z`。硬件在 MoveL 期间不灌关节指令。到位后法兰 XY 连续停稳
`servo.d435_handback_sec`(3 s)。赶路和杀栈期间不下发 D405。 `servo.d435_handback_sec`(0.5 s)才交回 D405。赶路期间不下发 D405(两套控制互抢会晃)。
停稳之后改回 D405,跟踪点是工件框的矩形中心,不追 k0。 停稳之后改回 D405,跟踪点是工件框的矩形中心,不追 k0。
启动高度用 `--init-z`(法兰 Z,米),对孔悬停用 `--hover-z`。两个都是法兰高度。 启动高度用 `--init-z`(法兰 Z,米),对孔悬停用 `--hover-z`。两个都是法兰高度。
下臂贴面用 `--final-gap`(规尖离面,毫米;site 默认 `final_gap_mm` 2)。笛卡尔锁 XY
时只改 Z:法兰 = 工具长 + 工件面 + 间隙。不能和 `--no-descent` 一起写。
会让工具尖进入工件、或超出工作盒的值直接拒绝,不会改成另一个高度再往下走。 会让工具尖进入工件、或超出工作盒的值直接拒绝,不会改成另一个高度再往下走。
不写则一阶段锁高仍按 site,对孔悬停仍是 `hole_hover_z_m`(89 为 0.320)。 不写则一阶段锁高仍按 site,对孔悬停仍是 `hole_hover_z_m`(89 为 0.320)。
框心对准连续 `servo.wrist_align_sec`(0.5 s)→ `/visual_servo/locked`。 框心对准连续 `servo.wrist_align_sec`(0.5 s)→ `/visual_servo/locked`。
本分支(`exp/hole-keypoint-once`)二阶段是**单次精拍**:悬停拍一次 k1/k2, 本分支(`exp/hole-keypoint-once`)二阶段是**单次精拍**:悬停拍一次 k1/k2,
把框心到目标孔一次走完(步长上限 40mm,不再截成 12mm),先降到观察高约 15mm 再交孔,斜线一步把 XY 补偿(≤3mm)和剩余 Z 收到贴面。轻降接触门控是 `final_gap+4mm`。 把框心到目标孔一次走完(步长上限 40mm)。现行实验 `hole_once_lock_xy_descend: true`:
`hole_detect.stage2.hole_keypoint_once: true`。看 `/tmp/stage2_result.json` 的 `snaps`。 走完后不补拍,一条厂家 MoveL 收到贴面。`once_ff_look_per_m: 0` 时 MoveL 只降 Z
(笛卡尔锁 XY);k>0 才沿 -look 加前馈。目标 = 当前厂家 `tcp_pose` + 相对位移,
不是 ROS 绝对坐标。保存点 `37b85aa` 是观察高 15mm 再斜线补 XY。看 `/tmp/stage2_result.json`。
`main` 保存点仍是多走多拍。 `main` 保存点仍是多走多拍。
孔坐标把关键点像素交到 `workpiece_surface_z_m`,不读深度图。下臂停在 孔坐标把关键点像素交到 `workpiece_surface_z_m`,不读深度图。下臂停在
`tool10120938` 的 TCP 长度(0.2245 m)加上 `final_gap_mm` 之上,不按旧 tool1 的 0.223 m。 `tool10120938` 的 TCP 长度(0.2245 m)加上 `final_gap_mm` 之上,不按旧 tool1 的 0.223 m。
+16 -5
View File
@@ -100,13 +100,23 @@ bash ~/ros2/scripts/run_all.sh --arm 89 --depth 6 --pitch 1.0
先悬停垂直朝下 → 拍一张照 → `detect_best_1.onnx` 框工件 → Hough 解 k1/k2 孔心 → 先悬停垂直朝下 → 拍一张照 → `detect_best_1.onnx` 框工件 → Hough 解 k1/k2 孔心 →
像素射线交到 `workpiece_surface_z_m`(不读深度图)+ 内参 + 手眼换算基座坐标 → 像素射线交到 `workpiece_surface_z_m`(不读深度图)+ 内参 + 手眼换算基座坐标 →
移动**规尖**到目标孔正上方。 移动**规尖**到目标孔正上方。`hole_visual_only` 时粗居中框心也走同一条平面交点,
不用深度(30° 相机下深度框心会让 `K1.x−规尖.x` 随站位翻号)。
本分支 `hole_keypoint_once: true`:悬停拍一次,把框心到 k1 的半孔距(约 30mm) 本分支 `hole_keypoint_once: true`:悬停拍一次,把框心到 k1 的半孔距(约 30mm)
一次走完(不再用多走多拍的 12mm 封顶),走后核验只记账。轻降先到观察高约 15mm 一次走完(不再用多走多拍的 12mm 封顶)。
(单步 20mm,约 3 步),再交孔测补偿,斜线一步把 XY(≤3mm)和剩余 Z 收到 `final_gap_mm`。
规尖沿用悬停冻结值,不在近距重测。两孔间距掉出 50–70mm 或要补超过 8mm 则不动、 现行实验 `hole_once_lock_xy_descend: true`:走完后不补拍,一条进程内厂家
也不贴面。轻降力接触门控是 `final_gap_mm + 4mm`(贴面 2mm 时 = 6mm)。看 MoveL 收到 `final_gap_mm`(规尖离面,毫米;`--final-gap` 覆盖,默认 2)。
`once_ff_look_per_m: 0` 时 ΔXY=0、只降 Z(笛卡尔锁 XY)。k>0 才
`ΔXY = -look × k × 剩余下降`。法兰 Z = 工具长 + 工件面 + 间隙,不许直接写法兰高度。MoveL 目标是**当前厂家 `tcp_pose` 加
相对位移**,不是 ROS 规尖绝对坐标。
2026-10-14:k=0.018 在框心改平面交点后,D435+D405 与单 D405 下臂都往 +X 过补
约 1.3mm、插不进——悬停 X 已对上,常数前馈是多余的。锁 XY 折线失败是关节 hop
漂 XY,不是笛卡尔 MoveL 锁 XY。对照 `37b85aa`:观察高约 15mm 再交孔斜线补 XY。
前馈超过 8mm 或工作盒会截断则不动。没有新鲜 `tcp_pose` 则不下发。
轻降力接触门控是 `final_gap_mm + 4mm`(贴面 2mm 时 = 6mm)。看
`/tmp/stage2_result.json` 的 `snaps[]`。`main` 保存点是多走多拍闭环。 `/tmp/stage2_result.json` 的 `snaps[]`。`main` 保存点是多走多拍闭环。
> **对的是"规尖",不是"法兰轴线"**(2026-10-08 定稿)。通止规夹在爪里,相对法兰轴线 > **对的是"规尖",不是"法兰轴线"**(2026-10-08 定稿)。通止规夹在爪里,相对法兰轴线
@@ -126,6 +136,7 @@ bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-only # 只垂直+悬
bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hole k2 # 对准 k2(默认 k1) bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hole k2 # 对准 k2(默认 k1)
bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-z 0.28 # 临时改悬停高,不动 yaml bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-z 0.28 # 临时改悬停高,不动 yaml
bash ~/ros2/scripts/run_all.sh --arm 89 --no-descent # 只验证精对准、不轻降 bash ~/ros2/scripts/run_all.sh --arm 89 --no-descent # 只验证精对准、不轻降
bash ~/ros2/scripts/run_all.sh --arm 89 --final-gap 8 # 锁 XY,规尖停在离面 8mm
``` ```
悬停锁高接口:`site.yaml → robots[89].servo.hole_hover_z_m`(91 在同名段)。 悬停锁高接口:`site.yaml → robots[89].servo.hole_hover_z_m`(91 在同名段)。
+22 -28
View File
@@ -7,54 +7,48 @@
/**: /**:
ros__parameters: ros__parameters:
num_poses: 24 num_poses: 21
waypoint_positions: [ waypoint_positions: [
# pose 0 # pose 0
-1.241563, -0.359631, 0.346153, 2.172565, -0.179027, 0.929920, -0.778876, -0.798667, -0.611079, 0.054912, 2.246988, 0.049199, 1.389219, 0.196135,
# pose 1 # pose 1
-1.388832, -0.554698, 0.416075, 1.897617, -0.358341, 0.827495, -0.762302, -0.787304, -0.531383, 0.240108, 2.183440, 0.367026, 1.370311, 0.472489,
# pose 2 # pose 2
-1.436048, -0.573046, 0.461762, 1.870967, -0.387696, 0.813090, -0.762917, -1.615896, -0.687070, 1.187600, 2.243693, -0.162789, 1.650045, 1.026121,
# pose 3 # pose 3
-1.614491, -0.519365, 0.693991, 2.156193, -0.499113, 0.968762, 0.073287, -1.787431, -0.668004, 1.408195, 2.025431, -0.064718, 0.565415, 1.324635,
# pose 4 # pose 4
-2.020087, -0.869088, 1.059166, 2.155478, -1.198473, 0.740567, 0.478840, -2.024343, -0.863793, 1.349640, 2.359469, -0.720629, 0.084467, 0.821412,
# pose 5 # pose 5
-2.082145, -1.122210, 1.203011, 1.950272, -1.269847, 0.434138, -0.641975, -1.884100, -1.117584, 1.183093, 2.127937, -1.258013, 0.151934, 0.985555,
# pose 6 # pose 6
-1.983153, -0.886082, 0.999329, 2.324821, -1.272773, 0.754127, 0.313613, -1.866465, -1.194137, 1.143263, 1.872811, -1.320028, 0.280719, 0.777663,
# pose 7 # pose 7
-2.021479, -0.886609, 1.055679, 2.337716, -1.282773, 0.669319, -0.028355, -1.729402, -1.139685, 1.127574, 1.913544, -1.329630, -0.194162, 1.383834,
# pose 8 # pose 8
-2.022792, -0.947211, 1.118194, 2.127275, -1.211995, 0.527528, -0.169900, -1.611937, -1.158158, 1.064049, 1.918313, -1.480676, -0.392994, 1.833894,
# pose 9 # pose 9
-2.004766, -1.065820, 1.183799, 1.901084, -1.217488, 0.380285, -0.312733, -2.252978, -1.387014, 1.686186, 2.101298, -1.333349, -0.111994, 0.036817,
# pose 10 # pose 10
-1.942863, -1.219617, 1.256733, 1.653113, -1.222302, 0.261818, -0.420706, -2.179722, -1.297659, 1.497171, 2.167675, -1.272158, 0.100548, 0.673603,
# pose 11 # pose 11
-2.039558, -1.082441, 1.189833, 1.996967, -1.241252, 0.521591, 0.526563, -1.921897, -1.543024, 1.000863, 1.897273, -1.455972, 1.437388, -0.140968,
# pose 12 # pose 12
-2.098074, -1.006236, 1.538183, 2.118382, -0.737422, 0.019903, 0.648802, -1.884059, -1.063347, 1.439248, 2.253200, -1.164963, -0.423306, 0.907197,
# pose 13 # pose 13
-2.039609, -1.159222, 1.488501, 1.908579, -1.168793, -0.140451, 0.034889, -1.987022, -1.235459, 1.354108, 2.370862, -1.354044, 0.065378, 0.923079,
# pose 14 # pose 14
-1.880783, -1.104601, 1.579382, 2.036427, -0.665049, 0.254105, 1.518504, -1.770869, -1.333861, 1.241996, 1.800723, -1.395819, -0.255590, 1.389660,
# pose 15 # pose 15
-2.015530, -1.535468, 1.711220, 1.836908, -1.707450, -0.400234, -0.708683, -1.761759, -1.369860, 1.213137, 1.791639, -1.467956, -0.170537, 1.473557,
# pose 16 # pose 16
-1.997672, -1.626362, 1.529443, 2.200206, -1.291013, 0.398664, 1.954208, -1.504294, -1.346227, 1.206550, 1.660186, -1.320782, -0.393676, 1.657215,
# pose 17 # pose 17
-1.981875, -1.620651, 1.545443, 2.373140, -1.167365, 0.396487, 2.012547, -1.846546, -1.476930, 0.946255, 1.910615, -2.435429, 1.535168, 2.151544,
# pose 18 # pose 18
-2.179415, -1.511968, 1.578832, 2.401706, -1.326480, -0.051353, 1.137181, -1.700067, -1.452760, 0.909035, 1.758222, -2.103930, 1.445249, 2.174743,
# pose 19 # pose 19
-2.173463, -1.530413, 1.575534, 2.173673, -1.289673, -0.090117, 0.867214, -2.173713, -0.983647, 1.399849, 2.384939, -1.379509, -0.216471, 0.253561,
# pose 20 # pose 20
-2.096314, -1.569352, 1.579820, 1.942057, -1.458598, -0.057003, 0.572953, -2.160187, -0.967904, 1.376288, 2.451713, -1.393524, -0.212278, 0.473606,
# pose 21
-2.129376, -1.566787, 1.903585, 1.962858, -1.454137, -0.335990, -0.148441,
# pose 22
-2.020280, -1.527165, 1.887130, 1.728957, -1.491867, -0.370585, -0.277940,
# pose 23
-2.140817, -1.607357, 1.617236, 2.153850, -1.571057, -0.147540, 0.770535,
] ]
+11 -2
View File
@@ -23,6 +23,7 @@
# bash ~/ros2/scripts/run_all.sh --arm 89 --no-descent # 对准后只降到观测高度(默认离面 15mm) # bash ~/ros2/scripts/run_all.sh --arm 89 --no-descent # 对准后只降到观测高度(默认离面 15mm)
# bash ~/ros2/scripts/run_all.sh --arm 89 --init-z 0.35 # 启动后先到这个法兰高度 [m] # bash ~/ros2/scripts/run_all.sh --arm 89 --init-z 0.35 # 启动后先到这个法兰高度 [m]
# bash ~/ros2/scripts/run_all.sh --arm 89 --hover-z 0.34 # 对孔悬停法兰高度 [m],不安全会拒绝 # bash ~/ros2/scripts/run_all.sh --arm 89 --hover-z 0.34 # 对孔悬停法兰高度 [m],不安全会拒绝
# bash ~/ros2/scripts/run_all.sh --arm 89 --final-gap 8 # 下臂锁 XY,规尖停在离面 8mm(site 默认 2)
# bash ~/ros2/scripts/run_all.sh --arm 89 --d435-only # 拍一张 K0,厂家 MoveL 把工具尖 XY 对准它(先停 run_real.sh) # bash ~/ros2/scripts/run_all.sh --arm 89 --d435-only # 拍一张 K0,厂家 MoveL 把工具尖 XY 对准它(先停 run_real.sh)
# #
# 前置(另开一个终端,先跑起来): # 前置(另开一个终端,先跑起来):
@@ -49,6 +50,7 @@ NO_DESCENT=false
D435_ONLY=false D435_ONLY=false
INIT_Z="" INIT_Z=""
HOVER_Z="" HOVER_Z=""
FINAL_GAP=""
# --sn/--arm 必须先解析:load_site_config.sh 靠 ROBOT_SN 决定加载哪套机器人配置 # --sn/--arm 必须先解析:load_site_config.sh 靠 ROBOT_SN 决定加载哪套机器人配置
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -71,15 +73,21 @@ while [[ $# -gt 0 ]]; do
--d435-only) D435_ONLY=true; SKIP_STAGE2=true; SKIP_STAGE3=true; shift ;; --d435-only) D435_ONLY=true; SKIP_STAGE2=true; SKIP_STAGE3=true; shift ;;
--init-z) INIT_Z="${2:?--init-z 需要法兰高度,单位米}"; shift 2 ;; --init-z) INIT_Z="${2:?--init-z 需要法兰高度,单位米}"; shift 2 ;;
--hover-z) HOVER_Z="${2:?--hover-z 需要法兰高度,单位米}"; shift 2 ;; --hover-z) HOVER_Z="${2:?--hover-z 需要法兰高度,单位米}"; shift 2 ;;
--final-gap) FINAL_GAP="${2:?--final-gap 需要规尖离面间隙,单位毫米}"; shift 2 ;;
--no-preview) PIPELINE_PREVIEW=false; shift ;; --no-preview) PIPELINE_PREVIEW=false; shift ;;
--camera-preview) CAMERA_PREVIEW=true; shift ;; --camera-preview) CAMERA_PREVIEW=true; shift ;;
--no-camera) WITH_CAMERA=false; shift ;; --no-camera) WITH_CAMERA=false; shift ;;
--no-tactile) WITH_TACTILE=false; shift ;; --no-tactile) WITH_TACTILE=false; shift ;;
-h|--help) sed -n '2,31p' "$0"; exit 0 ;; -h|--help) sed -n '2,32p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;; *) echo "Unknown: $1" >&2; exit 1 ;;
esac esac
done done
if [[ -n "${FINAL_GAP}" && "${NO_DESCENT}" == true ]]; then
echo "ERROR: 同时写了 --final-gap 和 --no-descent,不知道听谁的。" >&2
exit 1
fi
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "${ROOT}/scripts/env.sh" source "${ROOT}/scripts/env.sh"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -311,9 +319,10 @@ ARGS=(
) )
[[ -n "${INIT_Z}" ]] && ARGS+=("init_z_m:=${INIT_Z}") [[ -n "${INIT_Z}" ]] && ARGS+=("init_z_m:=${INIT_Z}")
[[ -n "${HOVER_Z}" ]] && ARGS+=("hover_z_m:=${HOVER_Z}") [[ -n "${HOVER_Z}" ]] && ARGS+=("hover_z_m:=${HOVER_Z}")
[[ -n "${FINAL_GAP}" ]] && ARGS+=("final_gap_mm:=${FINAL_GAP}")
[[ -n "${HOLE}" ]] && ARGS+=("target_hole:=${HOLE}") [[ -n "${HOLE}" ]] && ARGS+=("target_hole:=${HOLE}")
[[ -n "${DEPTH}" ]] && ARGS+=("insert_depth_mm:=${DEPTH}") [[ -n "${DEPTH}" ]] && ARGS+=("insert_depth_mm:=${DEPTH}")
[[ -n "${PITCH}" ]] && ARGS+=("screw_pitch_mm:=${PITCH}") [[ -n "${PITCH}" ]] && ARGS+=("screw_pitch_mm:=${PITCH}")
echo "=== launch pipeline hole=${HOLE:-site} depth=${DEPTH:-site} pitch=${PITCH:-site} init_z=${INIT_Z:-site} hover_z=${HOVER_Z:-site} skip1=${SKIP_STAGE1} skip2=${SKIP_STAGE2} skip3=${SKIP_STAGE3} no_descent=${NO_DESCENT} d435_only=${D435_ONLY} ===" echo "=== launch pipeline hole=${HOLE:-site} depth=${DEPTH:-site} pitch=${PITCH:-site} init_z=${INIT_Z:-site} hover_z=${HOVER_Z:-site} final_gap=${FINAL_GAP:-site} skip1=${SKIP_STAGE1} skip2=${SKIP_STAGE2} skip3=${SKIP_STAGE3} no_descent=${NO_DESCENT} d435_only=${D435_ONLY} ==="
exec ros2 launch arm_application pipeline.launch.py "${ARGS[@]}" exec ros2 launch arm_application pipeline.launch.py "${ARGS[@]}"
+6 -2
View File
@@ -12,6 +12,7 @@
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hole k2 # 对准 k2 # bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hole k2 # 对准 k2
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-only # 只垂直+悬停锁定(调高度) # bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-only # 只垂直+悬停锁定(调高度)
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-z 0.28 # 临时改悬停高(不改 yaml) # bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --hover-z 0.28 # 临时改悬停高(不改 yaml)
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --final-gap 8 # 下臂锁 XY,规尖离面 8mm
# bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --no-tactile # 不起触觉服务 # bash ~/ros2/scripts/run_stage2_locate.sh --arm 89 --no-tactile # 不起触觉服务
# #
# 悬停锁高固定接口:config/site.yaml → robots[89].servo.hole_hover_z_m # 悬停锁高固定接口:config/site.yaml → robots[89].servo.hole_hover_z_m
@@ -21,6 +22,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOVER_ONLY=false HOVER_ONLY=false
HOLE="" HOLE=""
HOVER_Z="" HOVER_Z=""
FINAL_GAP=""
WITH_TACTILE=true WITH_TACTILE=true
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -33,8 +35,9 @@ while [[ $# -gt 0 ]]; do
--hover-only) HOVER_ONLY=true; shift ;; --hover-only) HOVER_ONLY=true; shift ;;
--hole) HOLE="${2:?}"; shift 2 ;; --hole) HOLE="${2:?}"; shift 2 ;;
--hover-z) HOVER_Z="${2:?}"; shift 2 ;; --hover-z) HOVER_Z="${2:?}"; shift 2 ;;
--final-gap) FINAL_GAP="${2:?--final-gap 需要规尖离面间隙,单位毫米}"; shift 2 ;;
--no-tactile) WITH_TACTILE=false; shift ;; --no-tactile) WITH_TACTILE=false; shift ;;
-h|--help) sed -n '2,22p' "$0"; exit 0 ;; -h|--help) sed -n '2,18p' "$0"; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 1 ;; *) echo "Unknown: $1" >&2; exit 1 ;;
esac esac
done done
@@ -137,5 +140,6 @@ echo "=== 确认:左臂夹爪已夹持通止规,通止规轴线与法兰轴
ARGS=(hover_only:="${HOVER_ONLY}") ARGS=(hover_only:="${HOVER_ONLY}")
if [[ -n "${HOLE}" ]]; then ARGS+=(target_hole:="${HOLE}"); fi if [[ -n "${HOLE}" ]]; then ARGS+=(target_hole:="${HOLE}"); fi
if [[ -n "${HOVER_Z}" ]]; then ARGS+=(hover_z_m:="${HOVER_Z}"); fi if [[ -n "${HOVER_Z}" ]]; then ARGS+=(hover_z_m:="${HOVER_Z}"); fi
echo "=== launch stage2_locate hover_only=${HOVER_ONLY} hole=${HOLE:-site} hover_z=${HOVER_Z:-site} ===" if [[ -n "${FINAL_GAP}" ]]; then ARGS+=(final_gap_mm:="${FINAL_GAP}"); fi
echo "=== launch stage2_locate hover_only=${HOVER_ONLY} hole=${HOLE:-site} hover_z=${HOVER_Z:-site} final_gap=${FINAL_GAP:-site} ==="
exec ros2 launch arm_application stage2_locate.launch.py "${ARGS[@]}" exec ros2 launch arm_application stage2_locate.launch.py "${ARGS[@]}"
@@ -154,9 +154,9 @@ def d405_commands_arm(
) -> bool: ) -> bool:
"""D405 这一帧的点能不能下发给左臂。 """D405 这一帧的点能不能下发给左臂。
推理一直开着。正在用 D435 那一个点时,杀栈 MoveL 冷启动结束并且停稳之前不下发。 推理一直开着。正在用 D435 那一个点时,进程内 MoveL 结束并且停稳之前不下发。
停稳从控制栈回来之后才计,用 ``d435_handback_sec`` 和 停稳用 ``d435_handback_sec`` 和 ``lock_still_move_m``。赶路时 XY 已经进 15 mm
``lock_still_move_m``。赶路时 XY 已经进 15 mm 也不算到位。 也不算到位。不许在 MoveL 期间下发 D405(两套控制互抢会晃)。
``move_only``:只测 D435 位移,D405 点一律不下发(到位之后也不交)。 ``move_only``:只测 D435 位移,D405 点一律不下发(到位之后也不交)。
""" """
if move_only: if move_only:
@@ -541,6 +541,112 @@ HOLE_PAIR_TRIM_MAX_M = 0.070
# 悬停 → 观察高(约 15mm)单步上限。左臂间隙约 58mm,20mm × 3 步走完。 # 悬停 → 观察高(约 15mm)单步上限。左臂间隙约 58mm,20mm × 3 步走完。
# 观察高 → 贴面不再走这段,改斜线一步(XY 补偿 + 剩余 Z)。 # 观察高 → 贴面不再走这段,改斜线一步(XY 补偿 + 剩余 Z)。
ONCE_APPROACH_STEP_M = 0.020 ONCE_APPROACH_STEP_M = 0.020
# 悬停已对上时 ΔXY 必须为 0。site ``once_ff_look_per_m`` 同源。
# 2026-10-14 下午:框心改平面交点后两条链路下臂都仍 +1.3mm X 过补插不进。
# k=0.018 是深度框心时代的对冲,不是下降几何。笛卡尔 MoveL 只降 Z。
ONCE_FF_LOOK_PER_M = 0.0
ONCE_FF_MOVEL_VEL_MPS = 0.05
def once_look_ff_xy(look_xy, descent_m: float, k: float):
"""视线前馈 ΔXY [m]。k≤0 是笛卡尔锁 XY:返回 (0,0),不是 None。
k>0 时沿 -look 走 k×下降。下降≤0 / 视线退化 → None。
"""
if float(descent_m) <= 1e-9:
return None
if float(k) <= 1e-9:
return np.array([0.0, 0.0], dtype=float)
look = np.asarray(look_xy, dtype=float).reshape(2)
n = float(np.linalg.norm(look))
if n < 1e-6 or not np.all(np.isfinite(look)):
return None
look = look / n
dxy = -look * float(k) * float(descent_m)
if not np.all(np.isfinite(dxy)):
return None
return dxy
def once_ff_tcp_xyz(
flange_xy,
tip_off_xy,
look_xy,
descent_m: float,
k: float,
surface_z_m: float,
gap_m: float,
):
"""规尖(厂家 MoveL 的 TCP)目标 [m]。Z 是面 + 间隙,不是法兰。"""
dxy = once_look_ff_xy(look_xy, descent_m, k)
if dxy is None:
return None
off = np.asarray(tip_off_xy, dtype=float).reshape(2)
fl = np.asarray(flange_xy, dtype=float).reshape(2)
if not np.all(np.isfinite(off)) or not np.all(np.isfinite(fl)):
return None
tcp = np.array(
[
float(fl[0]) + float(off[0]) + float(dxy[0]),
float(fl[1]) + float(off[1]) + float(dxy[1]),
float(surface_z_m) + float(gap_m),
],
dtype=float,
)
if not np.all(np.isfinite(tcp)):
return None
return tcp, dxy
def d435_movel_from_tcp(tcp_xyz, flange_xy, k0_xy):
"""D435 粗定位 MoveL 目标 [m]:当前厂家 TCP + (K0−法兰) 的 ROS XY。
keep_z 由硬件用当前 TCP 的 Z 覆盖。ROS 绝对 K0 不能当 WORLD 目标
(2026-10-14 贴面 MoveL 已踩过)。缺数 / 非有限 → None。
"""
try:
t = np.asarray(tcp_xyz, dtype=float).reshape(3)
fl = np.asarray(flange_xy, dtype=float).reshape(2)
k0 = np.asarray(k0_xy, dtype=float).reshape(2)
except (TypeError, ValueError):
return None
if t.size != 3 or fl.size != 2 or k0.size != 2:
return None
if not np.all(np.isfinite(t)) or not np.all(np.isfinite(fl)) or not np.all(np.isfinite(k0)):
return None
out = np.array(
[float(t[0]) + float(k0[0]) - float(fl[0]),
float(t[1]) + float(k0[1]) - float(fl[1]),
float(t[2])],
dtype=float,
)
if not np.all(np.isfinite(out)):
return None
return out
def once_ff_movel_from_tcp(tcp_xyz, dxy, descent_m: float):
"""厂家 MoveL 目标 = 当前 Studio TCP + (ΔXY, −下降) [m]。
2026-10-14:把 ROS 规尖绝对坐标发给 MoveL,k=0.018 与 0.035 都落到
WORLD (0.595, −0.468, 0.024),法兰实走约 (+9, −8)mm。WORLD 原点与
ROS 基座不是同一点,绝对坐标不能用。相对位移才跟 k 有关。
"""
if float(descent_m) <= 1e-9:
return None
t = np.asarray(tcp_xyz, dtype=float).reshape(3)
d = np.asarray(dxy, dtype=float).reshape(2)
if t.size != 3 or d.size != 2:
return None
if not np.all(np.isfinite(t)) or not np.all(np.isfinite(d)):
return None
out = np.array(
[float(t[0]) + float(d[0]), float(t[1]) + float(d[1]), float(t[2]) - float(descent_m)],
dtype=float,
)
if not np.all(np.isfinite(out)):
return None
return out
def oneshot_align_step_m(err_m: float) -> float: def oneshot_align_step_m(err_m: float) -> float:
@@ -570,6 +676,46 @@ def flange_z_for_gap_m(tip_len_m: float, surface_z_m: float, gap_m: float) -> fl
return float(tip_len_m) + float(surface_z_m) + float(gap_m) return float(tip_len_m) + float(surface_z_m) + float(gap_m)
def final_gap_refusal(
gap_m: float,
tool_len_m: float,
surface_z_m: float,
hover_z_m: float,
z_min_m: float,
z_max_m: float,
) -> str:
"""笛卡尔锁 XY 的贴面间隙能不能用。空串表示可以;非空就是拒绝原因。
参数是规尖离面 [m],不是法兰 Z:法兰 = 工具长 + 工件面 + 间隙,只在这里算一次。
"""
gap = float(gap_m)
if not np.isfinite(gap):
return "贴面间隙不是有限数,拒绝开跑"
if gap <= 1e-9:
return (
f"贴面间隙 {gap * 1000:.1f}mm ≤ 0,规尖会进工件/贴面接触,拒绝开跑"
)
tool = float(tool_len_m)
surf = float(surface_z_m)
if tool <= 0.0 or surf <= 0.0:
return "写了贴面间隙,但工具长度或工件面未知,无法确认尖端在面之上,拒绝开跑"
hover_clear = float(hover_z_m) - tool - surf
if gap > hover_clear + 1e-6:
return (
f"贴面间隙 {gap * 1000:.1f}mm 比悬停离面 {hover_clear * 1000:.1f}mm 还高,"
"锁 XY 只会抬臂,拒绝开跑"
)
flange = flange_z_for_gap_m(tool, surf, gap)
z_lo = float(z_min_m)
z_hi = float(z_max_m)
if flange < z_lo - 1e-6 or flange > z_hi + 1e-6:
return (
f"贴面法兰 Z {flange:.3f}m(规尖离面 {gap * 1000:.1f}mm)"
f"超出工作盒 Z [{z_lo:.3f}, {z_hi:.3f}],拒绝开跑"
)
return ""
def decompose_base_xy(delta_xy, T_cam_to_base: np.ndarray): def decompose_base_xy(delta_xy, T_cam_to_base: np.ndarray):
"""把基座 XY 残差拆成沿视线 / 沿画面右 [m]。投影退化返回 None。""" """把基座 XY 残差拆成沿视线 / 沿画面右 [m]。投影退化返回 None。"""
axes = cam_xy_bias_axes(T_cam_to_base) axes = cam_xy_bias_axes(T_cam_to_base)
@@ -14,12 +14,11 @@ from __future__ import annotations
import os import os
import math import math
import subprocess
import sys import sys
import threading import threading
import time import time
from collections import deque from collections import deque
from typing import Deque, List, Optional from typing import Deque, List, Optional, Tuple
import numpy as np import numpy as np
import rclpy import rclpy
@@ -35,6 +34,7 @@ from rclpy.qos import (
HistoryPolicy, HistoryPolicy,
QoSProfile, QoSProfile,
ReliabilityPolicy, ReliabilityPolicy,
qos_profile_sensor_data,
qos_profile_system_default, qos_profile_system_default,
) )
from sensor_msgs.msg import JointState from sensor_msgs.msg import JointState
@@ -52,11 +52,11 @@ from arm_application.handeye_lib import cam_point_to_base, cam_to_base_T
from arm_application.overhead_oneshot import ( from arm_application.overhead_oneshot import (
D435_NEAR_CRUISE_MPS, D435_NEAR_CRUISE_MPS,
D435_SETTLE_S, D435_SETTLE_S,
D435_VENDOR_MOVEL_ACC_MPS2,
D435_VENDOR_MOVEL_VEL_MPS, D435_VENDOR_MOVEL_VEL_MPS,
StillLock, StillLock,
at_lock_height, at_lock_height,
d405_commands_arm, d405_commands_arm,
d435_movel_from_tcp,
flange_tilt_rad, flange_tilt_rad,
hold_translation_vz, hold_translation_vz,
hybrid_cruise_vxy, hybrid_cruise_vxy,
@@ -88,6 +88,11 @@ from arm_application.site_io import (
from arm_application.ros_node import TolerantNode from arm_application.ros_node import TolerantNode
try:
from flexiv_msgs.msg import RobotStates
except ImportError: # pragma: no cover
RobotStates = None # type: ignore
DEFAULT_JOINTS = [ DEFAULT_JOINTS = [
"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7",
@@ -219,7 +224,8 @@ class ServoNode(TolerantNode):
self.declare_parameter("lock_hold_sec", 2.0) self.declare_parameter("lock_hold_sec", 2.0)
# D405 跟住后法兰静止多久进二阶段。哨兵 -1 = 没从 site 接入。 # D405 跟住后法兰静止多久进二阶段。哨兵 -1 = 没从 site 接入。
# lock_still_sec 仍须从 site 接上(缺了就报错)。 # lock_still_sec 仍须从 site 接上(缺了就报错)。
# 交回 D405:MoveL 结束后杀栈冷启动,再叠 d435_handback_sec(现场 3 s)。 # 交回 D405:进程内 MoveL 到位后,再叠 d435_handback_sec(法兰 XY 停稳)。
# 旧杀栈冷启动曾把这 3s 垫在控制器重启后面;不杀栈后只留机械停稳。
self.declare_parameter("lock_still_sec", -1.0) self.declare_parameter("lock_still_sec", -1.0)
self.declare_parameter("d435_handback_sec", -1.0) self.declare_parameter("d435_handback_sec", -1.0)
self.declare_parameter("wrist_align_sec", -1.0) self.declare_parameter("wrist_align_sec", -1.0)
@@ -511,6 +517,24 @@ class ServoNode(TolerantNode):
10, 10,
callback_group=self._control_cbg, callback_group=self._control_cbg,
) )
# 厂家 tcp_pose(WORLD)。D435 MoveL 必须在这上面加 ROS 相对位移。
self._tcp_world: Optional[Tuple[float, float, float]] = None
self._t_tcp: Optional[float] = None
prefix_ns = str(self.get_parameter("joint_prefix").value or "").rstrip("_")
self.robot_states_topic = f"/{prefix_ns.replace('-', '_')}/flexiv_robot_states"
if RobotStates is not None:
self.create_subscription(
RobotStates,
self.robot_states_topic,
self._on_servo_robot_states,
qos_profile_sensor_data,
callback_group=self._state_cbg,
)
else:
self.get_logger().error(
"没有 flexiv_msgs.RobotStates,D435 粗定位不能读厂家 tcp_pose,"
"不许退回杀栈 MoveL"
)
cmd_qos = QoSProfile( cmd_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST, history=HistoryPolicy.KEEP_LAST,
@@ -606,6 +630,23 @@ class ServoNode(TolerantNode):
def _on_js(self, msg: JointState) -> None: def _on_js(self, msg: JointState) -> None:
self._last_js = msg self._last_js = msg
def _on_servo_robot_states(self, msg) -> None:
"""缓存厂家 ``tcp_pose``(WORLD)。名字避开其它节点的 ``_on_robot_states``。"""
p = msg.tcp_pose.pose.position
xyz = (float(p.x), float(p.y), float(p.z))
if not all(math.isfinite(v) for v in xyz):
return
self._tcp_world = xyz
self._t_tcp = time.monotonic()
def _fresh_tcp_world(self):
"""最近 0.5s 的厂家 TCP [m]。过期/没有 → None,不许用旧值凑。"""
if self._tcp_world is None or self._t_tcp is None:
return None
if time.monotonic() - float(self._t_tcp) > 0.5:
return None
return self._tcp_world
def _on_wrist_conf(self, msg: Float32) -> None: def _on_wrist_conf(self, msg: Float32) -> None:
c = float(msg.data) c = float(msg.data)
with self._lock: with self._lock:
@@ -1545,12 +1586,17 @@ class ServoNode(TolerantNode):
return False, "" return False, ""
def _d435_vendor_movel(self) -> bool: def _d435_vendor_movel(self) -> bool:
"""D435 这一点:停真机栈 → 厂家 MoveL → 冷启动控制栈。 """D435 这一点:进程内厂家 MoveL,不杀控制栈。
线速度 / 加速度走 ``D435_VENDOR_MOVEL_VEL_MPS``、``D435_VENDOR_MOVEL_ACC_MPS2``, 目标 = 当前厂家 TCP + (K0−法兰) 的 ROS XY,``keep_z``。硬件在 MoveL
交给 SDK 的 ``vel`` / ``acc``。杀栈再拉起大约多几秒,RDK 独占, 期间不灌关节指令,D405 被 ``d435_commanding`` 挡住。不许退回杀栈
D405 的点在整段 busy 期间都不会下发。停稳计时从冷启动回来之后才开始。 (那会冷启动十几秒,而且两套控制会互抢晃臂)。
""" """
lib = ros2_root() / "scripts" / "lib"
if str(lib) not in sys.path:
sys.path.insert(0, str(lib))
from flexiv_movel_ipc import write_request
with self._lock: with self._lock:
goal = None if self._p_feat_base is None else np.asarray( goal = None if self._p_feat_base is None else np.asarray(
self._p_feat_base, dtype=float self._p_feat_base, dtype=float
@@ -1558,38 +1604,51 @@ class ServoNode(TolerantNode):
if goal is None or not np.all(np.isfinite(goal)): if goal is None or not np.all(np.isfinite(goal)):
self.get_logger().error("没有 D435 基座点,厂家 MoveL 不下发") self.get_logger().error("没有 D435 基座点,厂家 MoveL 不下发")
return False return False
arm = str(os.environ.get("SITE_ROBOT_ARM") or "89").strip() or "89" tcp_now = self._fresh_tcp_world()
script = ros2_root() / "scripts" / "rdk_release_movel.py" if tcp_now is None:
if not script.is_file(): self.get_logger().error(
self.get_logger().error(f"找不到 {script}") "没有厂家 tcp_pose(话题 "
f"{getattr(self, 'robot_states_topic', '?')} 不新鲜),"
"拒绝杀栈凑 MoveL"
)
return False return False
cmd = [ T_ee = self._lookup_T(self.base_frame, self.ee_frame)
sys.executable, if T_ee is None:
str(script), self.get_logger().error("没有法兰 TF,D435 相对位移算不出,不下发")
"--xyz", return False
f"{float(goal[0]):.6f}", tgt = d435_movel_from_tcp(tcp_now, T_ee[:2, 3], goal[:2])
f"{float(goal[1]):.6f}", if tgt is None:
f"{float(goal[2]):.6f}", self.get_logger().error("D435 厂家 TCP 加上相对位移算不出,不下发")
"--arm", return False
arm, seq = int(time.monotonic() * 1000.0) % 1_000_000_000
"--vel", write_request(
f"{float(D435_VENDOR_MOVEL_VEL_MPS):.3f}", seq,
"--acc", float(tgt[0]),
f"{float(D435_VENDOR_MOVEL_ACC_MPS2):.3f}", float(tgt[1]),
] float(tgt[2]),
True,
float(D435_VENDOR_MOVEL_VEL_MPS),
)
self.get_logger().info( self.get_logger().info(
f"D435 厂家 MoveL XY=({goal[0]:.3f},{goal[1]:.3f}) " f"D435 进程内 MoveL XY=({goal[0]:.3f},{goal[1]:.3f}) "
f"vel={D435_VENDOR_MOVEL_VEL_MPS*1000:.0f}mm/s " f"厂家TCP现=({tcp_now[0]:.4f},{tcp_now[1]:.4f},{tcp_now[2]:.4f}) "
f"acc={D435_VENDOR_MOVEL_ACC_MPS2:.1f}m/s² " f"MoveL=({tgt[0]:.4f},{tgt[1]:.4f},keep_z) "
"—— 先停控制栈,MoveL 后再冷启动;期间不下发 D405" f"vel={D435_VENDOR_MOVEL_VEL_MPS * 1000:.0f}mm/s "
"—— 不杀栈;期间不下发 D405"
) )
t0 = time.monotonic() t0 = time.monotonic()
got = subprocess.run(cmd, check=False) acked, _why = self._wait_movel_file(seq, 2.0, want_done=False)
if not acked:
self.get_logger().error(
"厂家 MoveL 2s 内没 ack —— 硬件没接 /tmp/flexiv_movel_req。"
"真机环境重建 install/ 后再跑,不许改走杀栈假装成功"
)
return False
ok, detail = self._wait_movel_file(seq, 90.0, want_done=True)
dt = time.monotonic() - t0 dt = time.monotonic() - t0
ok = got.returncode == 0
self.get_logger().info( self.get_logger().info(
f"rdk_release_movel {'完成' if ok else '失败'},耗时 {dt:.2f}s" f"D435 进程内 MoveL {'完成' if ok else '失败'},耗时 {dt:.2f}s"
+ (f" 退出码 {got.returncode}" if not ok else "") + (f" {detail}" if detail else "")
) )
return bool(ok) return bool(ok)
@@ -10,6 +10,7 @@
4. fine — 拍照,two_stage(YOLO+HoughCircles)解 k1/k2 孔心像素 4. fine — 拍照,two_stage(YOLO+HoughCircles)解 k1/k2 孔心像素
5. align — 像素射线交工件面 + 内参 + 手眼,把规尖移到目标孔正上方。 5. align — 像素射线交工件面 + 内参 + 手眼,把规尖移到目标孔正上方。
``hole_keypoint_once`` 时只走一次,核验拍只记账; ``hole_keypoint_once`` 时只走一次,核验拍只记账;
``hole_once_lock_xy_descend`` 时按视线前馈一条 MoveL 贴面;
否则复拍迭代到 align_tol_m 否则复拍迭代到 align_tol_m
6. hold — 锁定关节,发布 /stage2/result(JSON) + /tmp/stage2_result.json 6. hold — 锁定关节,发布 /stage2/result(JSON) + /tmp/stage2_result.json
@@ -22,6 +23,7 @@ from __future__ import annotations
import json import json
import math import math
import os import os
import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -53,12 +55,18 @@ from arm_application.path_profile import (
) )
from arm_application.overhead_oneshot import ( from arm_application.overhead_oneshot import (
ONCE_APPROACH_STEP_M, ONCE_APPROACH_STEP_M,
ONCE_FF_LOOK_PER_M,
ONCE_FF_MOVEL_VEL_MPS,
ONCE_TRIM_MAX_STEP_M, ONCE_TRIM_MAX_STEP_M,
ONCE_TRIM_REFUSE_M, ONCE_TRIM_REFUSE_M,
cam_xy_bias_axes,
decompose_base_xy, decompose_base_xy,
flange_z_for_gap_m, flange_z_for_gap_m,
final_gap_refusal,
hole_pair_span_m, hole_pair_span_m,
hole_pair_span_ok, hole_pair_span_ok,
once_ff_movel_from_tcp,
once_ff_tcp_xyz,
oneshot_align_step_m, oneshot_align_step_m,
pixel_on_base_plane, pixel_on_base_plane,
) )
@@ -67,9 +75,14 @@ from arm_application.gauge_boot import perception_source_of
from arm_application.gauge_fsm import drag_verdict, resolve_hover_z, wrench_all_zero from arm_application.gauge_fsm import drag_verdict, resolve_hover_z, wrench_all_zero
from arm_application.hole_detect_hough import detect_holes from arm_application.hole_detect_hough import detect_holes
from arm_application.hole_detector import config_from_site, weights_from_site from arm_application.hole_detector import config_from_site, weights_from_site
from arm_application.site_io import deep_merge, load_site_dict from arm_application.site_io import deep_merge, load_site_dict, ros2_root
from arm_application.ros_node import TolerantNode from arm_application.ros_node import TolerantNode
try:
from flexiv_msgs.msg import RobotStates
except ImportError: # pragma: no cover
RobotStates = None # type: ignore
def contact_armed_gap_m(observe_gap_m: float, final_gap_m: float = 0.002) -> float: def contact_armed_gap_m(observe_gap_m: float, final_gap_m: float = 0.002) -> float:
"""轻降力接触门槛对应的几何间隙。离面更远时腕部力会漂几牛,不能当碰到。 """轻降力接触门槛对应的几何间隙。离面更远时腕部力会漂几牛,不能当碰到。
@@ -152,6 +165,9 @@ class Stage2Locate(TolerantNode):
self.declare_parameter("hover_only", False) self.declare_parameter("hover_only", False)
self.declare_parameter("target_hole", "") # 空字符串 → 用 site 的 stage2.target_hole self.declare_parameter("target_hole", "") # 空字符串 → 用 site 的 stage2.target_hole
self.declare_parameter("hover_z_m", -1.0) # 大于 0 → 覆盖 site 里的值 self.declare_parameter("hover_z_m", -1.0) # 大于 0 → 覆盖 site 里的值
# 贴面规尖离面 [mm]。≥0 → 覆盖 site ``final_gap_mm``。-1=用 site。
# 笛卡尔锁 XY 时法兰 Z = 工具长 + 工件面 + 间隙,XY 不动。
self.declare_parameter("final_gap_mm", -1.0)
# 工具尖端在法兰下方多少 [m];大于 0 → 覆盖 site。 # 工具尖端在法兰下方多少 [m];大于 0 → 覆盖 site。
# 仿真里由 run_gauge_assembly.sh --sim 传**实测值**(每次上料都可能不同), # 仿真里由 run_gauge_assembly.sh --sim 传**实测值**(每次上料都可能不同),
# 真机不传(走 site.yaml)。悬停高度与 S07 下探预算都按它算。 # 真机不传(走 site.yaml)。悬停高度与 S07 下探预算都按它算。
@@ -235,8 +251,14 @@ class Stage2Locate(TolerantNode):
# k1/k2,而流程会把它当"孔检测失败",现场根本想不到是粗居中把臂带走的。 # k1/k2,而流程会把它当"孔检测失败",现场根本想不到是粗居中把臂带走的。
self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.12) self.coarse_max_offset_m = float(stage2.get("coarse_max_offset_m") or 0.12)
self.coarse_border_px = int(stage2.get("coarse_border_px") or 2) self.coarse_border_px = int(stage2.get("coarse_border_px") or 2)
# 最后轻降到孔面上方 final_gap_mm(不接触) # 最后轻降到孔面上方 final_gap_mm(不接触)。启动参数 ≥0 覆盖 site。
self.final_gap_m = float(stage2.get("final_gap_mm") or 1.5) / 1000.0 self._final_gap_refused = ""
_gap_override_mm = float(self.get_parameter("final_gap_mm").value)
self.final_gap_user_set = _gap_override_mm >= 0.0
if self.final_gap_user_set:
self.final_gap_m = _gap_override_mm / 1000.0
else:
self.final_gap_m = float(stage2.get("final_gap_mm") or 1.5) / 1000.0
# 观测下降:对准后降到这个间隙就停,方便看规尖是不是对着孔。 # 观测下降:对准后降到这个间隙就停,方便看规尖是不是对着孔。
# 比 final_gap(贴面 2mm)高一截,对偏了也不会把规压上孔沿。 # 比 final_gap(贴面 2mm)高一截,对偏了也不会把规压上孔沿。
self.observe_gap_m = float(stage2.get("observe_gap_mm") or 15.0) / 1000.0 self.observe_gap_m = float(stage2.get("observe_gap_mm") or 15.0) / 1000.0
@@ -269,6 +291,16 @@ class Stage2Locate(TolerantNode):
# 本分支实验:悬停拍一次 k1/k2,走一次,复拍只记账不改 XY。 # 本分支实验:悬停拍一次 k1/k2,走一次,复拍只记账不改 XY。
# 键必须接线——写了 true 却仍多走多拍,等于没做实验。 # 键必须接线——写了 true 却仍多走多拍,等于没做实验。
self.hole_keypoint_once = _as_bool(stage2.get("hole_keypoint_once"), False) self.hole_keypoint_once = _as_bool(stage2.get("hole_keypoint_once"), False)
# 悬停走完后一条厂家 MoveL 收到贴面。k=0:笛卡尔锁 XY 只降 Z。
# 缺键 = 仍走观察高补交(37b85aa)。
self.hole_once_lock_xy_descend = _as_bool(
stage2.get("hole_once_lock_xy_descend"), False
)
_ff = stage2.get("once_ff_look_per_m")
if _ff is None:
self.once_ff_look_per_m = float(ONCE_FF_LOOK_PER_M)
else:
self.once_ff_look_per_m = float(_ff)
self._ws_clipped = False self._ws_clipped = False
self._hole_snaps: List[Dict[str, Any]] = [] self._hole_snaps: List[Dict[str, Any]] = []
if self.hover_z_user_set and (_tool_len <= 0.0 or _surface_z <= 0.0): if self.hover_z_user_set and (_tool_len <= 0.0 or _surface_z <= 0.0):
@@ -314,6 +346,28 @@ class Stage2Locate(TolerantNode):
f"final_descent_enable 被启动参数覆盖为 {self.final_descent_enable} " f"final_descent_enable 被启动参数覆盖为 {self.final_descent_enable} "
f"(site 里是 {stage2.get('final_descent_enable')})—— 只在调试时这么用" f"(site 里是 {stage2.get('final_descent_enable')})—— 只在调试时这么用"
) )
if self.final_gap_user_set and not self.final_descent_enable:
self._final_gap_refused = (
"同时写了 --final-gap 和 --no-descent,不知道听谁的,拒绝开跑"
)
self.get_logger().error(self._final_gap_refused)
elif self.final_gap_user_set and (_tool_len <= 0.0 or _surface_z <= 0.0):
self._final_gap_refused = (
"写了 --final-gap,但工具长度或工件面未知,无法确认尖端在面之上,拒绝执行"
)
self.get_logger().error(self._final_gap_refused)
elif _tool_len > 0.0 and _surface_z > 0.0:
_why_gap = final_gap_refusal(
self.final_gap_m,
_tool_len,
_surface_z,
self.hover_z,
float(self.ws_min[2]),
float(self.ws_max[2]),
)
if _why_gap:
self._final_gap_refused = _why_gap
self.get_logger().error(_why_gap)
self.vert_tol_rad = math.radians(float(stage2.get("verticalize_tol_deg") or 0.8)) self.vert_tol_rad = math.radians(float(stage2.get("verticalize_tol_deg") or 0.8))
self.settle_sec = float(stage2.get("settle_sec") or 0.45) self.settle_sec = float(stage2.get("settle_sec") or 0.45)
self.snap_frames = int(stage2.get("snap_frames") or 5) self.snap_frames = int(stage2.get("snap_frames") or 5)
@@ -398,6 +452,23 @@ class Stage2Locate(TolerantNode):
self.create_subscription( self.create_subscription(
WrenchStamped, self.ft_wrench_topic, self._on_wrench, qos_profile_sensor_data WrenchStamped, self.ft_wrench_topic, self._on_wrench, qos_profile_sensor_data
) )
# 厂家 tcp_pose(WORLD)。视线前馈 MoveL 必须在这上面加相对位移,
# 不许拿 ROS 法兰+规尖当绝对目标。回调名避开 stage3 的 `_on_robot_states`。
self._tcp_world: Optional[Tuple[float, float, float]] = None
self._t_tcp: Optional[float] = None
self.robot_states_topic = f"/{robot_ns}/flexiv_robot_states"
if RobotStates is not None:
self.create_subscription(
RobotStates,
self.robot_states_topic,
self._on_stage2_robot_states,
qos_profile_sensor_data,
)
elif self.hole_once_lock_xy_descend:
self.get_logger().error(
"没有 flexiv_msgs.RobotStates,视线前馈不能读厂家 tcp_pose,"
"不许退回 ROS 绝对坐标"
)
self._state_pub = self.create_publisher(String, "/stage2/state", 10) self._state_pub = self.create_publisher(String, "/stage2/state", 10)
self._result_pub = self.create_publisher(String, "/stage2/result", 10) self._result_pub = self.create_publisher(String, "/stage2/result", 10)
@@ -415,10 +486,33 @@ class Stage2Locate(TolerantNode):
f" image={self.image_topic} depth={self.depth_topic}\n" f" image={self.image_topic} depth={self.depth_topic}\n"
f" target={self.target_hole} (k1={self.k1_label}) tol={self.align_tol*1000:.1f}mm " f" target={self.target_hole} (k1={self.k1_label}) tol={self.align_tol*1000:.1f}mm "
f"hover_only={self.hover_only} hole_visual_only={self.hole_visual_only} " f"hover_only={self.hover_only} hole_visual_only={self.hole_visual_only} "
f"hole_keypoint_once={self.hole_keypoint_once}\n" f"hole_keypoint_once={self.hole_keypoint_once} "
f"lock_xy_descend={self.hole_once_lock_xy_descend} "
f"ff_look={self.once_ff_look_per_m:.3f} "
f"final_gap={self.final_gap_m * 1000:.1f}mm"
+ (
f"→法兰Z={flange_z_for_gap_m(self.tool_tip_below_flange_m, self.surface_z_m, self.final_gap_m):.4f}m"
if self.tool_tip_below_flange_m > 0.0 and self.surface_z_m > 0.0
else ""
)
+ "\n"
f" 工具长={self.tool_tip_below_flange_m:.4f}m(法兰到尖,下臂不得低于面+间隙)" f" 工具长={self.tool_tip_below_flange_m:.4f}m(法兰到尖,下臂不得低于面+间隙)"
) )
if self.hole_keypoint_once: if self.hole_keypoint_once and self.hole_once_lock_xy_descend:
k = float(self.once_ff_look_per_m)
if k <= 1e-9:
self.get_logger().warn(
"对孔方案=单次精拍·笛卡尔锁 XY(实验)。悬停走完后不补拍,"
"厂家 MoveL 只降 Z、ΔXY=0。k>0 会沿 -look 再加前馈。"
"对照 37b85aa 把 hole_once_lock_xy_descend 改回 false。"
)
else:
self.get_logger().warn(
"对孔方案=单次精拍·视线前馈斜线(实验)。悬停走完后不补拍,"
f"ΔXY=-look×{k:.3f}×剩余下降,一条 MoveL 到贴面。"
"对照 37b85aa 把 hole_once_lock_xy_descend 改回 false。"
)
elif self.hole_keypoint_once:
self.get_logger().warn( self.get_logger().warn(
"对孔方案=单次精拍(实验分支)。拍一次→走一次,复拍和下降后只记账不改 XY。" "对孔方案=单次精拍(实验分支)。拍一次→走一次,复拍和下降后只记账不改 XY。"
"残差以核验拍为准,不许用 hop 到位冒充。看 /tmp/stage2_result.json 的 snaps。" "残差以核验拍为准,不许用 hop 到位冒充。看 /tmp/stage2_result.json 的 snaps。"
@@ -438,6 +532,23 @@ class Stage2Locate(TolerantNode):
def _on_info(self, msg: CameraInfo) -> None: def _on_info(self, msg: CameraInfo) -> None:
self._info = msg self._info = msg
def _on_stage2_robot_states(self, msg) -> None:
"""缓存厂家 ``tcp_pose``(WORLD)。名字必须与 stage3 ``_on_robot_states`` 分开。"""
p = msg.tcp_pose.pose.position
xyz = (float(p.x), float(p.y), float(p.z))
if not all(math.isfinite(v) for v in xyz):
return
self._tcp_world = xyz
self._t_tcp = time.monotonic()
def _fresh_tcp_world(self):
"""最近 0.5s 的厂家 TCP [m]。过期/没有 → None,不许用旧值凑。"""
if self._tcp_world is None or self._t_tcp is None:
return None
if time.monotonic() - float(self._t_tcp) > 0.5:
return None
return self._tcp_world
def _on_wrench(self, msg: WrenchStamped) -> None: def _on_wrench(self, msg: WrenchStamped) -> None:
"""缓存腕部 FT 的**轴向力**(`geometry_msgs/WrenchStamped`,法兰系)。 """缓存腕部 FT 的**轴向力**(`geometry_msgs/WrenchStamped`,法兰系)。
@@ -1652,7 +1763,7 @@ class Stage2Locate(TolerantNode):
""" """
# final_descent_enable:多走多拍降到贴面 final_gap(约 2mm)。 # final_descent_enable:多走多拍降到贴面 final_gap(约 2mm)。
# 关掉时(--no-descent)仍做**观测下降**,停在 observe_gap(约 15mm)。 # 关掉时(--no-descent)仍做**观测下降**,停在 observe_gap(约 15mm)。
# 单次精拍由调用方传入 target_gap_m=观察高,贴面由随后斜线一步完成。 # 单次精拍补交路径传入观察高;锁 XY 实验传入贴面 final_gap。
if target_gap_m is None: if target_gap_m is None:
target_gap = ( target_gap = (
self.final_gap_m if self.final_descent_enable else self.observe_gap_m self.final_gap_m if self.final_descent_enable else self.observe_gap_m
@@ -1817,7 +1928,12 @@ class Stage2Locate(TolerantNode):
"""把基座残差拆成视线 / 画面右,写入记录。投影退化时键仍在、值为 None。""" """把基座残差拆成视线 / 画面右,写入记录。投影退化时键仍在、值为 None。"""
rec = dict(rec) rec = dict(rec)
rec["tag"] = tag rec["tag"] = tag
rec["scheme"] = "keypoint_once" if self.hole_keypoint_once else "multi_snap" if self.hole_keypoint_once and getattr(self, "hole_once_lock_xy_descend", False):
rec["scheme"] = "keypoint_once_look_ff"
elif self.hole_keypoint_once:
rec["scheme"] = "keypoint_once"
else:
rec["scheme"] = "multi_snap"
rec["cam_frame"] = self.cam_frame rec["cam_frame"] = self.cam_frame
rec["image_topic"] = self.image_topic rec["image_topic"] = self.image_topic
err_vec = np.array( err_vec = np.array(
@@ -1992,6 +2108,155 @@ class Stage2Locate(TolerantNode):
) )
return q, Tee, True return q, Tee, True
def _vendor_movel_tcp(self, xyz, vel: float) -> bool:
"""进程内厂家 MoveL:TCP 目标,不杀控制栈。"""
lib = ros2_root() / "scripts" / "lib"
if str(lib) not in sys.path:
sys.path.insert(0, str(lib))
from flexiv_movel_ipc import read_ack, read_done, write_request
seq = int(time.monotonic() * 1000.0) % 1_000_000_000
write_request(
seq, float(xyz[0]), float(xyz[1]), float(xyz[2]), False, float(vel)
)
t0 = time.monotonic()
acked = False
while time.monotonic() - t0 < 90.0:
if not rclpy.ok():
return False
self._spin(0.02)
if not acked:
if read_ack() == seq:
acked = True
elif time.monotonic() - t0 > 2.0:
self.get_logger().error(
"厂家 MoveL 2s 内没 ack —— 硬件没接 /tmp/flexiv_movel_req。"
"真机环境重建 install/ 后再跑,不许改走关节折线假装成功"
)
return False
continue
got = read_done()
if got is not None and int(got[0]) == seq:
if not got[1]:
self.get_logger().error(f"厂家 MoveL 失败:{got[2]}")
return bool(got[1])
self.get_logger().error("厂家 MoveL 90s 内没有 done")
return False
def _once_ff_diagonal_to_gap(self, q, Tee, *, target_gap_m):
"""悬停点一条 MoveL 收到 ``target_gap_m``。k=0 只降 Z;k>0 再沿 -look 收 XY。"""
self._state("ff_diagonal_movel")
tip_len = float(self.tool_tip_below_flange_m)
surf = float(self.surface_z_m)
if tip_len <= 0.0 or surf <= 0.0:
self.get_logger().error("贴面下降缺工具长/工件面,停在悬停高")
return False, q, Tee
if self._tip_off is None:
self.get_logger().error("贴面下降需要悬停规尖偏移,没有就不许用法兰凑")
return False, q, Tee
k = float(self.once_ff_look_per_m)
look_xy = np.array([0.0, 0.0], dtype=float)
T_cam = self._lookup(self.base_frame, self.cam_frame)
if k > 1e-9:
if T_cam is None:
self.get_logger().error("没有 cam TF,视线轴算不出,不下发")
return False, q, Tee
axes = cam_xy_bias_axes(T_cam)
if axes is None:
self.get_logger().error("光轴水平投影退化,不下发")
return False, q, Tee
look_xy, _right = axes
elif T_cam is not None:
axes = cam_xy_bias_axes(T_cam)
if axes is not None:
look_xy, _right = axes
gap0 = float(Tee[2, 3]) - tip_len - surf
descent = gap0 - float(target_gap_m)
packed = once_ff_tcp_xyz(
[Tee[0, 3], Tee[1, 3]],
self._tip_off,
look_xy,
descent,
k,
surf,
target_gap_m,
)
if packed is None:
if descent <= 0.001:
self.get_logger().info("规尖已在目标间隙附近,无需斜线")
self.descent_final_gap_m = gap0
return True, q, Tee
self.get_logger().error("视线前馈算不出 ΔXY,停在悬停高")
return False, q, Tee
tcp, dxy = packed
n_xy = float(np.hypot(float(dxy[0]), float(dxy[1])))
if n_xy > ONCE_TRIM_REFUSE_M + 1e-9:
self.get_logger().error(
f"视线前馈 {n_xy * 1000:.1f}mm 超过 {ONCE_TRIM_REFUSE_M * 1000:.0f}mm,"
"不像交平面那几毫米,停在悬停高"
)
return False, q, Tee
z_flange = flange_z_for_gap_m(tip_len, surf, target_gap_m)
flange_tgt = np.array(
[
float(Tee[0, 3]) + float(dxy[0]),
float(Tee[1, 3]) + float(dxy[1]),
z_flange,
]
)
self._ws_clipped = False
self._clamp_ws(flange_tgt)
if self._ws_clipped:
self.get_logger().error(
"视线前馈目标被工作盒截断,不下发(不许把孔位改到盒沿)"
)
return False, q, Tee
self._spin(0.05)
tcp_now = self._fresh_tcp_world()
if tcp_now is None:
self.get_logger().error(
"没有厂家 tcp_pose(话题 "
f"{getattr(self, 'robot_states_topic', '?')} 不新鲜),"
"拒绝用 ROS 规尖绝对坐标发 MoveL"
)
return False, q, Tee
movel_xyz = once_ff_movel_from_tcp(tcp_now, dxy, descent)
if movel_xyz is None:
self.get_logger().error("厂家 TCP 加上相对位移算不出,停在悬停高")
return False, q, Tee
off_xy = (
(float(tcp_now[0]) - float(tcp[0]) + float(dxy[0])) * 1000.0,
(float(tcp_now[1]) - float(tcp[1]) + float(dxy[1])) * 1000.0,
)
scheme = "笛卡尔锁XY只降Z" if n_xy < 1e-6 else "视线前馈斜线"
self.get_logger().info(
f"{scheme}:look=({look_xy[0]:+.3f},{look_xy[1]:+.3f}) "
f"k={k:.3f} 降 {descent * 1000:.1f}mm → "
f"ΔXY=({dxy[0] * 1000:+.2f},{dxy[1] * 1000:+.2f})mm "
f"厂家TCP现=({tcp_now[0]:.4f},{tcp_now[1]:.4f},{tcp_now[2]:.4f}) "
f"MoveL=({movel_xyz[0]:.4f},{movel_xyz[1]:.4f},{movel_xyz[2]:.4f}) "
f"ROS尖目标=({tcp[0]:.4f},{tcp[1]:.4f},{tcp[2]:.4f}) "
f"WORLD相对ROS XY差≈({off_xy[0]:+.1f},{off_xy[1]:+.1f})mm "
f"vel={ONCE_FF_MOVEL_VEL_MPS * 1000:.0f}mm/s"
)
fl0 = (float(Tee[0, 3]), float(Tee[1, 3]), float(Tee[2, 3]))
ok = self._vendor_movel_tcp(movel_xyz, ONCE_FF_MOVEL_VEL_MPS)
q2, Tee2 = self._wait_state(2.0)
if q2 is not None and Tee2 is not None:
q, Tee = q2, Tee2
self._pin(q)
self.descent_final_gap_m = float(Tee[2, 3]) - tip_len - surf
dax = (float(Tee[0, 3]) - fl0[0]) * 1000.0
day = (float(Tee[1, 3]) - fl0[1]) * 1000.0
daz = (float(Tee[2, 3]) - fl0[2]) * 1000.0
self.get_logger().info(
f"贴面走后:法兰 ΔXYZ=({dax:+.2f},{day:+.2f},{daz:+.2f})mm "
f"指令 ΔXY=({dxy[0] * 1000:+.2f},{dxy[1] * 1000:+.2f})mm "
f"间隙 {self.descent_final_gap_m * 1000:.1f}mm "
f"{'到位' if ok else 'MoveL未完成'}"
)
return bool(ok), q, Tee
def _trim_xy_keep_hover_tip(self, q, Tee, final: Dict[str, Any], z_target_m=None): def _trim_xy_keep_hover_tip(self, q, Tee, final: Dict[str, Any], z_target_m=None):
"""观察高重交孔,规尖沿用悬停冻住的偏移。只许一跳、XY ≤3mm。 """观察高重交孔,规尖沿用悬停冻住的偏移。只许一跳、XY ≤3mm。
@@ -2470,6 +2735,9 @@ class Stage2Locate(TolerantNode):
if self._hover_refused: if self._hover_refused:
self.get_logger().error(self._hover_refused) self.get_logger().error(self._hover_refused)
return 2 return 2
if self._final_gap_refused:
self.get_logger().error(self._final_gap_refused)
return 2
if self.hover_z_user_set: if self.hover_z_user_set:
z_lo = float(self.ws_min[2]) z_lo = float(self.ws_min[2])
@@ -2641,6 +2909,37 @@ class Stage2Locate(TolerantNode):
if truth is not None and truth.get("part_center_base"): if truth is not None and truth.get("part_center_base"):
p_base = np.asarray(truth["part_center_base"][0], dtype=float) p_base = np.asarray(truth["part_center_base"][0], dtype=float)
src = "真值" src = "真值"
# 对孔走平面交点时,框心必须同一条射线∩面。深度框心沿 30° 视线
# 偏 1~2mm,符号随站位翻(D435 送到夹具位 vs 纯 D405 停在别处),
# 精拍再把这截当 K1.x−规尖.x 走掉,前馈又沿同轴加一次。
if p_base is None and self.hole_visual_only:
if float(self.surface_z_m) <= 0.0:
self.get_logger().error(
"粗居中要对孔同一平面,但 workpiece_surface_z_m 没有配,"
"拒绝用深度图凑框心"
)
break
T_cam = self._lookup(self.base_frame, self.cam_frame)
if T_cam is None:
self.get_logger().error("粗居中缺 cam TF,拒绝用深度图凑框心")
break
hit = pixel_on_base_plane(
float(bx),
float(by),
float(intr["fx"]),
float(intr["fy"]),
float(intr["cx"]),
float(intr["cy"]),
T_cam,
float(self.surface_z_m),
)
if hit is None:
self.get_logger().error(
"粗居中框心射线没交到工件面,拒绝用深度图凑"
)
break
p_base = hit
src = "像素交工件面"
if p_base is None: if p_base is None:
z_wp = self._surface_depth(depth_m, bbox) z_wp = self._surface_depth(depth_m, bbox)
p_base = self._cam_to_base(self._px_to_cam(bx, by, z_wp, intr)) p_base = self._cam_to_base(self._px_to_cam(bx, by, z_wp, intr))
@@ -2931,21 +3230,32 @@ class Stage2Locate(TolerantNode):
final["align_trusted"] = False final["align_trusted"] = False
final["fail_reason"] = "未量到规尖偏移,工具位置退化为法兰轴线" final["fail_reason"] = "未量到规尖偏移,工具位置退化为法兰轴线"
q, Tee, _moved = self._oneshot_align_move(q, Tee, tgt_b, gxy, err) q, Tee, _moved = self._oneshot_align_move(q, Tee, tgt_b, gxy, err)
q, Tee, final = self._observe_holes(
q, Tee, final, tag="走后核验"
)
final["commanded_residual_mm"] = commanded_mm final["commanded_residual_mm"] = commanded_mm
if not final.get("residual_remeasured"): if self.hole_once_lock_xy_descend:
self.get_logger().error( final["residual_remeasured"] = False
f"走后核验没复测到。开环指令残差 {commanded_mm:.2f}mm," final["lock_xy_descend"] = True
"不是 hop 到位,不把这次当对准成功" if self._tip_off is not None:
final["ok"] = True
self.get_logger().warn(
f"视线前馈斜线:悬停指令 {commanded_mm:.2f}mm 已走,"
"不补拍。下一步按 k×下降收 XY,一条 MoveL 到贴面。"
"residual_mm 仍是走之前那一拍,不是贴面复测"
)
else:
q, Tee, final = self._observe_holes(
q, Tee, final, tag="走后核验"
)
if not final.get("residual_remeasured"):
self.get_logger().error(
f"走后核验没复测到。开环指令残差 {commanded_mm:.2f}mm,"
"不是 hop 到位,不把这次当对准成功"
)
final["ok"] = False
self.get_logger().info(
f"单次精拍悬停走完:指令 {commanded_mm:.2f}mm → "
f"复测 {final.get('residual_mm')}mm;下降后还要补交平面漂的那几毫米"
) )
final["ok"] = False
self._state("aligned") self._state("aligned")
self.get_logger().info(
f"单次精拍悬停走完:指令 {commanded_mm:.2f}mm → "
f"复测 {final.get('residual_mm')}mm;下降后还要补交平面漂的那几毫米"
)
break break
if err <= self.align_tol: if err <= self.align_tol:
@@ -3036,7 +3346,15 @@ class Stage2Locate(TolerantNode):
# ---- 4.5) 柔顺轻降到孔面上方 1-2mm(不接触)---- # ---- 4.5) 柔顺轻降到孔面上方 1-2mm(不接触)----
# 下降量由**几何**决定(法兰 FK − 工具长 − 工件面 − final_gap),深度读数只 # 下降量由**几何**决定(法兰 FK − 工具长 − 工件面 − final_gap),深度读数只
# 做交叉核对,见 _descend_above_hole 的说明。 # 做交叉核对,见 _descend_above_hole 的说明。
if self.hole_keypoint_once: if self.hole_keypoint_once and self.hole_once_lock_xy_descend:
# 视线前馈斜线:不经过观察高、不补交。一条 MoveL 收到贴面。
lock_gap = (
self.final_gap_m if self.final_descent_enable else self.observe_gap_m
)
okd, q, Tee = self._once_ff_diagonal_to_gap(
q, Tee, target_gap_m=lock_gap
)
elif self.hole_keypoint_once:
# 先降到观察高(约 15mm)再交孔;贴面由补孔斜线一步完成,不再 4mm 切到 2mm。 # 先降到观察高(约 15mm)再交孔;贴面由补孔斜线一步完成,不再 4mm 切到 2mm。
okd, q, Tee = self._descend_above_hole( okd, q, Tee = self._descend_above_hole(
q, Tee, q, Tee,
@@ -3047,7 +3365,12 @@ class Stage2Locate(TolerantNode):
okd, q, Tee = self._descend_above_hole(q, Tee) okd, q, Tee = self._descend_above_hole(q, Tee)
if not okd: if not okd:
self.get_logger().warn("轻降未完全到位,保持当前高度(安全优先)") self.get_logger().warn("轻降未完全到位,保持当前高度(安全优先)")
if self.hole_keypoint_once: if self.hole_keypoint_once and self.hole_once_lock_xy_descend:
tip_len = float(self.tool_tip_below_flange_m)
surf = float(self.surface_z_m)
if tip_len > 0.0 and surf > 0.0:
self.descent_final_gap_m = float(Tee[2, 3]) - tip_len - surf
elif self.hole_keypoint_once:
# 悬停那一拍的基座 XY 不能带到观测高:前倾 30°,降 50mm 交点会漂约 4mm。 # 悬停那一拍的基座 XY 不能带到观测高:前倾 30°,降 50mm 交点会漂约 4mm。
# 核验记账后补 XY;规尖沿用悬停冻结值,不在观测高度重测。 # 核验记账后补 XY;规尖沿用悬停冻结值,不在观测高度重测。
q, Tee, final = self._observe_holes(q, Tee, final, tag="降后核验") q, Tee, final = self._observe_holes(q, Tee, final, tag="降后核验")
@@ -84,6 +84,7 @@ def _launch_setup(context, *args, **kwargs):
d435_only = _as_bool(LaunchConfiguration("d435_move_only").perform(context)) d435_only = _as_bool(LaunchConfiguration("d435_move_only").perform(context))
init_z = LaunchConfiguration("init_z_m").perform(context).strip() init_z = LaunchConfiguration("init_z_m").perform(context).strip()
hover_z = LaunchConfiguration("hover_z_m").perform(context).strip() hover_z = LaunchConfiguration("hover_z_m").perform(context).strip()
final_gap = LaunchConfiguration("final_gap_mm").perform(context).strip()
target_hole = LaunchConfiguration("target_hole").perform(context).strip() target_hole = LaunchConfiguration("target_hole").perform(context).strip()
depth = LaunchConfiguration("insert_depth_mm").perform(context).strip() depth = LaunchConfiguration("insert_depth_mm").perform(context).strip()
pitch = LaunchConfiguration("screw_pitch_mm").perform(context).strip() pitch = LaunchConfiguration("screw_pitch_mm").perform(context).strip()
@@ -125,6 +126,7 @@ def _launch_setup(context, *args, **kwargs):
"d435_move_only": d435_only, "d435_move_only": d435_only,
"init_z_m": float(init_z) if init_z else -1.0, "init_z_m": float(init_z) if init_z else -1.0,
"hover_z_m": float(hover_z) if hover_z else -1.0, "hover_z_m": float(hover_z) if hover_z else -1.0,
"final_gap_mm": float(final_gap) if final_gap else -1.0,
# -1=按 site 的 stage2.final_descent_enable;0=强制不降(调试) # -1=按 site 的 stage2.final_descent_enable;0=强制不降(调试)
"final_descent_enable": 0 if no_descent else -1, "final_descent_enable": 0 if no_descent else -1,
} }
@@ -136,7 +138,8 @@ def _launch_setup(context, *args, **kwargs):
LogInfo(msg=f"[pipeline] arm={robot.get('label') or robot.get('sn')} cam={cam}"), LogInfo(msg=f"[pipeline] arm={robot.get('label') or robot.get('sn')} cam={cam}"),
LogInfo( LogInfo(
msg=f"[pipeline] hole={target_hole or 'site'} depth={depth or 'site'} " msg=f"[pipeline] hole={target_hole or 'site'} depth={depth or 'site'} "
f"pitch={pitch or 'site'} skip1={skip1} skip2={skip2} skip3={skip3}" f"pitch={pitch or 'site'} final_gap={final_gap or 'site'} "
f"skip1={skip1} skip2={skip2} skip3={skip3}"
+ (" [d435_only:只测 D435 位移]" if d435_only else "") + (" [d435_only:只测 D435 位移]" if d435_only else "")
+ (" [no_descent:只对准,不轻降]" if no_descent else "") + (" [no_descent:只对准,不轻降]" if no_descent else "")
), ),
@@ -166,6 +169,7 @@ def generate_launch_description() -> LaunchDescription:
DeclareLaunchArgument("d435_move_only", default_value="false"), DeclareLaunchArgument("d435_move_only", default_value="false"),
DeclareLaunchArgument("init_z_m", default_value=""), DeclareLaunchArgument("init_z_m", default_value=""),
DeclareLaunchArgument("hover_z_m", default_value=""), DeclareLaunchArgument("hover_z_m", default_value=""),
DeclareLaunchArgument("final_gap_mm", default_value=""),
DeclareLaunchArgument("preview", default_value="true"), DeclareLaunchArgument("preview", default_value="true"),
OpaqueFunction(function=_launch_setup), OpaqueFunction(function=_launch_setup),
] ]
@@ -75,6 +75,7 @@ def _launch_setup(context, *args, **kwargs):
preview = LaunchConfiguration("preview").perform(context).lower() in ("1", "true", "yes") preview = LaunchConfiguration("preview").perform(context).lower() in ("1", "true", "yes")
target_hole = LaunchConfiguration("target_hole").perform(context).strip() target_hole = LaunchConfiguration("target_hole").perform(context).strip()
hover_z = LaunchConfiguration("hover_z_m").perform(context).strip() hover_z = LaunchConfiguration("hover_z_m").perform(context).strip()
final_gap = LaunchConfiguration("final_gap_mm").perform(context).strip()
tx, ty, tz, qx, qy, qz, qw = _handeye_xyz_xyzw(handeye) tx, ty, tz, qx, qy, qz, qw = _handeye_xyz_xyzw(handeye)
static_tf = Node( static_tf = Node(
@@ -101,6 +102,7 @@ def _launch_setup(context, *args, **kwargs):
"hover_only": hover_only, "hover_only": hover_only,
"target_hole": target_hole, "target_hole": target_hole,
"hover_z_m": float(hover_z) if hover_z else -1.0, "hover_z_m": float(hover_z) if hover_z else -1.0,
"final_gap_mm": float(final_gap) if final_gap else -1.0,
"preview": preview, "preview": preview,
} }
], ],
@@ -123,6 +125,7 @@ def generate_launch_description() -> LaunchDescription:
DeclareLaunchArgument("hover_only", default_value="false"), DeclareLaunchArgument("hover_only", default_value="false"),
DeclareLaunchArgument("target_hole", default_value=""), DeclareLaunchArgument("target_hole", default_value=""),
DeclareLaunchArgument("hover_z_m", default_value=""), DeclareLaunchArgument("hover_z_m", default_value=""),
DeclareLaunchArgument("final_gap_mm", default_value=""),
DeclareLaunchArgument("preview", default_value="true"), DeclareLaunchArgument("preview", default_value="true"),
OpaqueFunction(function=_launch_setup), OpaqueFunction(function=_launch_setup),
] ]
@@ -60,7 +60,14 @@ from arm_application.overhead_oneshot import (
hole_pair_span_ok, hole_pair_span_ok,
oneshot_align_step_m, oneshot_align_step_m,
flange_z_for_gap_m, flange_z_for_gap_m,
final_gap_refusal,
d435_movel_from_tcp,
once_ff_movel_from_tcp,
once_ff_tcp_xyz,
once_look_ff_xy,
ONCE_APPROACH_STEP_M, ONCE_APPROACH_STEP_M,
ONCE_FF_LOOK_PER_M,
ONCE_FF_MOVEL_VEL_MPS,
ONCE_TRIM_MAX_STEP_M, ONCE_TRIM_MAX_STEP_M,
ONCE_TRIM_REFUSE_M, ONCE_TRIM_REFUSE_M,
pixel_on_base_plane, pixel_on_base_plane,
@@ -175,21 +182,44 @@ class TestHandoff:
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
assert "pixel_on_base_plane(" in stage2 assert "pixel_on_base_plane(" in stage2
assert "hole_visual_only" in stage2 assert "hole_visual_only" in stage2
coarse = stage2[stage2.find("# ---- 3) 粗居中") : stage2.find("# ---- 4)")]
assert "pixel_on_base_plane" in coarse
assert "拒绝用深度图凑框心" in coarse
assert "_trim_xy_after_descent" in stage2 assert "_trim_xy_after_descent" in stage2
assert "align_max_iters" in stage2 assert "align_max_iters" in stage2
assert "像素交工件面" in stage2 assert "像素交工件面" in stage2
assert "det_depth = None if self.hole_visual_only else med_depth" in stage2 assert "det_depth = None if self.hole_visual_only else med_depth" in stage2
assert 'self.hole_keypoint_once = _as_bool(stage2.get("hole_keypoint_once")' in stage2 assert 'self.hole_keypoint_once = _as_bool(stage2.get("hole_keypoint_once")' in stage2
assert 'self.hole_once_lock_xy_descend = _as_bool(' in stage2
obs = stage2[stage2.find("def _observe_holes"): stage2.find("def _oneshot_align_move")] obs = stage2[stage2.find("def _observe_holes"): stage2.find("def _oneshot_align_move")]
assert "_goto(" not in obs, "核验拍不许改 XY" assert "_goto(" not in obs, "核验拍不许改 XY"
assert "residual_remeasured" in obs assert "residual_remeasured" in obs
desc = stage2[stage2.find("okd, q, Tee = self._descend_above_hole"):] desc = stage2[stage2.find("# ---- 4.5)"):]
desc = desc[:1800] desc = desc[:2800]
assert "hole_once_lock_xy_descend" in desc
assert "if self.hole_keypoint_once:" in desc assert "if self.hole_keypoint_once:" in desc
assert 'tag="降后核验"' in desc assert 'tag="降后核验"' in desc
assert "_trim_xy_after_descent" in desc assert "_trim_xy_after_descent" in desc
assert "target_gap_m=self.observe_gap_m" in desc assert "target_gap_m=self.observe_gap_m" in desc
assert "ONCE_APPROACH_STEP_M" in desc assert "ONCE_APPROACH_STEP_M" in desc
lock_at = stage2.find("# ---- 4.5)")
assert lock_at > 0
lock_block = stage2[lock_at: stage2.find("elif self.hole_keypoint_once:", lock_at)]
assert "hole_once_lock_xy_descend" in lock_block
assert "lock_gap" in lock_block
assert "_once_ff_diagonal_to_gap" in lock_block
assert "self._descend_above_hole" not in lock_block
assert "_observe_holes" not in lock_block
assert "_trim_xy_after_descent" not in lock_block
assert "once_ff_tcp_xyz" in stage2
assert "once_ff_movel_from_tcp" in stage2
assert "_fresh_tcp_world" in stage2
assert "没有厂家 tcp_pose" in stage2
assert "write_request" in stage2
moved_at = stage2.find("q, Tee, _moved = self._oneshot_align_move")
moved_blk = stage2[moved_at: stage2.find("self._state(\"aligned\")", moved_at) + 80]
assert "hole_once_lock_xy_descend" in moved_blk
assert 'tag="走后核验"' in moved_blk
keep = stage2[ keep = stage2[
stage2.find("def _trim_xy_keep_hover_tip"): stage2.find("def _trim_xy_after_descent") stage2.find("def _trim_xy_keep_hover_tip"): stage2.find("def _trim_xy_after_descent")
] ]
@@ -235,12 +265,15 @@ class TestHandoff:
assert abs(euler[0]) == pytest.approx(180.0, abs=1e-6) assert abs(euler[0]) == pytest.approx(180.0, abs=1e-6)
assert abs(euler[1]) == pytest.approx(0.0, abs=1e-6) assert abs(euler[1]) == pytest.approx(0.0, abs=1e-6)
assert abs(euler[2]) == pytest.approx(0.0, abs=1e-6) assert abs(euler[2]) == pytest.approx(0.0, abs=1e-6)
assert "rdk_release_movel.py" in servo
movel = servo[servo.find("def _d435_vendor_movel"): servo.find("def _d435_oneshot_holds_locked")] movel = servo[servo.find("def _d435_vendor_movel"): servo.find("def _d435_oneshot_holds_locked")]
assert "rdk_release_movel.py" not in movel
assert "write_request" in movel
assert "d435_movel_from_tcp" in movel
assert "keep_z" in movel or "True" in movel
assert "拒绝杀栈凑 MoveL" in movel
assert "D435_VENDOR_MOVEL_VEL_MPS" in movel assert "D435_VENDOR_MOVEL_VEL_MPS" in movel
assert "D435_VENDOR_MOVEL_ACC_MPS2" in movel assert "D435_VENDOR_MOVEL_ACC_MPS2" not in movel
assert '"--acc"' in movel assert '"--acc"' not in movel
assert '"0.05"' not in movel
assert D435_VENDOR_MOVEL_VEL_MPS == pytest.approx(0.3) assert D435_VENDOR_MOVEL_VEL_MPS == pytest.approx(0.3)
assert D435_VENDOR_MOVEL_ACC_MPS2 == pytest.approx(1.5) assert D435_VENDOR_MOVEL_ACC_MPS2 == pytest.approx(1.5)
rel = ( rel = (
@@ -251,10 +284,14 @@ class TestHandoff:
assert f"default={D435_VENDOR_MOVEL_ACC_MPS2}" in rel assert f"default={D435_VENDOR_MOVEL_ACC_MPS2}" in rel
assert '"--acc"' in rel assert '"--acc"' in rel
assert 'prim["acc"]' in goto assert 'prim["acc"]' in goto
tgt = d435_movel_from_tcp([0.580, -0.460, 0.096], [0.687, -0.113], [0.708, -0.168])
assert tgt is not None
np.testing.assert_allclose(tgt, [0.580 + 0.021, -0.460 - 0.055, 0.096], atol=1e-9)
assert d435_movel_from_tcp(None, [0.0, 0.0], [0.1, 0.1]) is None
assert "d435_handback_sec" in servo assert "d435_handback_sec" in servo
assert "still_sec=max(0.0, self.d435_handback_sec)" in servo assert "still_sec=max(0.0, self.d435_handback_sec)" in servo
assert "wrist_align_sec" in servo assert "wrist_align_sec" in servo
# 杀栈 MoveL 还在走时不许把 vendor_done 提前置上,停稳计时也不许在这段路上累计。 # 进程内 MoveL 还在走时不许把 vendor_done 提前置上,停稳计时也不许在这段路上累计。
call_at = servo.find("ok = self._d435_vendor_movel()") call_at = servo.find("ok = self._d435_vendor_movel()")
done_at = servo.find("self._d435_vendor_done = True", call_at) done_at = servo.find("self._d435_vendor_done = True", call_at)
assert call_at > 0 and done_at > call_at assert call_at > 0 and done_at > call_at
@@ -274,6 +311,23 @@ class TestHandoff:
flat = np.eye(4) flat = np.eye(4)
flat[:3, 3] = [0.0, 0.0, 0.50] flat[:3, 3] = [0.0, 0.0, 0.50]
assert pixel_on_base_plane(320, 240, 400, 400, 320, 240, flat, 0.022) is None assert pixel_on_base_plane(320, 240, 400, 400, 320, 240, flat, 0.022) is None
# 前倾 30°:同一主点,深度偏远的点比平面交点更 −X。爪站在深度点上时
# K1.x−规尖.x > 0;深度偏近则反号。这就是两套一阶段站位符号相反的几何。
ca, sa = float(np.cos(np.deg2rad(30))), float(np.sin(np.deg2rad(30)))
tilt = np.eye(4)
tilt[:3, :3] = np.array(
[[0.0, sa, -ca], [1.0, 0.0, 0.0], [0.0, -ca, -sa]], dtype=float
)
tilt[:3, 3] = [0.65, -0.50, 0.19]
plane_pt = pixel_on_base_plane(320, 240, 400, 400, 320, 240, tilt, 0.022)
assert plane_pt is not None
ray = tilt[:3, :3] @ np.array([0.0, 0.0, 1.0])
ray = ray / float(np.linalg.norm(ray))
far = plane_pt[:3] + 0.020 * ray
near = plane_pt[:3] - 0.020 * ray
assert far[0] < plane_pt[0] < near[0]
assert (plane_pt[0] - far[0]) > 0.0
assert (plane_pt[0] - near[0]) < 0.0
lib = ( lib = (
__import__("pathlib").Path(__file__).resolve().parents[3] / "scripts" / "lib" __import__("pathlib").Path(__file__).resolve().parents[3] / "scripts" / "lib"
) )
@@ -815,6 +869,10 @@ class TestHoleBiasAxes:
st2 = (site.get("hole_detect") or {}).get("stage2") or {} st2 = (site.get("hole_detect") or {}).get("stage2") or {}
assert st2.get("hole_keypoint_once") is True assert st2.get("hole_keypoint_once") is True
assert st2.get("hole_visual_only") is True assert st2.get("hole_visual_only") is True
assert st2.get("hole_once_lock_xy_descend") is True
assert st2.get("once_ff_look_per_m") == pytest.approx(ONCE_FF_LOOK_PER_M)
servo = (site.get("servo") or {})
assert servo.get("d435_handback_sec") == pytest.approx(0.5)
def test_oneshot_step_covers_half_hole_pair(self): def test_oneshot_step_covers_half_hole_pair(self):
"""框心到 k1 约 29mm:必须整步走完,不许再截成 12mm。""" """框心到 k1 约 29mm:必须整步走完,不许再截成 12mm。"""
@@ -836,3 +894,85 @@ class TestHoleBiasAxes:
assert ONCE_APPROACH_STEP_M == pytest.approx(0.020) assert ONCE_APPROACH_STEP_M == pytest.approx(0.020)
assert flange_z_for_gap_m(0.223, 0.022, 0.002) == pytest.approx(0.247) assert flange_z_for_gap_m(0.223, 0.022, 0.002) == pytest.approx(0.247)
assert flange_z_for_gap_m(0.223, 0.022, 0.015) == pytest.approx(0.260) assert flange_z_for_gap_m(0.223, 0.022, 0.015) == pytest.approx(0.260)
def test_final_gap_refusal_uses_tip_clearance_not_flange_z(self):
"""贴面参数是规尖离面。0 / 比悬停还高 / 法兰出盒必须红。"""
tool, surf, hover = 0.2245, 0.022, 0.320
z_lo, z_hi = 0.18, 0.65
assert final_gap_refusal(0.002, tool, surf, hover, z_lo, z_hi) == ""
assert final_gap_refusal(0.008, tool, surf, hover, z_lo, z_hi) == ""
assert "≤ 0" in final_gap_refusal(0.0, tool, surf, hover, z_lo, z_hi)
assert "未知" in final_gap_refusal(0.008, 0.0, surf, hover, z_lo, z_hi)
hover_clear = hover - tool - surf
why_up = final_gap_refusal(hover_clear + 0.005, tool, surf, hover, z_lo, z_hi)
assert "抬臂" in why_up
why_box = final_gap_refusal(0.008, tool, surf, hover, 0.40, z_hi)
assert "工作盒" in why_box
flange_8 = flange_z_for_gap_m(tool, surf, 0.008)
assert flange_8 == pytest.approx(0.2545)
assert flange_8 < hover
def test_final_gap_cli_overrides_site_and_conflicts_with_no_descent(self):
"""--final-gap 必须接到 launch/节点;和 --no-descent 一起写必须在脚本里红。"""
from pathlib import Path
root = Path(__file__).resolve().parents[1]
ws = root
while ws != ws.parent and not (ws / "scripts" / "run_all.sh").is_file():
ws = ws.parent
run_all = (ws / "scripts" / "run_all.sh").read_text(encoding="utf-8")
stage2 = (root / "arm_application" / "stage2_locate_node.py").read_text(
encoding="utf-8"
)
launch = (root / "launch" / "pipeline.launch.py").read_text(encoding="utf-8")
assert "--final-gap" in run_all
assert "final_gap_mm:=${FINAL_GAP}" in run_all
assert "同时写了 --final-gap 和 --no-descent" in run_all
assert 'self.declare_parameter("final_gap_mm"' in stage2
assert "final_gap_user_set" in stage2
assert "target_gap_m=lock_gap" in stage2
assert '"final_gap_mm": float(final_gap) if final_gap else -1.0' in launch
assert 'DeclareLaunchArgument("final_gap_mm"' in launch
def test_once_look_ff_is_minus_look_times_k_dz(self):
"""现行 k=0 → ΔXY=(0,0)。k=0.018、look=-X、降 71.5mm 才是 +1.287mm X。"""
k0 = float(ONCE_FF_LOOK_PER_M)
assert k0 == pytest.approx(0.0)
dxy0 = once_look_ff_xy([-1.0, 0.0], 0.0715, k0)
assert dxy0 is not None
np.testing.assert_allclose(dxy0, [0.0, 0.0], atol=1e-12)
np.testing.assert_allclose(
once_look_ff_xy([0.0, 0.0], 0.0715, 0.0), [0.0, 0.0], atol=1e-12
)
assert once_look_ff_xy([-1.0, 0.0], 0.0, 0.0) is None
k = 0.018
expect_x = k * 0.0715
dxy = once_look_ff_xy([-1.0, 0.0], 0.0715, k)
assert dxy is not None
np.testing.assert_allclose(dxy, [expect_x, 0.0], atol=1e-9)
assert expect_x == pytest.approx(0.001287, abs=1e-6)
assert once_look_ff_xy([0.0, 0.0], 0.0715, k) is None
assert once_look_ff_xy([-1.0, 0.0], 0.0, k) is None
packed = once_ff_tcp_xyz(
[0.50, -0.40],
[-0.001, -0.001],
[-1.0, 0.0],
0.0715,
k0,
0.022,
0.002,
)
assert packed is not None
tcp, dxy2 = packed
assert tcp[2] == pytest.approx(0.024)
assert tcp[2] < 0.18
np.testing.assert_allclose(tcp[:2], [0.50 - 0.001, -0.40 - 0.001])
np.testing.assert_allclose(dxy2, [0.0, 0.0], atol=1e-12)
assert ONCE_FF_MOVEL_VEL_MPS == pytest.approx(0.05)
dz_refuse = ONCE_TRIM_REFUSE_M / k + 0.01
assert once_look_ff_xy([-1.0, 0.0], dz_refuse, k) is not None
assert float(np.hypot(*once_look_ff_xy([-1.0, 0.0], dz_refuse, k))) > ONCE_TRIM_REFUSE_M
rel = once_ff_movel_from_tcp([0.580, -0.460, 0.096], [0.0, 0.0], 0.0715)
assert rel is not None
np.testing.assert_allclose(rel, [0.580, -0.460, 0.0245], atol=1e-9)
assert once_ff_movel_from_tcp([0.580, -0.460, 0.096], [0.00129, 0.0], 0.0) is None
@@ -238,7 +238,7 @@ class TestRealSiteBranchDefault:
assert s["handeye_mode"] == "eye_in_hand" assert s["handeye_mode"] == "eye_in_hand"
assert s["handeye_file"].endswith("89_eye_in_hand.yaml") assert s["handeye_file"].endswith("89_eye_in_hand.yaml")
assert s["lock_still_sec"] == pytest.approx(3.0) assert s["lock_still_sec"] == pytest.approx(3.0)
assert s["d435_handback_sec"] == pytest.approx(3.0) assert s["d435_handback_sec"] == pytest.approx(0.5)
assert s["wrist_align_sec"] == pytest.approx(0.5) assert s["wrist_align_sec"] == pytest.approx(0.5)
assert s["lock_still_move_m"] == pytest.approx(0.015) assert s["lock_still_move_m"] == pytest.approx(0.015)
# 负向:跟踪外参不能被换成顶视文件 # 负向:跟踪外参不能被换成顶视文件
@@ -254,6 +254,11 @@ def test_coarse_loop_rejects_before_moving():
"又在咬孔检测器的 bbox 当粗居中目标了(真机那帧是整幅画面)" "又在咬孔检测器的 bbox 当粗居中目标了(真机那帧是整幅画面)"
) )
assert "skip_coarse = True" in src, "'跳过粗居中'必须真的不移动" assert "skip_coarse = True" in src, "'跳过粗居中'必须真的不移动"
coarse = src[src.find("# ---- 3) 粗居中") : src.find("# ---- 4)")]
assert "hole_visual_only" in coarse
assert "pixel_on_base_plane" in coarse
assert "拒绝用深度图凑框心" in coarse
assert 'src = "像素交工件面"' in coarse
def test_guard_reason_is_logged_with_both_numbers(): def test_guard_reason_is_logged_with_both_numbers():
@@ -36,6 +36,7 @@ from __future__ import annotations
from typing import Any, List, Optional from typing import Any, List, Optional
import numpy as np import numpy as np
import pytest
from arm_application.overhead_oneshot import ONCE_APPROACH_STEP_M from arm_application.overhead_oneshot import ONCE_APPROACH_STEP_M
from arm_application.stage2_locate_node import ( from arm_application.stage2_locate_node import (
@@ -112,6 +113,7 @@ class _StubStage2:
self._contact_after = contact_after_steps self._contact_after = contact_after_steps
# 记录每一次实际下发的下降步长,用来核对累计行程 # 记录每一次实际下发的下降步长,用来核对累计行程
self.steps_mm: List[float] = [] self.steps_mm: List[float] = []
self.goto_hold_xy: List[bool] = []
self.pins = 0 self.pins = 0
# --- 被测函数会调到的钩子 --- # --- 被测函数会调到的钩子 ---
@@ -134,10 +136,14 @@ class _StubStage2:
def _goto(self, _q: Any, Tee: Any, target: Any, **_kw: Any) -> Any: def _goto(self, _q: Any, Tee: Any, target: Any, **_kw: Any) -> Any:
Tee = np.array(Tee, dtype=float) Tee = np.array(Tee, dtype=float)
self.goto_hold_xy.append(bool(_kw.get("hold_xy")))
step_mm = (Tee[2, 3] - float(target[2])) * 1000.0 step_mm = (Tee[2, 3] - float(target[2])) * 1000.0
# 记录"下降步"(正数);回退步(负数)不计入下降行程 # 记录"下降步"(正数);回退步(负数)不计入下降行程
if step_mm > 0.0: if step_mm > 0.0:
self.steps_mm.append(step_mm) self.steps_mm.append(step_mm)
if not _kw.get("hold_xy"):
Tee[0, 3] = float(target[0])
Tee[1, 3] = float(target[1])
Tee[2, 3] = float(target[2]) Tee[2, 3] = float(target[2])
self._cur_z = float(target[2]) self._cur_z = float(target[2])
return True, np.zeros(7), Tee return True, np.zeros(7), Tee
@@ -290,6 +296,194 @@ def test_contact_not_armed_while_still_high_above_the_part():
assert contact_armed_by_gap(0.005, 0.015) is True assert contact_armed_by_gap(0.005, 0.015) is True
def test_lock_xy_descend_holds_xy_and_stops_at_final_gap():
"""锁 XY 实验:从悬停纯 Z 收到 2mm,XY 不许动,步长仍 20mm。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _StubStage2()
Tee0 = np.eye(4)
Tee0[0, 3] = 0.512
Tee0[1, 3] = -0.401
Tee0[2, 3] = _HOVER_Z
ok, _q, Tee = Stage2Locate._descend_above_hole( # type: ignore[arg-type]
st, np.zeros(7), Tee0, target_gap_m=_FINAL_GAP, step_m=ONCE_APPROACH_STEP_M
)
assert ok is True
gap = float(Tee[2, 3]) - _TOOL_LEN - _SURFACE_Z
assert abs(gap - _FINAL_GAP) < 0.001, f"应停在贴面 2mm,实际 {gap * 1000:.1f}mm"
assert Tee[0, 3] == 0.512
assert Tee[1, 3] == -0.401
assert max(st.steps_mm) <= ONCE_APPROACH_STEP_M * 1000 + 1e-6
assert all(st.goto_hold_xy), "下臂每一步都必须 hold_xy"
def _look_neg_x_T() -> np.ndarray:
T = np.eye(4)
T[:3, :3] = np.array(
[[0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]],
dtype=float,
)
return T
def test_once_ff_zero_k_is_cartesian_lock_xy_movel():
"""k=0 必须下发相对 MoveL,且 ΔXY=0、只降 Z。不许停在悬停高装成功。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st.once_ff_look_per_m = 0.0
st._lookup = lambda *_a, **_k: np.eye(4) # type: ignore[method-assign]
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - _FINAL_GAP
np.testing.assert_allclose(got[0], [0.580, -0.460, 0.096 - descent], atol=1e-9)
assert any("笛卡尔锁XY只降Z" in m for m in st.logs)
def _ff_stub_ready() -> _StubStage2:
st = _StubStage2()
st.once_ff_look_per_m = 0.0
st._tip_off = np.array([-0.001, -0.001], dtype=float)
st.cam_frame = "cam"
st.base_frame = "base"
st._ws_clipped = False
st._lookup = lambda *_a, **_k: _look_neg_x_T() # type: ignore[method-assign]
st._clamp_ws = lambda *_a, **_k: None # type: ignore[method-assign]
st._spin = lambda *_a, **_k: None # type: ignore[method-assign]
st._wait_state = lambda *_a, **_k: (None, None) # type: ignore[method-assign]
st.robot_states_topic = "/Rizon4_063989/flexiv_robot_states"
return st
def test_once_ff_refuses_without_tcp_pose():
"""没有厂家 tcp_pose 不许退回 ROS 绝对坐标。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st._fresh_tcp_world = lambda: None # type: ignore[method-assign]
called: list = []
st._vendor_movel_tcp = lambda *_a, **_k: called.append(1) or True # type: ignore[method-assign]
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is False
assert called == []
assert Tee1[2, 3] == pytest.approx(_HOVER_Z)
assert any("没有厂家 tcp_pose" in m for m in st.logs)
def test_once_ff_movel_is_flexiv_tcp_plus_delta():
"""MoveL 必须是当前厂家 TCP + (ΔXY, −下降),不是 ROS 规尖绝对坐标。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - _FINAL_GAP
np.testing.assert_allclose(got[0], [0.580, -0.460, 0.096 - descent], atol=1e-9)
assert got[0][0] == pytest.approx(0.580)
assert not np.allclose(got[0][:2], [0.595 - 0.001, -0.467 - 0.001], atol=1e-4)
def test_once_ff_custom_final_gap_only_changes_z():
"""--final-gap 8mm:MoveL XY 仍为 0,下降量必须少 6mm,不许再走贴面 2mm。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
gap_custom = 0.008
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=gap_custom
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - gap_custom
descent_2mm = gap0 - _FINAL_GAP
np.testing.assert_allclose(got[0], [0.580, -0.460, 0.096 - descent], atol=1e-9)
assert got[0][0] == pytest.approx(0.580)
assert got[0][1] == pytest.approx(-0.460)
assert descent == pytest.approx(descent_2mm - 0.006)
assert any("笛卡尔锁XY只降Z" in m for m in st.logs)
def test_once_ff_k_018_still_adds_look_xy():
"""有人把 k 改回 0.018 时,MoveL 必须真的加 +k×下降,不许再吞成绝对坐标。"""
import rclpy
if not rclpy.ok():
rclpy.init()
st = _ff_stub_ready()
st.once_ff_look_per_m = 0.018
st._fresh_tcp_world = lambda: (0.580, -0.460, 0.096) # type: ignore[method-assign]
got: list = []
st._vendor_movel_tcp = ( # type: ignore[method-assign]
lambda xyz, _vel: got.append([float(xyz[0]), float(xyz[1]), float(xyz[2])]) or True
)
Tee = np.eye(4)
Tee[0, 3] = 0.595
Tee[1, 3] = -0.467
Tee[2, 3] = _HOVER_Z
ok, _q, _Tee1 = Stage2Locate._once_ff_diagonal_to_gap( # type: ignore[arg-type]
st, np.zeros(7), Tee, target_gap_m=_FINAL_GAP
)
assert ok is True
assert len(got) == 1
gap0 = _HOVER_Z - _TOOL_LEN - _SURFACE_Z
descent = gap0 - _FINAL_GAP
dxy = 0.018 * descent
np.testing.assert_allclose(got[0], [0.580 + dxy, -0.460, 0.096 - descent], atol=1e-9)
assert any("视线前馈斜线" in m for m in st.logs)
def test_oneshot_approach_stops_at_observe_gap_with_larger_steps(): def test_oneshot_approach_stops_at_observe_gap_with_larger_steps():
"""单次精拍第一段只降到 15mm,步长 20mm(约 3 步),不许 4mm 切到 2mm。""" """单次精拍第一段只降到 15mm,步长 20mm(约 3 步),不许 4mm 切到 2mm。"""
import rclpy import rclpy