P1:设备契约层收尾 + Flexiv 驱动适配器 + 触觉四路数据打通
新增包
- arm_kinematics:从 arm_application 提取几何雅可比 + DLS 逆解(纯 numpy)。
它被应用层和驱动层共用,留在应用层会让驱动反向依赖应用层。
arm_application/geom_ik.py 改为 re-export shim,现有 import 一行未改。
- arm_driver_flexiv:非夕驱动适配器。只补 manifest 声明而现有节点缺失的接口
(/home /enable /clear_fault /switch_tool /soft_drag /compliant_descent),
已有能力不重复实现。RDK 访问经 rdk_py310 下的长驻桥,本包不 import flexivrdk。
新接口
- arm_msgs/SwitchTool.srv、arm_msgs/CompliantDescent.action
- 合并 capabilities 里语义重复的 set_tool / switch_tool
修复(都是只在真机上暴露、且难以定位的问题)
- rdk_client 会永久挂死:select 的"可读"不等于"有完整一行",readline 遇到
半行 JSON 会一直阻塞等换行符,超时机制完全失效。改用 os.read + 自己做行缓冲。
- CompliantDescent 行程少一步:descended + step > max 在浮点累加下会提前跳出,
永远到不了标称行程。改用整数步数计数。
- CompliantDescentRunner 的 TF frame 退化成 _base_link:没继承节点已解析的 SN。
- resolve_orisys_config 仍指向旧路径;load_orisys_from_site 读的是旧键 config。
触觉四路数据打通
- 深度图 / 力矢量场原先被 tactile_worker 有意丢弃(注释写"图像不过队列"),
从未发布到 ROS。新增 image_utils(数组→Image,编码自动判定)、独立的图像
队列(maxsize=1 满则丢旧)与 /tactile/<side>/{image,vector}。
默认关闭(tactile.image_hz=0),开启也不影响三力的 30fps 时序。
- mock 后端也会发图像,无硬件即可验证整条发布链路。
配置
- site.yaml 新增 tactile.image_hz;移除与 model 重复的旧键 tactile.config
- 型号配置从 src/arm_application/config/orisys/ 迁到
devices/sensors/orisys/models/dd03/(随 SDK 一起放)
测试:198 个(原 139)
- arm_kinematics 24(雅可比/逆解的解析算例与不变量)
- arm_driver_flexiv 69(下压状态机 34 + RDK 协议层 35)
- arm_application 59(图像打包 36 + 矢量场探测 23)
This commit is contained in:
@@ -445,8 +445,8 @@ hole_detect:
|
|||||||
# bash ~/ros2/scripts/run_tactile.sh
|
# bash ~/ros2/scripts/run_tactile.sh
|
||||||
tactile:
|
tactile:
|
||||||
backend: "orisys" # orisys | mock
|
backend: "orisys" # orisys | mock
|
||||||
model: "dd03" # 传感器型号(设备契约层用的键;须与 devices/sensors/orisys/models/ 下的目录同名)
|
# 传感器型号。必须与 devices/sensors/orisys/models/ 下的子目录名一致。
|
||||||
config: "dd03" # 旧键,与 model 同义。仅为兼容现有节点保留,P2 会移除
|
model: "dd03"
|
||||||
# 型号配置随 SDK 一起放:devices/sensors/orisys/models/<型号>/config.json
|
# 型号配置随 SDK 一起放:devices/sensors/orisys/models/<型号>/config.json
|
||||||
config_file: "/home/USER/ros2/devices/sensors/orisys/models/dd03/config.json"
|
config_file: "/home/USER/ros2/devices/sensors/orisys/models/dd03/config.json"
|
||||||
# 用 by-path 稳定路径(插哪个 USB 口就认哪个口,不受 video 编号漂移影响)。
|
# 用 by-path 稳定路径(插哪个 USB 口就认哪个口,不受 video 编号漂移影响)。
|
||||||
@@ -461,6 +461,10 @@ tactile:
|
|||||||
motion_threshold: 0.0
|
motion_threshold: 0.0
|
||||||
target_fps: 30.0
|
target_fps: 30.0
|
||||||
publish_hz: 20.0
|
publish_hz: 20.0
|
||||||
|
# 视触觉图像与力矢量场的发布频率。0 = 关闭(默认)。
|
||||||
|
# 三力走 30fps 的轻量队列;图像每帧几百 KB,单独走一条 maxsize=1 的队列,
|
||||||
|
# 打开它不会影响三力的时序。需要看形变图/矢量场时设成 5~10 即可。
|
||||||
|
image_hz: 0.0
|
||||||
rotation_config: "" # 左右共用;有分指文件则用下面两项
|
rotation_config: "" # 左右共用;有分指文件则用下面两项
|
||||||
left_rotation: "" # 例:orisys SDK 的 rotation_config_0.json
|
left_rotation: "" # 例:orisys SDK 的 rotation_config_0.json
|
||||||
right_rotation: ""
|
right_rotation: ""
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ capabilities:
|
|||||||
- servo_joint
|
- servo_joint
|
||||||
- servo_cartesian
|
- servo_cartesian
|
||||||
- compliant_descent
|
- compliant_descent
|
||||||
- set_tool
|
|
||||||
- switch_tool
|
- switch_tool
|
||||||
- home
|
- home
|
||||||
- enable
|
- enable
|
||||||
|
|||||||
@@ -24,29 +24,55 @@ description: >
|
|||||||
# 重要:上层算法依赖的数据契约
|
# 重要:上层算法依赖的数据契约
|
||||||
outputs:
|
outputs:
|
||||||
- topic: tangential_1
|
- topic: tangential_1
|
||||||
|
ros_topic: "/tactile/{left|right}/status"
|
||||||
|
field: fshearx
|
||||||
|
msg: arm_msgs/TactileStatus
|
||||||
type: scalar
|
type: scalar
|
||||||
unit: 任意单位(非牛顿)
|
unit: 任意单位(非牛顿)
|
||||||
notes: 基线会漂移,所有阈值必须按「相对静止均值的增量」使用
|
notes: 基线会漂移,所有阈值必须按「相对静止均值的增量」使用
|
||||||
|
|
||||||
- topic: tangential_2
|
- topic: tangential_2
|
||||||
|
ros_topic: "/tactile/{left|right}/status"
|
||||||
|
field: fsheary
|
||||||
|
msg: arm_msgs/TactileStatus
|
||||||
type: scalar
|
type: scalar
|
||||||
unit: 任意单位(非牛顿)
|
unit: 任意单位(非牛顿)
|
||||||
notes: 与 tangential_1 正交。通止规装配需要确认哪一路对应哪个方向,见 README
|
notes: 与 tangential_1 正交。通止规装配需要确认哪一路对应哪个方向,见 README
|
||||||
|
|
||||||
- topic: normal
|
- topic: normal
|
||||||
|
ros_topic: "/tactile/{left|right}/status"
|
||||||
|
field: fnormal
|
||||||
|
msg: arm_msgs/TactileStatus
|
||||||
type: scalar
|
type: scalar
|
||||||
unit: 任意单位(非牛顿)
|
unit: 任意单位(非牛顿)
|
||||||
notes: 法向力。拧紧判定需要「法向力突增」这一条件
|
notes: 法向力。拧紧判定需要「法向力突增」这一条件
|
||||||
|
|
||||||
- topic: depth
|
- topic: depth
|
||||||
|
ros_topic: "/tactile/{left|right}/image"
|
||||||
|
msg: sensor_msgs/Image
|
||||||
type: image
|
type: image
|
||||||
unit: null
|
unit: null
|
||||||
notes: 接触面深度图
|
notes: >
|
||||||
|
接触面深度 / 形变图。编码随 SDK 返回值而定:float32 深度图是 32FC1,
|
||||||
|
uint8 形变图是 mono8(image_utils 自动判断,判不出来就跳过并告警一次)。
|
||||||
|
|
||||||
|
需要把 site.yaml 的 tactile.image_hz 设为 >0 才会发布 —— 默认 0(关闭),
|
||||||
|
因为图像每帧几百 KB,不该让它挤占三力那条 30fps 的时序。
|
||||||
|
|
||||||
- topic: force_vector
|
- topic: force_vector
|
||||||
|
ros_topic: "/tactile/{left|right}/vector"
|
||||||
|
msg: sensor_msgs/Image
|
||||||
type: image
|
type: image
|
||||||
unit: null
|
unit: null
|
||||||
notes: 接触点力矢量可视化
|
notes: >
|
||||||
|
接触点力矢量场。每像素 2 个 float32(dx, dy),编码 32FC2 —— 这是 ROS 里
|
||||||
|
表达矢量场的标准方式。
|
||||||
|
|
||||||
|
Orisys 的绑定里没有一个明确叫 "force vector map" 的方法,所以实现按语义
|
||||||
|
依次尝试 compute_flow / compute_hhd / compute_deformation,取第一个返回
|
||||||
|
二维以上数组的。全都拿不到就不发这个话题(宁可没有,也不发一张假图)。
|
||||||
|
|
||||||
|
同样需要 tactile.image_hz > 0。
|
||||||
|
|
||||||
# --- 厂商物料放置要求 ------------------------------------------------------
|
# --- 厂商物料放置要求 ------------------------------------------------------
|
||||||
assets:
|
assets:
|
||||||
@@ -121,6 +147,21 @@ config_schema:
|
|||||||
default: 20.0
|
default: 20.0
|
||||||
doc: 数据发布率。
|
doc: 数据发布率。
|
||||||
|
|
||||||
|
- key: image_hz
|
||||||
|
site_path: tactile.image_hz
|
||||||
|
type: float
|
||||||
|
unit: Hz
|
||||||
|
required: false
|
||||||
|
default: 0.0
|
||||||
|
doc: >
|
||||||
|
视触觉图像(深度/形变图,话题 /tactile/<side>/image)与力矢量场
|
||||||
|
(话题 /tactile/<side>/vector)的发布频率。0 = 关闭。
|
||||||
|
|
||||||
|
默认关是因为图像每帧几百 KB,跨进程序列化 + ROS 发布的开销远大于三力;
|
||||||
|
三力的 30fps 是接触判据的基础,不该被动到。图像走一条独立的
|
||||||
|
maxsize=1 队列,满了丢旧留新,所以打开它也不会拖累三力。
|
||||||
|
人工观察 / 调试设 5~10 即可,不必跟 target_fps 一样高。
|
||||||
|
|
||||||
- key: cuda
|
- key: cuda
|
||||||
site_path: tactile.cuda
|
site_path: tactile.cuda
|
||||||
type: bool
|
type: bool
|
||||||
|
|||||||
+12
-2
@@ -10,8 +10,18 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `arm_application/orisys_tactile.py` | SDK 适配 + `load_orisys_from_site` + `tactile_worker`(多进程工作器) |
|
| `arm_application/orisys_tactile.py` | SDK 适配 + `load_orisys_from_site` + `tactile_worker`(多进程工作器) |
|
||||||
| `arm_application/tactile_interface.py` | 可替换接口 / Mock |
|
| `arm_application/tactile_interface.py` | 可替换接口 / Mock |
|
||||||
| `arm_application/tactile_node.py` | 发布 `/tactile/left\|right/status` |
|
| `arm_application/tactile_node.py` | 发布 `/tactile/left\|right/status`(三力),以及可选的 `/image`(深度图)与 `/vector`(力矢量场) |
|
||||||
| `config/orisys/dd03.json` | 传感器几何/跟踪(厂家配置,可进 git) |
|
| `arm_application/image_utils.py` | numpy 数组 → `sensor_msgs/Image` 的打包(编码自动判定) |
|
||||||
|
| `devices/sensors/orisys/models/dd03/config.json` | 传感器几何/跟踪(厂家配置,可进 git)。原先在 `src/arm_application/config/orisys/`,已迁到设备目录随 SDK 一起放 |
|
||||||
|
|
||||||
|
**四路数据怎么拿到**:三力在 `/tactile/<side>/status`(`TactileStatus` 的
|
||||||
|
`fnormal` / `fshearx` / `fsheary`),深度图在 `/tactile/<side>/image`,
|
||||||
|
力矢量场在 `/tactile/<side>/vector`(`32FC2`,每像素 dx/dy)。
|
||||||
|
|
||||||
|
后两路默认**关闭**:设 `site.yaml` 的 `tactile.image_hz > 0` 才发布。原因见下节 ——
|
||||||
|
图像不该和 30fps 的三力挤同一条队列。没有硬件时可以用
|
||||||
|
`ros2 run arm_application tactile --ros-args -p backend:=mock -p image_hz:=5.0`
|
||||||
|
把这两条话题跑起来验证链路。
|
||||||
|
|
||||||
**架构:每侧手指一个独立进程**(厂家示例 `two_sensor_pipeline.py` 的推荐做法,
|
**架构:每侧手指一个独立进程**(厂家示例 `two_sensor_pipeline.py` 的推荐做法,
|
||||||
每进程独立 CUDA 上下文)。实测单进程双开两路会互相拖垮(30→0.8fps),
|
每进程独立 CUDA 上下文)。实测单进程双开两路会互相拖垮(30→0.8fps),
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""RDK 操作公共函数(必须在 rdk_py310 / Python 3.10 下运行)。
|
||||||
|
|
||||||
|
背景与约束
|
||||||
|
----------
|
||||||
|
非夕的 `flexivrdk` Python 绑定与 ROS 环境的 Python 3.11 ABI 不兼容,所以所有
|
||||||
|
直接调用 RDK 的代码必须以子进程方式跑在 `/home/cc/rdk_py310/bin/python` 下。
|
||||||
|
因此本文件不能用任何 3.11+ 语法,也不 import ROS。
|
||||||
|
|
||||||
|
抽取原因
|
||||||
|
--------
|
||||||
|
`ensure_operational` / `ensure_idle` / tool 相关函数原先在
|
||||||
|
`scripts/rdk_switch_tool.py` 与 `scripts/rdk_drag_bridge.py` 里各写了一份,
|
||||||
|
行为已经出现细微差异(一个用 ServoOn 一个用 Enable,超时时间不同)。
|
||||||
|
统一到本文件后,两处调用方行为一致。
|
||||||
|
|
||||||
|
重要限制
|
||||||
|
--------
|
||||||
|
RDK 对同一台机器人是**独占访问**:同时有两个进程连着同一个 SN 会失败或行为异常。
|
||||||
|
所以框架里所有 RDK 操作都应当经由 `rdk_drag_bridge.py` 这一个长驻进程串行化,
|
||||||
|
不要让业务节点各自起 RDK 连接。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 使能与故障
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def ensure_operational(robot: Any, timeout_s: float = 10.0) -> Optional[str]:
|
||||||
|
"""确保机器人处于 operational(已使能、无故障)。
|
||||||
|
|
||||||
|
返回 ``None`` 表示成功,否则返回给用户看的错误说明。
|
||||||
|
|
||||||
|
这是幂等的:已经 operational 时不做任何事。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if robot.fault():
|
||||||
|
robot.ClearFault()
|
||||||
|
time.sleep(0.5)
|
||||||
|
if not robot.operational():
|
||||||
|
# 不同 RDK 版本一个叫 ServoOn 一个叫 Enable。
|
||||||
|
for fn in ("ServoOn", "Enable"):
|
||||||
|
if hasattr(robot, fn):
|
||||||
|
try:
|
||||||
|
getattr(robot, fn)()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while not robot.operational() and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if not robot.operational():
|
||||||
|
return (
|
||||||
|
"机器人未处于 operational 状态(故障 / 急停未复位?)。"
|
||||||
|
"请在示教器上清故障、松开急停,并确认处于 AUTO(Remote)。"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return str(exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_fault(robot: Any) -> Optional[str]:
|
||||||
|
"""清除故障。返回 ``None`` 表示成功。"""
|
||||||
|
try:
|
||||||
|
robot.ClearFault()
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return str(exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_idle(robot: Any, mode: Any, timeout_s: float = 10.0) -> Optional[str]:
|
||||||
|
"""确保机器人切到 IDLE 模式(工具切换等操作的前提)。
|
||||||
|
|
||||||
|
返回 ``None`` 表示成功,否则返回错误说明。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
robot.SwitchMode(mode.IDLE)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
err = ensure_operational(robot, timeout_s=timeout_s)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
try:
|
||||||
|
robot.SwitchMode(mode.IDLE)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return f"无法切到 IDLE: {exc}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 工具坐标系
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def single_arm_groups(robot: Any) -> List[Any]:
|
||||||
|
"""返回工具 API 的关节组列表。
|
||||||
|
|
||||||
|
RDK 1.9 的 Tool API 是单臂的(没有 JointGroup 概念),此时返回 ``[None]``
|
||||||
|
作为哨兵,调用方据此走不带 group 参数的分支。老版本返回实际组名列表。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
info = robot.info()
|
||||||
|
groups = getattr(info, "single_arm_groups", None) or {}
|
||||||
|
keys = list(groups.keys()) if isinstance(groups, dict) else list(groups)
|
||||||
|
if keys:
|
||||||
|
return keys
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import flexivrdk
|
||||||
|
|
||||||
|
if hasattr(flexivrdk, "JointGroup"):
|
||||||
|
return [flexivrdk.JointGroup.ARM_1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [None]
|
||||||
|
|
||||||
|
|
||||||
|
def tool_name(tool: Any, group: Any) -> str:
|
||||||
|
"""读当前工具名。"""
|
||||||
|
try:
|
||||||
|
return str(tool.name() if group is None else tool.name(group))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def tool_params(tool: Any, group: Any) -> Dict[str, Any]:
|
||||||
|
"""读当前工具参数,归一化成普通 dict。
|
||||||
|
|
||||||
|
返回 ``{"mass_kg": float, "com_m": [x,y,z], "tcp_m": [x,y,z]}``。
|
||||||
|
读不到的字段用 0 / 空列表。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = tool.params() if group is None else tool.params(group)
|
||||||
|
except Exception:
|
||||||
|
return {"mass_kg": 0.0, "com_m": [], "tcp_m": []}
|
||||||
|
|
||||||
|
def _floats(value: Any) -> List[float]:
|
||||||
|
try:
|
||||||
|
return [float(v) for v in list(value)[:3]]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mass_kg": float(getattr(params, "mass", 0.0) or 0.0),
|
||||||
|
"com_m": _floats(getattr(params, "CoM", []) or []),
|
||||||
|
"tcp_m": _floats(getattr(params, "tcp_location", []) or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def switch_tool(tool: Any, group: Any, target: str) -> None:
|
||||||
|
"""切换工具。失败抛异常。"""
|
||||||
|
if group is None:
|
||||||
|
tool.Switch(target)
|
||||||
|
else:
|
||||||
|
tool.Switch(group, target)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tool_name(tool: Any, want: str) -> str:
|
||||||
|
"""把用户给的模糊名字解析成 Studio 工具池里的准确名字。
|
||||||
|
|
||||||
|
大小写不敏感。找不到时抛 ``ValueError``,消息里列出全部可选项 ——
|
||||||
|
这样操作员能立刻看到拼错在哪,而不是面对一个"切换失败"。
|
||||||
|
"""
|
||||||
|
want = (want or "").strip()
|
||||||
|
if not want:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
names = list(tool.list() or [])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ValueError(f"无法读取 Studio 工具列表: {exc}") from exc
|
||||||
|
|
||||||
|
if want in names:
|
||||||
|
return want
|
||||||
|
lower = {str(n).lower(): str(n) for n in names}
|
||||||
|
hit = lower.get(want.lower())
|
||||||
|
if hit:
|
||||||
|
return hit
|
||||||
|
raise ValueError(
|
||||||
|
f"工具 '{want}' 不在 Studio 工具池里。可选: "
|
||||||
|
+ (", ".join(str(n) for n in names) if names else "(空)")
|
||||||
|
)
|
||||||
+145
-35
@@ -1,24 +1,41 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""RDK Free-Drive bridge (must run under rdk_py310 / Python 3.10).
|
"""RDK 控制桥(必须在 rdk_py310 / Python 3.10 下运行)。
|
||||||
|
|
||||||
Why soft-impedance drag
|
为什么需要这个进程
|
||||||
-----------------------
|
------------------
|
||||||
Under AUTO (Remote) the Studio Free Drive button is unavailable (remote owns
|
非夕的 `flexivrdk` Python 绑定与 ROS 环境的 Python 3.11 ABI 不兼容,且 RDK 对
|
||||||
the robot). RDK FloatingJoint/FloatingCartesian often need an extra license.
|
同一台机器人是**独占访问**:多个进程同时连同一个 SN 会失败。所以框架里所有
|
||||||
So we default to soft NRT_JOINT_IMPEDANCE: low stiffness + target tracks
|
RDK 操作都收敛到本进程,由它串行化,业务节点通过 JSON 行协议远程调用。
|
||||||
measured joints → arm can be pushed by hand while Remote stays on.
|
|
||||||
|
|
||||||
IMPORTANT: flexivrdk.Robot is NOT thread-safe. All robot API calls (including
|
这也让 `arm_driver_flexiv` 不必 import flexivrdk,符合
|
||||||
the drag loop) share one lock. Joint reads during drag use a cache so Enter
|
`docs/INTERFACES.md` 的约束(业务节点不得 import flexivrdk)。
|
||||||
does not race Stop()/SendJointPosition.
|
|
||||||
|
|
||||||
Line protocol (JSON on stdout only; RDK logs → stderr):
|
软拖拽为什么用 NRT_JOINT_IMPEDANCE
|
||||||
|
----------------------------------
|
||||||
|
AUTO(Remote) 模式下示教器上的 Free Drive 按钮不可用(控制权在 Remote)。
|
||||||
|
RDK 的 FloatingJoint/FloatingCartesian 通常需要额外授权。
|
||||||
|
所以默认用低刚度 NRT_JOINT_IMPEDANCE:目标跟踪实测关节角 + 低刚度,
|
||||||
|
于是手臂可以用手推动,同时 Remote 保持开启。
|
||||||
|
|
||||||
|
线程安全
|
||||||
|
--------
|
||||||
|
`flexivrdk.Robot` 不是线程安全的。所有 robot API 调用(含拖拽循环)共用同一
|
||||||
|
把锁 `rlock`。拖拽期间的关节读取走缓存,避免 Enter 与 Stop() 竞争。
|
||||||
|
|
||||||
|
行协议(JSON 只写 stdout;RDK 自身日志走 stderr)
|
||||||
|
-------------------------------------------------
|
||||||
{"cmd":"ping"}
|
{"cmd":"ping"}
|
||||||
{"cmd":"connect","sn":"Rizon4-063989"}
|
{"cmd":"connect","sn":"Rizon4-063989","iface":"enp4s0"}
|
||||||
|
{"cmd":"enable"} # 使能 + 清故障(幂等)
|
||||||
|
{"cmd":"clear_fault"} # 显式清故障并使能
|
||||||
|
{"cmd":"switch_tool","name":"tool1"} # 切工具;name 空=只查询
|
||||||
|
{"cmd":"status"} # operational/fault/mode/tool
|
||||||
{"cmd":"drag_on","mode":"soft"} # soft | floating
|
{"cmd":"drag_on","mode":"soft"} # soft | floating
|
||||||
{"cmd":"drag_off"}
|
{"cmd":"drag_off"}
|
||||||
{"cmd":"joints"}
|
{"cmd":"joints"}
|
||||||
{"cmd":"quit"}
|
{"cmd":"quit"}
|
||||||
|
|
||||||
|
每个命令的应答都是单行 JSON,含 ``ok`` 字段;失败时另有 ``error``。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,8 +44,23 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
# 公共 RDK 操作统一在 scripts/lib/rdk_helpers.py,避免与 rdk_switch_tool.py
|
||||||
|
# 各写一份导致行为漂移。
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent / 'lib'))
|
||||||
|
from rdk_helpers import ( # noqa: E402
|
||||||
|
clear_fault as rdk_clear_fault,
|
||||||
|
ensure_idle,
|
||||||
|
ensure_operational,
|
||||||
|
resolve_tool_name,
|
||||||
|
single_arm_groups,
|
||||||
|
switch_tool,
|
||||||
|
tool_name,
|
||||||
|
tool_params,
|
||||||
|
)
|
||||||
|
|
||||||
_JSON_OUT = sys.stdout
|
_JSON_OUT = sys.stdout
|
||||||
sys.stdout = sys.stderr
|
sys.stdout = sys.stderr
|
||||||
|
|
||||||
@@ -131,29 +163,8 @@ class SoftDrag:
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_operational(robot) -> Optional[str]:
|
def _ensure_operational(robot) -> Optional[str]:
|
||||||
try:
|
"""保留旧名以兼容既有调用点;实现已统一到 rdk_helpers.ensure_operational。"""
|
||||||
if robot.fault():
|
return ensure_operational(robot)
|
||||||
robot.ClearFault()
|
|
||||||
time.sleep(0.5)
|
|
||||||
if not robot.operational():
|
|
||||||
for fn in ('ServoOn', 'Enable'):
|
|
||||||
if hasattr(robot, fn):
|
|
||||||
try:
|
|
||||||
getattr(robot, fn)()
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
t0 = time.monotonic()
|
|
||||||
while not robot.operational() and time.monotonic() - t0 < 10:
|
|
||||||
time.sleep(0.2)
|
|
||||||
if not robot.operational():
|
|
||||||
return (
|
|
||||||
'Robot not operational (fault/E-Stop?). '
|
|
||||||
'Clear fault on pendant, release E-Stop, AUTO Remote.'
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
return str(exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _idle_async(robot, lock: threading.Lock, flexivrdk, timeout: float = 4.0) -> bool:
|
def _idle_async(robot, lock: threading.Lock, flexivrdk, timeout: float = 4.0) -> bool:
|
||||||
@@ -308,6 +319,105 @@ def main() -> int:
|
|||||||
cached_q = q
|
cached_q = q
|
||||||
reply({'ok': True, 'q': q, 'drag': drag_on})
|
reply({'ok': True, 'q': q, 'drag': drag_on})
|
||||||
|
|
||||||
|
elif cmd == 'enable':
|
||||||
|
if robot is None:
|
||||||
|
reply({'ok': False, 'error': '尚未 connect'})
|
||||||
|
continue
|
||||||
|
err = ensure_operational(robot)
|
||||||
|
reply({
|
||||||
|
'ok': err is None,
|
||||||
|
'error': err,
|
||||||
|
'operational': bool(robot.operational()),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif cmd == 'clear_fault':
|
||||||
|
if robot is None:
|
||||||
|
reply({'ok': False, 'error': '尚未 connect'})
|
||||||
|
continue
|
||||||
|
err = rdk_clear_fault(robot)
|
||||||
|
if err:
|
||||||
|
reply({'ok': False, 'error': err})
|
||||||
|
continue
|
||||||
|
# 清故障后顺手使能,否则调用方还得再发一条 enable。
|
||||||
|
err2 = ensure_operational(robot)
|
||||||
|
reply({
|
||||||
|
'ok': err2 is None,
|
||||||
|
'error': err2,
|
||||||
|
'fault': bool(robot.fault()),
|
||||||
|
'operational': bool(robot.operational()),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif cmd == 'switch_tool':
|
||||||
|
if robot is None:
|
||||||
|
reply({'ok': False, 'error': '尚未 connect'})
|
||||||
|
continue
|
||||||
|
want = str(msg.get('name', '')).strip()
|
||||||
|
|
||||||
|
# 切换工具要求 IDLE;这一步是同步的,因为切换本身不能被打断。
|
||||||
|
err = ensure_idle(robot, flexivrdk.Mode, timeout_s=12.0)
|
||||||
|
if err:
|
||||||
|
reply({'ok': False, 'error': err})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with rlock:
|
||||||
|
tool = flexivrdk.Tool(robot)
|
||||||
|
groups = single_arm_groups(robot)
|
||||||
|
available = [str(n) for n in (tool.list() or [])]
|
||||||
|
anchor = groups[0] if groups else None
|
||||||
|
|
||||||
|
if not want:
|
||||||
|
# 纯查询:不切换,把当前工具与参数报回去。
|
||||||
|
params = tool_params(tool, anchor)
|
||||||
|
reply({
|
||||||
|
'ok': True,
|
||||||
|
'switched': False,
|
||||||
|
'current': tool_name(tool, anchor),
|
||||||
|
'available': available,
|
||||||
|
**params,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
target = resolve_tool_name(tool, want)
|
||||||
|
already = all(tool_name(tool, g) == target for g in groups)
|
||||||
|
if not already:
|
||||||
|
for g in groups:
|
||||||
|
switch_tool(tool, g, target)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
params = tool_params(tool, anchor)
|
||||||
|
reply({
|
||||||
|
'ok': True,
|
||||||
|
'switched': not already,
|
||||||
|
'already': already,
|
||||||
|
'current': tool_name(tool, anchor),
|
||||||
|
'target': target,
|
||||||
|
'available': available,
|
||||||
|
**params,
|
||||||
|
})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
reply({'ok': False, 'error': str(exc)})
|
||||||
|
|
||||||
|
elif cmd == 'status':
|
||||||
|
if robot is None:
|
||||||
|
reply({'ok': False, 'connected': False, 'error': '尚未 connect'})
|
||||||
|
continue
|
||||||
|
info = {'ok': True, 'connected': True, 'drag': drag_on}
|
||||||
|
try:
|
||||||
|
info['operational'] = bool(robot.operational())
|
||||||
|
info['fault'] = bool(robot.fault())
|
||||||
|
info['mode'] = str(robot.mode())
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
info['error'] = str(exc)
|
||||||
|
try:
|
||||||
|
with rlock:
|
||||||
|
tool = flexivrdk.Tool(robot)
|
||||||
|
groups = single_arm_groups(robot)
|
||||||
|
info['tool'] = tool_name(tool, groups[0] if groups else None)
|
||||||
|
except Exception:
|
||||||
|
info['tool'] = ''
|
||||||
|
reply(info)
|
||||||
|
|
||||||
elif cmd == 'quit':
|
elif cmd == 'quit':
|
||||||
if soft is not None:
|
if soft is not None:
|
||||||
soft.stop()
|
soft.stop()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
| `config/perception.yaml` `servo.yaml` | 默认值,被 `site.yaml` 覆盖 |
|
| `config/perception.yaml` `servo.yaml` | 默认值,被 `site.yaml` 覆盖 |
|
||||||
| `config/hole_detect.yaml` | `two_stage` 缺省;被 `site.yaml hole_detect` 覆盖 |
|
| `config/hole_detect.yaml` | `two_stage` 缺省;被 `site.yaml hole_detect` 覆盖 |
|
||||||
| `config/hole_insert.yaml` | `yolo`/`hough` 缺省 |
|
| `config/hole_insert.yaml` | `yolo`/`hough` 缺省 |
|
||||||
| `config/orisys/dd03.json` | Orisys 厂家配置,路径写在 `site.yaml tactile.config_file` |
|
| ~~`config/orisys/dd03.json`~~ | 已迁走:型号配置随 SDK 放在 `devices/sensors/orisys/models/dd03/config.json`,路径写在 `site.yaml tactile.config_file` |
|
||||||
| `config/handeye_result_89.yaml` / `_91.yaml` | 该臂手眼 T(标定写入;禁止混用) |
|
| `config/handeye_result_89.yaml` / `_91.yaml` | 该臂手眼 T(标定写入;禁止混用) |
|
||||||
| `config/handeye_poses_89.yaml` / `_91.yaml` | 该臂示教关节 |
|
| `config/handeye_poses_89.yaml` / `_91.yaml` | 该臂示教关节 |
|
||||||
| `launch/visual_servo.launch.py` | 感知 + 伺服 + 手眼 TF |
|
| `launch/visual_servo.launch.py` | 感知 + 伺服 + 手眼 TF |
|
||||||
|
|||||||
@@ -1,135 +1,40 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""TF-based geometric Jacobian + damped least-squares position IK step."""
|
"""向后兼容 shim —— 实现已移到 :mod:`arm_kinematics.geom_ik`。
|
||||||
|
|
||||||
from __future__ import annotations
|
为什么搬走
|
||||||
|
----------
|
||||||
|
这套几何雅可比 + DLS 逆解同时被应用层(视觉伺服、搜孔装配)和驱动层
|
||||||
|
(``arm_driver_flexiv`` 的柔顺下压)需要。留在 ``arm_application`` 里会让驱动层
|
||||||
|
反过来依赖应用层,属于层次倒置。搬到不依赖 ROS 的 ``arm_kinematics`` 后,
|
||||||
|
两边都依赖一个底层包。
|
||||||
|
|
||||||
from typing import List, Optional, Sequence, Tuple
|
为什么保留这个文件
|
||||||
|
------------------
|
||||||
|
``stage2_locate_node`` / ``stage3_search_node`` / ``servo_node`` 等现有代码都在
|
||||||
|
``from arm_application.geom_ik import ...``。保留 shim 就不用改动这些已经在跑的
|
||||||
|
文件 —— 重构不应牵连能工作的代码。
|
||||||
|
|
||||||
import numpy as np
|
新代码请直接 ``from arm_kinematics import dls_twist``。
|
||||||
|
"""
|
||||||
|
|
||||||
# URDF axes expressed in each child link frame (Rizon4).
|
from arm_kinematics.geom_ik import ( # noqa: F401
|
||||||
RIZON_LINK_AXES = [
|
RIZON_LINK_AXES,
|
||||||
(0.0, 0.0, 1.0), # joint1 -> link1
|
dls_position_step,
|
||||||
(0.0, 1.0, 0.0), # joint2 -> link2
|
dls_translate_hold_ori,
|
||||||
(0.0, 0.0, 1.0),
|
dls_twist,
|
||||||
(0.0, 1.0, 0.0),
|
geometric_jacobian,
|
||||||
(0.0, 0.0, 1.0),
|
load_handeye_T,
|
||||||
(0.0, 1.0, 0.0),
|
position_jacobian,
|
||||||
(0.0, 0.0, 1.0),
|
quat_xyzw_to_R,
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def quat_xyzw_to_R(q: Sequence[float]) -> np.ndarray:
|
|
||||||
x, y, z, w = [float(v) for v in q]
|
|
||||||
n = np.sqrt(x * x + y * y + z * z + w * w) + 1e-12
|
|
||||||
x, y, z, w = x / n, y / n, z / n, w / n
|
|
||||||
return np.array(
|
|
||||||
[
|
|
||||||
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
|
||||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
|
||||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
|
||||||
],
|
|
||||||
dtype=float,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
def geometric_jacobian(
|
"RIZON_LINK_AXES",
|
||||||
link_R: Sequence[np.ndarray],
|
"quat_xyzw_to_R",
|
||||||
link_p: Sequence[np.ndarray],
|
"geometric_jacobian",
|
||||||
ee_p: np.ndarray,
|
"position_jacobian",
|
||||||
axes_local: Sequence[Tuple[float, float, float]] = RIZON_LINK_AXES,
|
"dls_position_step",
|
||||||
) -> np.ndarray:
|
"dls_twist",
|
||||||
"""Return 6x7 geometric Jacobian in base frame (linear then angular)."""
|
"dls_translate_hold_ori",
|
||||||
n = len(axes_local)
|
"load_handeye_T",
|
||||||
J = np.zeros((6, n), dtype=float)
|
]
|
||||||
pee = np.asarray(ee_p, dtype=float).reshape(3)
|
|
||||||
for i in range(n):
|
|
||||||
R = np.asarray(link_R[i], dtype=float).reshape(3, 3)
|
|
||||||
p = np.asarray(link_p[i], dtype=float).reshape(3)
|
|
||||||
z = R @ np.asarray(axes_local[i], dtype=float).reshape(3)
|
|
||||||
J[:3, i] = np.cross(z, pee - p)
|
|
||||||
J[3:, i] = z
|
|
||||||
return J
|
|
||||||
|
|
||||||
|
|
||||||
def position_jacobian(
|
|
||||||
link_R: Sequence[np.ndarray],
|
|
||||||
link_p: Sequence[np.ndarray],
|
|
||||||
ee_p: np.ndarray,
|
|
||||||
axes_local: Sequence[Tuple[float, float, float]] = RIZON_LINK_AXES,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Return 3x7 translational Jacobian in base frame."""
|
|
||||||
return geometric_jacobian(link_R, link_p, ee_p, axes_local)[:3, :]
|
|
||||||
|
|
||||||
|
|
||||||
def dls_position_step(
|
|
||||||
J: np.ndarray,
|
|
||||||
dp_base: np.ndarray,
|
|
||||||
*,
|
|
||||||
damping: float = 0.05,
|
|
||||||
max_dq: float = 0.05,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Solve J dq = dp with damped least squares; clip |dq|."""
|
|
||||||
dp = np.asarray(dp_base, dtype=float).reshape(3)
|
|
||||||
JT = J.T
|
|
||||||
A = J @ JT + (damping ** 2) * np.eye(3)
|
|
||||||
dq = JT @ np.linalg.solve(A, dp)
|
|
||||||
dq = np.clip(dq, -abs(max_dq), abs(max_dq))
|
|
||||||
return dq
|
|
||||||
|
|
||||||
|
|
||||||
def dls_twist(
|
|
||||||
J6: np.ndarray,
|
|
||||||
v_base: np.ndarray,
|
|
||||||
*,
|
|
||||||
w_base: Optional[np.ndarray] = None,
|
|
||||||
damping: float = 0.08,
|
|
||||||
max_qdot: float = 0.80,
|
|
||||||
ori_weight: float = 1.2,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Solve J qdot = [v; w] with orientation held (w≈0 by default)."""
|
|
||||||
dx = np.zeros(6, dtype=float)
|
|
||||||
dx[:3] = np.asarray(v_base, dtype=float).reshape(3)
|
|
||||||
if w_base is not None:
|
|
||||||
dx[3:] = np.asarray(w_base, dtype=float).reshape(3)
|
|
||||||
w = np.array([1.0, 1.0, 1.0, ori_weight, ori_weight, ori_weight], dtype=float)
|
|
||||||
Jw = J6 * w[:, None]
|
|
||||||
dxw = dx * w
|
|
||||||
JT = Jw.T
|
|
||||||
A = Jw @ JT + (damping ** 2) * np.eye(6)
|
|
||||||
qdot = JT @ np.linalg.solve(A, dxw)
|
|
||||||
return np.clip(qdot, -abs(max_qdot), abs(max_qdot))
|
|
||||||
|
|
||||||
|
|
||||||
def dls_translate_hold_ori(
|
|
||||||
J6: np.ndarray,
|
|
||||||
dp_base: np.ndarray,
|
|
||||||
*,
|
|
||||||
damping: float = 0.08,
|
|
||||||
max_dq: float = 0.05,
|
|
||||||
ori_weight: float = 1.2,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Translate EE by dp while keeping orientation (wx,wy,wz ≈ 0)."""
|
|
||||||
dx = np.zeros(6, dtype=float)
|
|
||||||
dx[:3] = np.asarray(dp_base, dtype=float).reshape(3)
|
|
||||||
w = np.array([1.0, 1.0, 1.0, ori_weight, ori_weight, ori_weight], dtype=float)
|
|
||||||
Jw = J6 * w[:, None]
|
|
||||||
dxw = dx * w
|
|
||||||
JT = Jw.T
|
|
||||||
A = Jw @ JT + (damping ** 2) * np.eye(6)
|
|
||||||
dq = JT @ np.linalg.solve(A, dxw)
|
|
||||||
return np.clip(dq, -abs(max_dq), abs(max_dq))
|
|
||||||
|
|
||||||
|
|
||||||
def load_handeye_T(path: str) -> np.ndarray:
|
|
||||||
"""Load 4x4 from handeye_result.yaml (T_4x4 list)."""
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
data = yaml.safe_load(f)
|
|
||||||
if not isinstance(data, dict) or 'T_4x4' not in data:
|
|
||||||
raise ValueError(f'No T_4x4 in hand-eye file: {path}')
|
|
||||||
T = np.array(data['T_4x4'], dtype=float)
|
|
||||||
if T.shape != (4, 4):
|
|
||||||
raise ValueError(f'T_4x4 shape {T.shape}, expected (4,4)')
|
|
||||||
return T
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""把 numpy 数组打包成 ``sensor_msgs/Image``。
|
||||||
|
|
||||||
|
为什么需要
|
||||||
|
----------
|
||||||
|
Orisys 视触觉传感器的数据分两类:
|
||||||
|
|
||||||
|
* **三力**(``fnormal`` / ``fshearx`` / ``fsheary``)—— 每帧几个标量,走子进程
|
||||||
|
队列几乎没有开销。
|
||||||
|
* **图像类**(形变图 / 深度图 / 力矢量场)—— 每帧几百 KB 到几 MB。原先
|
||||||
|
``tactile_worker`` 为了性能**故意不过队列**,于是这些数据从未离开子进程、
|
||||||
|
也从未发布到 ROS 话题上。
|
||||||
|
|
||||||
|
本模块只负责最后一公里:把已经拿到的数组正确打包成 ``Image``。放在单独文件
|
||||||
|
里是为了能脱离相机单测 —— 编码、``step``、通道顺序这些细节一旦写错,下游
|
||||||
|
拿到的图会静默错位(图像撕裂、颜色通道互换),而不是报错。
|
||||||
|
|
||||||
|
支持的形状与编码
|
||||||
|
----------------
|
||||||
|
=============== ============== ==========================================
|
||||||
|
形状 dtype 编码
|
||||||
|
=============== ============== ==========================================
|
||||||
|
``(H, W)`` ``uint8`` ``mono8``
|
||||||
|
``(H, W)`` ``uint16`` ``mono16``
|
||||||
|
``(H, W)`` ``float32`` ``32FC1``
|
||||||
|
``(H, W, 1)`` 任意 ``mono8``
|
||||||
|
``(H, W, 2)`` ``float32`` ``32FC2``(力矢量场:每像素 dx, dy)
|
||||||
|
``(H, W, 3)`` ``uint8`` ``rgb8``
|
||||||
|
``(H, W, 4)`` ``uint8`` ``rgba8``
|
||||||
|
=============== ============== ==========================================
|
||||||
|
|
||||||
|
2 通道的 ``32FC2`` 是 ROS 里表达矢量场的标准做法,``step = W * 4 * 2``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from sensor_msgs.msg import Image
|
||||||
|
|
||||||
|
__all__ = ["ndarray_to_image", "infer_encoding", "UnsupportedImageError"]
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedImageError(ValueError):
|
||||||
|
"""数组的形状/类型没有对应的 ROS 编码。
|
||||||
|
|
||||||
|
宁可显式报错也不要猜 —— 猜错的结果是下游拿到一张静默错位的图。
|
||||||
|
错误消息里带上实际形状与 dtype,方便对照本文档的表格。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
#: (通道数, dtype) -> ROS 编码
|
||||||
|
_ENCODING_BY_CHANNELS = {
|
||||||
|
(1, np.dtype(np.uint8)): "mono8",
|
||||||
|
(1, np.dtype(np.uint16)): "mono16",
|
||||||
|
(1, np.dtype(np.float32)): "32FC1",
|
||||||
|
(2, np.dtype(np.float32)): "32FC2",
|
||||||
|
(3, np.dtype(np.uint8)): "rgb8",
|
||||||
|
(4, np.dtype(np.uint8)): "rgba8",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_encoding(array: np.ndarray) -> str:
|
||||||
|
"""推断 ROS 编码。推不出来时抛 :class:`UnsupportedImageError`。"""
|
||||||
|
arr = np.asarray(array)
|
||||||
|
if arr.ndim == 2:
|
||||||
|
channels = 1
|
||||||
|
elif arr.ndim == 3:
|
||||||
|
channels = arr.shape[2]
|
||||||
|
else:
|
||||||
|
raise UnsupportedImageError(
|
||||||
|
f"只支持 2D(灰度)或 3D(H,W,C)数组,收到 {arr.ndim}D,形状 {arr.shape}"
|
||||||
|
)
|
||||||
|
|
||||||
|
key = (channels, arr.dtype)
|
||||||
|
encoding = _ENCODING_BY_CHANNELS.get(key)
|
||||||
|
if encoding is None:
|
||||||
|
supported = ", ".join(
|
||||||
|
f"{enc}({c}通道/{d.name})"
|
||||||
|
for (c, d), enc in sorted(_ENCODING_BY_CHANNELS.items(), key=lambda kv: kv[1])
|
||||||
|
)
|
||||||
|
raise UnsupportedImageError(
|
||||||
|
f"没有 {channels} 通道 + {arr.dtype.name} 对应的 ROS 编码。"
|
||||||
|
f"支持: {supported}。"
|
||||||
|
f"(收到形状 {arr.shape})"
|
||||||
|
)
|
||||||
|
return encoding
|
||||||
|
|
||||||
|
|
||||||
|
def _layout(array: np.ndarray) -> Tuple[int, int, int]:
|
||||||
|
"""返回 ``(height, width, channels)``,并把 (H,W,1) 归一化成 2D 语义。"""
|
||||||
|
arr = np.asarray(array)
|
||||||
|
if arr.ndim == 2:
|
||||||
|
h, w = arr.shape
|
||||||
|
return int(h), int(w), 1
|
||||||
|
h, w, c = arr.shape
|
||||||
|
return int(h), int(w), int(c)
|
||||||
|
|
||||||
|
|
||||||
|
def ndarray_to_image(
|
||||||
|
array: np.ndarray,
|
||||||
|
*,
|
||||||
|
frame_id: str = "",
|
||||||
|
stamp=None,
|
||||||
|
encoding: Optional[str] = None,
|
||||||
|
) -> Image:
|
||||||
|
"""把数组打包成 ``sensor_msgs/Image``。
|
||||||
|
|
||||||
|
参数
|
||||||
|
----
|
||||||
|
array:
|
||||||
|
图像或矢量场。必须是 C 连续(``np.ascontiguousarray`` 会自动处理)。
|
||||||
|
encoding:
|
||||||
|
显式指定编码以覆盖推断。仅在确实知道自己在做什么时使用 ——
|
||||||
|
编码与 dtype 不匹配会让下游按错误的元素大小解读 ``data``。
|
||||||
|
"""
|
||||||
|
arr = np.asarray(array)
|
||||||
|
if encoding is None:
|
||||||
|
encoding = infer_encoding(arr)
|
||||||
|
|
||||||
|
height, width, channels = _layout(arr)
|
||||||
|
|
||||||
|
# (H,W,1) 在内存里与 (H,W) 连续一致,压平即可。
|
||||||
|
flat = np.ascontiguousarray(arr).reshape(-1)
|
||||||
|
|
||||||
|
msg = Image()
|
||||||
|
if stamp is not None:
|
||||||
|
msg.header.stamp = stamp
|
||||||
|
msg.header.frame_id = frame_id
|
||||||
|
msg.height = height
|
||||||
|
msg.width = width
|
||||||
|
msg.encoding = encoding
|
||||||
|
msg.is_bigendian = False
|
||||||
|
msg.step = int(width * channels * flat.dtype.itemsize)
|
||||||
|
msg.data = flat.tobytes()
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def expected_step(array: np.ndarray, encoding: Optional[str] = None) -> int:
|
||||||
|
"""算出应当写入 ``Image.step`` 的值。测试与自检用。"""
|
||||||
|
arr = np.asarray(array)
|
||||||
|
_, width, channels = _layout(arr)
|
||||||
|
if encoding is not None:
|
||||||
|
channels = {"mono8": 1, "mono16": 1, "32FC1": 1, "32FC2": 2,
|
||||||
|
"rgb8": 3, "rgba8": 4}.get(encoding, channels)
|
||||||
|
itemsize = {"mono8": 1, "mono16": 2, "32FC1": 4, "32FC2": 4,
|
||||||
|
"rgb8": 1, "rgba8": 1}.get(encoding, arr.dtype.itemsize)
|
||||||
|
return int(width * channels * itemsize)
|
||||||
|
return int(width * channels * arr.dtype.itemsize)
|
||||||
@@ -32,9 +32,28 @@ class OrisysDualConfig:
|
|||||||
baseline_seconds: float = 2.0
|
baseline_seconds: float = 2.0
|
||||||
filter_alpha: float = 0.25
|
filter_alpha: float = 0.25
|
||||||
backend: str = "orisys" # orisys | mock
|
backend: str = "orisys" # orisys | mock
|
||||||
|
image_hz: float = 0.0
|
||||||
|
"""视触觉图像(深度/形变图)的发布频率。0 = 不发布图像。
|
||||||
|
|
||||||
|
为什么默认关:图像每帧几百 KB,跨进程序列化 + ROS 发布的开销远大于三力。
|
||||||
|
三力路径必须跑在 30fps(接触判据靠它),图像只是给人看和给算法备用,
|
||||||
|
默认不开以免动到已经调好的三力时序。装配现场按需在 site.yaml 打开。"""
|
||||||
|
|
||||||
|
|
||||||
def resolve_orisys_config(config_name: str, config_file: str = "") -> str:
|
def resolve_orisys_config(config_name: str, config_file: str = "") -> str:
|
||||||
|
"""把型号名解析成实际的 config.json 路径。
|
||||||
|
|
||||||
|
查找顺序(先命中先用):
|
||||||
|
|
||||||
|
1. 显式传入的 ``config_file``
|
||||||
|
2. 参数本身就是存在的文件路径
|
||||||
|
3. ``devices/sensors/orisys/models/<型号>/config.json`` —— 型号配置随 SDK 放,
|
||||||
|
这是现在唯一推荐的位置
|
||||||
|
4. 老位置 ``src/arm_application/config/orisys/<型号>.json``(兼容保留:
|
||||||
|
站点自己放的定制配置可能还在这里)
|
||||||
|
5. Orisys 包自带的 ``configs/<型号>.json``
|
||||||
|
6. 都没找到就把原样返回,交给 SDK 自己解析(它支持内置型号名)
|
||||||
|
"""
|
||||||
if config_file:
|
if config_file:
|
||||||
p = Path(config_file).expanduser()
|
p = Path(config_file).expanduser()
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
@@ -43,14 +62,19 @@ def resolve_orisys_config(config_name: str, config_file: str = "") -> str:
|
|||||||
p = Path(raw).expanduser()
|
p = Path(raw).expanduser()
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
return str(p)
|
return str(p)
|
||||||
pkg = ros2_root() / "src" / "arm_application" / "config" / "orisys" / f"{raw}.json"
|
|
||||||
if pkg.is_file():
|
root = ros2_root()
|
||||||
return str(pkg)
|
for cand in (
|
||||||
|
root / "devices" / "sensors" / "orisys" / "models" / raw / "config.json",
|
||||||
|
root / "src" / "arm_application" / "config" / "orisys" / f"{raw}.json",
|
||||||
|
):
|
||||||
|
if cand.is_file():
|
||||||
|
return str(cand)
|
||||||
try:
|
try:
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
root = Path(orisys.__file__).resolve().parent.parent
|
pkg_root = Path(orisys.__file__).resolve().parent.parent
|
||||||
for cand in (root / "configs" / f"{raw}.json", root / "config" / f"{raw}.json"):
|
for cand in (pkg_root / "configs" / f"{raw}.json", pkg_root / "config" / f"{raw}.json"):
|
||||||
if cand.is_file():
|
if cand.is_file():
|
||||||
return str(cand)
|
return str(cand)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -63,7 +87,9 @@ def load_orisys_from_site(data: dict) -> OrisysDualConfig:
|
|||||||
left_raw = t.get("left") if isinstance(t.get("left"), dict) else {}
|
left_raw = t.get("left") if isinstance(t.get("left"), dict) else {}
|
||||||
right_raw = t.get("right") if isinstance(t.get("right"), dict) else {}
|
right_raw = t.get("right") if isinstance(t.get("right"), dict) else {}
|
||||||
cfg_file = str(t.get("config_file") or "")
|
cfg_file = str(t.get("config_file") or "")
|
||||||
cfg_name = str(t.get("config") or "dd03")
|
# model 是设备契约层选型号用的键(对应 models/<型号>/ 目录名);
|
||||||
|
# config 是它的旧名,仅为兼容老站点文件保留,站点里应统一用 model。
|
||||||
|
cfg_name = str(t.get("model") or t.get("config") or "dd03")
|
||||||
resolved = resolve_orisys_config(cfg_name, cfg_file)
|
resolved = resolve_orisys_config(cfg_name, cfg_file)
|
||||||
common = dict(
|
common = dict(
|
||||||
config_name=resolved,
|
config_name=resolved,
|
||||||
@@ -100,9 +126,37 @@ def load_orisys_from_site(data: dict) -> OrisysDualConfig:
|
|||||||
baseline_seconds=float(t.get("baseline_seconds") or 2.0),
|
baseline_seconds=float(t.get("baseline_seconds") or 2.0),
|
||||||
filter_alpha=float(t.get("filter_alpha") or 0.25),
|
filter_alpha=float(t.get("filter_alpha") or 0.25),
|
||||||
backend=str(t.get("backend") or "orisys"),
|
backend=str(t.get("backend") or "orisys"),
|
||||||
|
image_hz=float(t.get("image_hz") or 0.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_vector_map(sensor: Any) -> Optional[np.ndarray]:
|
||||||
|
"""尝试从传感器读出力矢量场(每像素的切向力/位移矢量)。
|
||||||
|
|
||||||
|
为什么要"尝试"
|
||||||
|
--------------
|
||||||
|
Orisys 的 Python 绑定是编译过的,公开接口里没有一个明确叫
|
||||||
|
"force vector map" 的方法。按语义最接近的是 ``compute_flow()``
|
||||||
|
(形变光流 ≈ 切向位移场 ≈ 力矢量场),其次是 ``compute_hhd()``。
|
||||||
|
不同 SDK 版本这两个方法的返回值也不一样 —— 有的是 ``(H,W,2)`` 数组,
|
||||||
|
有的返回 ``bool``(只给"形变是否超阈值")或可视化图。
|
||||||
|
|
||||||
|
所以这里逐个试,只接受真正是二维以上数组的结果;都拿不到就返回 ``None``,
|
||||||
|
上层跳过该话题而不是发一张假图。哪一路成功会打到日志里,便于现场核对。
|
||||||
|
"""
|
||||||
|
for name in ("compute_flow", "compute_hhd", "compute_deformation"):
|
||||||
|
fn = getattr(sensor, name, None)
|
||||||
|
if fn is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = fn()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(result, np.ndarray) and result.ndim >= 2 and result.size:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_video(value) -> int | str:
|
def _parse_video(value) -> int | str:
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return value
|
return value
|
||||||
@@ -153,13 +207,17 @@ class OrisysTactile(TactileInterface):
|
|||||||
self._calibrate_baseline()
|
self._calibrate_baseline()
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
|
||||||
def read_side(self, side: str) -> dict[str, Any]:
|
def read_side(self, side: str, want_vector: bool = False) -> dict[str, Any]:
|
||||||
"""读指定侧(单侧进程模式);未连接/未知侧返回全零。"""
|
"""读指定侧(单侧进程模式);未连接/未知侧返回全零。
|
||||||
|
|
||||||
|
``want_vector=True`` 时额外计算力矢量场(逐像素运算,较贵)——
|
||||||
|
只应在需要发布矢量图的那些帧上打开。
|
||||||
|
"""
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
return self._empty_sample(side)
|
return self._empty_sample(side)
|
||||||
for item in self._sensors:
|
for item in self._sensors:
|
||||||
if item["side"] == side:
|
if item["side"] == side:
|
||||||
return self._read_single(item)
|
return self._read_single(item, want_vector=want_vector)
|
||||||
return self._empty_sample(side)
|
return self._empty_sample(side)
|
||||||
|
|
||||||
def disconnect(self) -> None:
|
def disconnect(self) -> None:
|
||||||
@@ -226,7 +284,7 @@ class OrisysTactile(TactileInterface):
|
|||||||
raw={"left": left, "right": right, "centroid": self._estimate_centroid(left, right)},
|
raw={"left": left, "right": right, "centroid": self._estimate_centroid(left, right)},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _read_single(self, item: dict) -> dict[str, Any]:
|
def _read_single(self, item: dict, want_vector: bool = False) -> dict[str, Any]:
|
||||||
sensor = item["sensor"]
|
sensor = item["sensor"]
|
||||||
cfg = item["cfg"]
|
cfg = item["cfg"]
|
||||||
img = sensor.get_img()
|
img = sensor.get_img()
|
||||||
@@ -263,6 +321,9 @@ class OrisysTactile(TactileInterface):
|
|||||||
"centroid": centroid,
|
"centroid": centroid,
|
||||||
"centroid_found": centroid_found,
|
"centroid_found": centroid_found,
|
||||||
"img": img,
|
"img": img,
|
||||||
|
# 矢量场只在被要求时才算:compute_flow 是逐像素运算,每帧都做会
|
||||||
|
# 明显增加 worker 耗时,可能拖累三力的 30fps。
|
||||||
|
"vector": read_vector_map(sensor) if want_vector else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _empty_sample(self, side: str = "unknown") -> dict[str, Any]:
|
def _empty_sample(self, side: str = "unknown") -> dict[str, Any]:
|
||||||
@@ -276,6 +337,7 @@ class OrisysTactile(TactileInterface):
|
|||||||
"centroid": (0.0, 0.0),
|
"centroid": (0.0, 0.0),
|
||||||
"centroid_found": False,
|
"centroid_found": False,
|
||||||
"img": None,
|
"img": None,
|
||||||
|
"vector": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _calibrate_baseline(self) -> None:
|
def _calibrate_baseline(self) -> None:
|
||||||
@@ -403,10 +465,17 @@ def _usb_self_heal(vid_src, side: str) -> None:
|
|||||||
return f"[{side}] USB 自愈失败 {dev}: {exc}"
|
return f"[{side}] USB 自愈失败 {dev}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
def tactile_worker(side: str, cfg: OrisysDualConfig, q, stop_event) -> None:
|
def tactile_worker(side: str, cfg: OrisysDualConfig, q, stop_event, image_q=None) -> None:
|
||||||
"""独立进程:开一侧传感器,按 target_fps 把最新样本推进队列(图像不过队列)。
|
"""独立进程:开一侧传感器,把样本推进队列。
|
||||||
|
|
||||||
|
q: mp.Queue(调用方给 maxsize≈2,满了丢旧留新)—— 三力等轻量数据,每帧都推。
|
||||||
|
image_q: mp.Queue 或 None —— 视触觉图像与力矢量场。
|
||||||
|
|
||||||
|
图像默认**不过队列**(``image_q=None``):每帧几百 KB,跨进程序列化 +
|
||||||
|
ROS 发布的开销会挤占三力的 30fps 时序。需要图像时调用方传一个
|
||||||
|
``maxsize=1`` 的队列,并按 ``cfg.image_hz`` 节流推送 —— 队列满时丢旧留新,
|
||||||
|
主进程来不及消费也只会丢图像,不会影响三力。
|
||||||
|
|
||||||
q: mp.Queue(调用方给 maxsize≈2,满了丢旧留新)
|
|
||||||
stop_event: mp.Event,置位后干净退出(先 disconnect 释放 V4L2 设备)
|
stop_event: mp.Event,置位后干净退出(先 disconnect 释放 V4L2 设备)
|
||||||
|
|
||||||
自动重连:摄像头被强占/USB 抖动后会出现 get_img 持续 select 超时(空读,
|
自动重连:摄像头被强占/USB 抖动后会出现 get_img 持续 select 超时(空读,
|
||||||
@@ -429,7 +498,23 @@ def tactile_worker(side: str, cfg: OrisysDualConfig, q, stop_event) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _put_image(item: dict) -> None:
|
||||||
|
"""图像入队:只保留最新一帧。队列满说明主进程没在消费,丢掉旧的即可。"""
|
||||||
|
try:
|
||||||
|
if image_q is None:
|
||||||
|
return
|
||||||
|
if image_q.full():
|
||||||
|
try:
|
||||||
|
image_q.get_nowait()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
image_q.put(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
period = 1.0 / max(1.0, float(getattr(cfg.left if side == "left" else cfg.right, "target_fps", 30.0)))
|
period = 1.0 / max(1.0, float(getattr(cfg.left if side == "left" else cfg.right, "target_fps", 30.0)))
|
||||||
|
img_period = (1.0 / cfg.image_hz) if (image_q is not None and cfg.image_hz > 0) else 0.0
|
||||||
|
last_img_t = 0.0
|
||||||
vid_src = getattr(cfg.left if side == "left" else cfg.right, "vid_src", None)
|
vid_src = getattr(cfg.left if side == "left" else cfg.right, "vid_src", None)
|
||||||
tac: Optional[OrisysTactile] = None
|
tac: Optional[OrisysTactile] = None
|
||||||
bad = 0
|
bad = 0
|
||||||
@@ -452,9 +537,11 @@ def tactile_worker(side: str, cfg: OrisysDualConfig, q, stop_event) -> None:
|
|||||||
time.sleep(min(backoff, 3.0))
|
time.sleep(min(backoff, 3.0))
|
||||||
backoff *= 2.0
|
backoff *= 2.0
|
||||||
continue
|
continue
|
||||||
|
# 只有图像帧才算矢量场:compute_flow 是逐像素运算,每帧都算会拖累三力。
|
||||||
|
want_img = image_q is not None and (time.perf_counter() - last_img_t) >= img_period
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
d = tac.read_side(side)
|
d = tac.read_side(side, want_vector=want_img)
|
||||||
empty = d.get("img") is None
|
empty = d.get("img") is None
|
||||||
except Exception:
|
except Exception:
|
||||||
d, empty = None, True
|
d, empty = None, True
|
||||||
@@ -478,7 +565,16 @@ def tactile_worker(side: str, cfg: OrisysDualConfig, q, stop_event) -> None:
|
|||||||
bad = 0
|
bad = 0
|
||||||
dead_cycles = 0
|
dead_cycles = 0
|
||||||
if d is not None and not empty:
|
if d is not None and not empty:
|
||||||
d = {k: v for k, v in d.items() if k != "img"}
|
if want_img:
|
||||||
|
last_img_t = time.perf_counter()
|
||||||
|
_put_image({
|
||||||
|
"side": side,
|
||||||
|
"img": d.get("img"),
|
||||||
|
"vector": d.get("vector"),
|
||||||
|
"stamp": time.time(),
|
||||||
|
})
|
||||||
|
# 图像与矢量场已单独走 image_q;三力这条轻量队列不背它们。
|
||||||
|
d = {k: v for k, v in d.items() if k not in ("img", "vector")}
|
||||||
d["error"] = ""
|
d["error"] = ""
|
||||||
_put(d)
|
_put(d)
|
||||||
rest = period - (time.perf_counter() - t0)
|
rest = period - (time.perf_counter() - t0)
|
||||||
|
|||||||
@@ -12,22 +12,32 @@ import multiprocessing as mp
|
|||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import rclpy
|
import rclpy
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
|
|
||||||
|
from arm_application.image_utils import UnsupportedImageError, ndarray_to_image
|
||||||
from arm_application.orisys_tactile import load_orisys_from_site, tactile_worker
|
from arm_application.orisys_tactile import load_orisys_from_site, tactile_worker
|
||||||
from arm_application.site_io import load_site_dict, site_path
|
from arm_application.site_io import load_site_dict, site_path
|
||||||
from arm_msgs.msg import TactileStatus
|
from arm_msgs.msg import TactileStatus
|
||||||
|
from sensor_msgs.msg import Image
|
||||||
|
|
||||||
|
|
||||||
class TactileNode(Node):
|
class TactileNode(Node):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__("tactile")
|
super().__init__("tactile")
|
||||||
self.declare_parameter("publish_hz", 20.0)
|
self.declare_parameter("publish_hz", 20.0)
|
||||||
|
# 下面两个可以用 ROS 参数覆盖 site.yaml,便于不改站点文件就做验证
|
||||||
|
# (例如 mock 后端 + 打开图像话题来确认链路通)。
|
||||||
|
self.declare_parameter("backend", "")
|
||||||
|
self.declare_parameter("image_hz", -1.0)
|
||||||
data = load_site_dict()
|
data = load_site_dict()
|
||||||
t = data.get("tactile") or {}
|
t = data.get("tactile") or {}
|
||||||
hz = float(t.get("publish_hz") or self.get_parameter("publish_hz").value or 20.0)
|
hz = float(t.get("publish_hz") or self.get_parameter("publish_hz").value or 20.0)
|
||||||
self._backend = str(t.get("backend") or "orisys").strip().lower()
|
backend_override = str(self.get_parameter("backend").value).strip()
|
||||||
|
self._backend = (
|
||||||
|
backend_override or str(t.get("backend") or "orisys")
|
||||||
|
).strip().lower()
|
||||||
self._mock = self._backend in ("mock", "mock_orisys")
|
self._mock = self._backend in ("mock", "mock_orisys")
|
||||||
|
|
||||||
self._pub = {
|
self._pub = {
|
||||||
@@ -41,6 +51,37 @@ class TactileNode(Node):
|
|||||||
self._queues: dict[str, object] = {}
|
self._queues: dict[str, object] = {}
|
||||||
self._stop = None
|
self._stop = None
|
||||||
|
|
||||||
|
# --- 视触觉图像 / 力矢量场 ---
|
||||||
|
# 默认关闭(image_hz=0):图像每帧几百 KB,不该拖累三力的 30fps 时序。
|
||||||
|
# 打开后走一条 maxsize=1 的独立队列,满则丢旧留新 —— 主进程来不及消费
|
||||||
|
# 只会丢图像,三力一条不受影响。
|
||||||
|
self._image_hz = (
|
||||||
|
float(self.get_parameter("image_hz").value)
|
||||||
|
if float(self.get_parameter("image_hz").value) >= 0
|
||||||
|
else float(t.get("image_hz") or 0.0)
|
||||||
|
)
|
||||||
|
self._image_q = None
|
||||||
|
self._image_warned: set[str] = set()
|
||||||
|
self._image_pub: dict[str, object] = {}
|
||||||
|
self._vector_pub: dict[str, object] = {}
|
||||||
|
self._image_count: dict[str, int] = {}
|
||||||
|
self._last_mock_img = 0.0
|
||||||
|
|
||||||
|
# 图像 publisher 与后端无关 —— mock 后端也要能发。
|
||||||
|
# 否则"图像话题到底通不通"只能等到有相机、上真机那天才发现。
|
||||||
|
if self._image_hz > 0:
|
||||||
|
for side in ("left", "right"):
|
||||||
|
self._image_pub[side] = self.create_publisher(
|
||||||
|
Image, f"/tactile/{side}/image", 5
|
||||||
|
)
|
||||||
|
self._vector_pub[side] = self.create_publisher(
|
||||||
|
Image, f"/tactile/{side}/vector", 5
|
||||||
|
)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"视触觉图像已开启: {self._image_hz:.1f}Hz "
|
||||||
|
f"→ /tactile/{{left,right}}/image 与 /vector"
|
||||||
|
)
|
||||||
|
|
||||||
if self._mock:
|
if self._mock:
|
||||||
self._connected = {"left": True, "right": True}
|
self._connected = {"left": True, "right": True}
|
||||||
self.get_logger().info("tactile mock backend")
|
self.get_logger().info("tactile mock backend")
|
||||||
@@ -48,11 +89,14 @@ class TactileNode(Node):
|
|||||||
cfg = load_orisys_from_site(data)
|
cfg = load_orisys_from_site(data)
|
||||||
ctx = mp.get_context("spawn") # spawn:子进程不带 DDS 线程,CUDA 上下文干净
|
ctx = mp.get_context("spawn") # spawn:子进程不带 DDS 线程,CUDA 上下文干净
|
||||||
self._stop = ctx.Event()
|
self._stop = ctx.Event()
|
||||||
|
if self._image_hz > 0:
|
||||||
|
self._image_q = ctx.Queue(maxsize=1)
|
||||||
for side in ("left", "right"):
|
for side in ("left", "right"):
|
||||||
q = ctx.Queue(maxsize=2)
|
q = ctx.Queue(maxsize=2)
|
||||||
self._queues[side] = q
|
self._queues[side] = q
|
||||||
p = ctx.Process(
|
p = ctx.Process(
|
||||||
target=tactile_worker, args=(side, cfg, q, self._stop),
|
target=tactile_worker,
|
||||||
|
args=(side, cfg, q, self._stop, self._image_q),
|
||||||
name=f"tactile_{side}", daemon=True,
|
name=f"tactile_{side}", daemon=True,
|
||||||
)
|
)
|
||||||
p.start()
|
p.start()
|
||||||
@@ -81,7 +125,101 @@ class TactileNode(Node):
|
|||||||
self._connected[side] = True
|
self._connected[side] = True
|
||||||
self._latest[side] = (time.monotonic(), latest)
|
self._latest[side] = (time.monotonic(), latest)
|
||||||
|
|
||||||
|
def _drain_images(self) -> None:
|
||||||
|
"""把 worker 推来的最新图像 / 力矢量场发布出去。
|
||||||
|
|
||||||
|
只取最新一帧:worker 已按 ``image_hz`` 节流、队列 ``maxsize=1``,
|
||||||
|
所以这里读到的永远是最新画面;主进程偶尔卡一下,丢掉的是中间帧
|
||||||
|
而不是最新帧。
|
||||||
|
"""
|
||||||
|
if self._image_q is None:
|
||||||
|
return
|
||||||
|
latest = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
latest = self._image_q.get_nowait()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if latest is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
side = str(latest.get("side") or "")
|
||||||
|
if side not in self._image_pub:
|
||||||
|
return
|
||||||
|
stamp = self.get_clock().now().to_msg()
|
||||||
|
frame_id = f"{side}_tactile"
|
||||||
|
|
||||||
|
self._publish_array(
|
||||||
|
self._image_pub[side], latest.get("img"), frame_id, stamp, side, "image"
|
||||||
|
)
|
||||||
|
self._publish_array(
|
||||||
|
self._vector_pub[side], latest.get("vector"), frame_id, stamp, side, "vector"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _publish_array(self, pub, array, frame_id: str, stamp, side: str, tag: str) -> None:
|
||||||
|
"""打包并发布一个数组;形状/类型不支持时跳过该话题而不是崩溃。
|
||||||
|
|
||||||
|
``array is None`` 是正常情况(该侧没有矢量场输出、或本帧没算),
|
||||||
|
不报错也不发空图 —— 发一张全零的假图会让下游以为接触到了东西。
|
||||||
|
"""
|
||||||
|
if array is None:
|
||||||
|
return
|
||||||
|
key = f"{side}_{tag}"
|
||||||
|
try:
|
||||||
|
msg = ndarray_to_image(array, frame_id=frame_id, stamp=stamp)
|
||||||
|
except UnsupportedImageError as exc:
|
||||||
|
# 编码不支持是固定的(SDK 给的 dtype 不会变),每侧每种图只提醒一次,
|
||||||
|
# 否则 5Hz 的警告会把现场日志冲掉。
|
||||||
|
if key not in self._image_warned:
|
||||||
|
self._image_warned.add(key)
|
||||||
|
self.get_logger().warn(
|
||||||
|
f"[{key}] 无法打包成 Image,已跳过该话题: {exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pub.publish(msg)
|
||||||
|
except Exception as exc:
|
||||||
|
if f"{key}_pub" not in self._image_warned:
|
||||||
|
self._image_warned.add(f"{key}_pub")
|
||||||
|
self.get_logger().warn(f"[{key}] 发布失败: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._image_count.get(key, 0) == 0:
|
||||||
|
self.get_logger().info(
|
||||||
|
f"[{key}] 首帧已发布 ({msg.width}x{msg.height} {msg.encoding})"
|
||||||
|
)
|
||||||
|
self._image_count[key] = self._image_count.get(key, 0) + 1
|
||||||
|
|
||||||
|
def _publish_mock_images(self, force: float) -> None:
|
||||||
|
"""mock 后端下造一张形变图 + 一张力矢量场,用来打通图像发布链路。
|
||||||
|
|
||||||
|
没有传感器时也要能把 ``/tactile/<side>/image`` 与 ``/vector`` 跑起来 ——
|
||||||
|
否则这两个话题是否真的能出图,只能等到上真机那天才知道。
|
||||||
|
|
||||||
|
图像内容模仿视触觉的形变:中间一个高斯隆起(法向深度),
|
||||||
|
连同指向中心的切向流(矢量场),接触越深幅度越大。
|
||||||
|
"""
|
||||||
|
if not self._image_pub:
|
||||||
|
return
|
||||||
|
stamp = self.get_clock().now().to_msg()
|
||||||
|
h, w = 48, 64
|
||||||
|
yy, xx = np.mgrid[0:h, 0:w]
|
||||||
|
cx, cy = w / 2.0, h / 2.0
|
||||||
|
bump = np.exp(-(((xx - cx) ** 2 + (yy - cy) ** 2) / (2.0 * 9.0 ** 2)))
|
||||||
|
|
||||||
|
depth = (bump * (0.05 + 0.5 * float(force))).astype(np.float32)
|
||||||
|
vx = ((cx - xx) / w * bump * float(force)).astype(np.float32)
|
||||||
|
vy = ((cy - yy) / h * bump * float(force)).astype(np.float32)
|
||||||
|
vector = np.dstack([vx, vy])
|
||||||
|
|
||||||
|
for side in ("left", "right"):
|
||||||
|
frame = f"{side}_tactile"
|
||||||
|
self._publish_array(self._image_pub[side], depth, frame, stamp, side, "image")
|
||||||
|
self._publish_array(self._vector_pub[side], vector, frame, stamp, side, "vector")
|
||||||
|
|
||||||
def _tick(self) -> None:
|
def _tick(self) -> None:
|
||||||
|
self._drain_images()
|
||||||
if self._mock:
|
if self._mock:
|
||||||
self._mock_n += 1
|
self._mock_n += 1
|
||||||
force = 0.2 if self._mock_n >= 8 else 0.0
|
force = 0.2 if self._mock_n >= 8 else 0.0
|
||||||
@@ -91,6 +229,12 @@ class TactileNode(Node):
|
|||||||
"fps": 30.0, "is_contact": self._mock_n >= 8,
|
"fps": 30.0, "is_contact": self._mock_n >= 8,
|
||||||
"centroid": (0.0, 0.0),
|
"centroid": (0.0, 0.0),
|
||||||
})
|
})
|
||||||
|
# mock 的图像也按 image_hz 节流,避免比真机行为更"勤快"。
|
||||||
|
if self._image_hz > 0:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_mock_img >= 1.0 / self._image_hz:
|
||||||
|
self._last_mock_img = now
|
||||||
|
self._publish_mock_images(force)
|
||||||
return
|
return
|
||||||
for side in ("left", "right"):
|
for side in ("left", "right"):
|
||||||
self._drain(side)
|
self._drain(side)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# Orisys 传感器配置
|
|
||||||
|
|
||||||
`dd03.json` 是厂家相机/跟踪参数,由 `site.yaml` → `tactile.config_file` 指向。
|
|
||||||
换型号时把新 json 放到本目录,并改 `tactile.config` / `config_file`。
|
|
||||||
|
|
||||||
Orisys Python SDK **不进本仓库**,在工控机:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e /path/to/orisys-sdk
|
|
||||||
```
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "dd03",
|
|
||||||
"type": "single_camera",
|
|
||||||
"version": "1.0",
|
|
||||||
"camera": {
|
|
||||||
"resolution_raw": [1280, 720],
|
|
||||||
"fps": 30,
|
|
||||||
"resolution_stitch": [420, 450],
|
|
||||||
"source_scale": 0.4,
|
|
||||||
"old": [672, 728, 1, 0.5],
|
|
||||||
"work": [504, 546, 1],
|
|
||||||
"work2": [420, 450, 0.8],
|
|
||||||
"work3": [350, 375, 4, 0.7],
|
|
||||||
"work4": [280, 300, 4, 0.4, 0.2]
|
|
||||||
},
|
|
||||||
"tracker": {
|
|
||||||
"adaptive": true,
|
|
||||||
"lower_threshold": 12.0,
|
|
||||||
"upper_threshold": 18.0,
|
|
||||||
"loop_closure_interval": 1,
|
|
||||||
"algorithm": "dis",
|
|
||||||
"dis_preset": "medium",
|
|
||||||
"dis_mode": 3,
|
|
||||||
"algorithm_list": ["raft", "dis"],
|
|
||||||
"hhd_downsample": 3
|
|
||||||
},
|
|
||||||
"contact": {
|
|
||||||
"min_contour_area": 3000,
|
|
||||||
"flow_threshold": 0.2,
|
|
||||||
"div_threshold": 0.022,
|
|
||||||
"expansion_threshold": 0.01,
|
|
||||||
"kernel_size": [5, 5],
|
|
||||||
"denoise_sigma": 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
<exec_depend>tf2_ros</exec_depend>
|
<exec_depend>tf2_ros</exec_depend>
|
||||||
<exec_depend>python3-yaml</exec_depend>
|
<exec_depend>python3-yaml</exec_depend>
|
||||||
<exec_depend>python3-serial</exec_depend>
|
<exec_depend>python3-serial</exec_depend>
|
||||||
|
<!-- geom_ik 的实现已移到 arm_kinematics(驱动层也要用,避免层次倒置)。
|
||||||
|
本包保留 arm_application/geom_ik.py 作为 re-export shim。 -->
|
||||||
|
<exec_depend>arm_kinematics</exec_depend>
|
||||||
|
|
||||||
<export>
|
<export>
|
||||||
<build_type>ament_python</build_type>
|
<build_type>ament_python</build_type>
|
||||||
|
|||||||
@@ -2,3 +2,11 @@
|
|||||||
script_dir=$base/lib/arm_application
|
script_dir=$base/lib/arm_application
|
||||||
[install]
|
[install]
|
||||||
install_scripts=$base/lib/arm_application
|
install_scripts=$base/lib/arm_application
|
||||||
|
|
||||||
|
[tool:pytest]
|
||||||
|
# ROS 2 Humble 的 launch_testing / launch_ros 两个 pytest 插件用了旧版 hook
|
||||||
|
# 签名(pytest_pycollect_makemodule 多声明了 path 参数),当前 pytest 会在
|
||||||
|
# 加载插件阶段就抛 PluginValidationError,连收集都进不去。
|
||||||
|
# 本包的单测不涉及 launch 测试,直接禁掉这两个插件。
|
||||||
|
addopts = -p no:launch_testing -p no:launch_ros
|
||||||
|
testpaths = test
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""``image_utils`` 的单测:数组 -> ``sensor_msgs/Image`` 的打包是否正确。
|
||||||
|
|
||||||
|
这类代码的错误是**静默**的:编码写错、step 算错、通道顺序颠倒,都不会抛异常,
|
||||||
|
只是下游拿到的图裂开或颜色错位 —— 而现场很难判断是相机坏了还是打包错了。
|
||||||
|
所以这里把每种编码的字节布局都钉死。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arm_application.image_utils import (
|
||||||
|
UnsupportedImageError,
|
||||||
|
expected_step,
|
||||||
|
infer_encoding,
|
||||||
|
ndarray_to_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 编码推断
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestInferEncoding:
|
||||||
|
def test_mono8(self):
|
||||||
|
assert infer_encoding(np.zeros((4, 5), np.uint8)) == "mono8"
|
||||||
|
|
||||||
|
def test_mono16(self):
|
||||||
|
assert infer_encoding(np.zeros((4, 5), np.uint16)) == "mono16"
|
||||||
|
|
||||||
|
def test_float_depth_is_32fc1(self):
|
||||||
|
"""深度图常以米为单位的 float32 出现,必须是 32FC1 而不是 mono。"""
|
||||||
|
assert infer_encoding(np.zeros((4, 5), np.float32)) == "32FC1"
|
||||||
|
|
||||||
|
def test_vector_field_is_32fc2(self):
|
||||||
|
"""力矢量场(每像素 dx, dy)用 32FC2 表达,这是 ROS 里的标准做法。"""
|
||||||
|
assert infer_encoding(np.zeros((4, 5, 2), np.float32)) == "32FC2"
|
||||||
|
|
||||||
|
def test_rgb8(self):
|
||||||
|
assert infer_encoding(np.zeros((4, 5, 3), np.uint8)) == "rgb8"
|
||||||
|
|
||||||
|
def test_rgba8(self):
|
||||||
|
assert infer_encoding(np.zeros((4, 5, 4), np.uint8)) == "rgba8"
|
||||||
|
|
||||||
|
def test_single_channel_3d_is_mono(self):
|
||||||
|
"""(H,W,1) 等价于灰度,不要因为没有 1 通道的编码表项就报错。"""
|
||||||
|
assert infer_encoding(np.zeros((4, 5, 1), np.uint8)) == "mono8"
|
||||||
|
|
||||||
|
def test_1d_is_rejected(self):
|
||||||
|
with pytest.raises(UnsupportedImageError) as exc:
|
||||||
|
infer_encoding(np.zeros(10, np.uint8))
|
||||||
|
assert "2D" in str(exc.value)
|
||||||
|
|
||||||
|
def test_4d_is_rejected(self):
|
||||||
|
with pytest.raises(UnsupportedImageError):
|
||||||
|
infer_encoding(np.zeros((2, 3, 4, 5), np.uint8))
|
||||||
|
|
||||||
|
def test_uint16_rgb_is_rejected(self):
|
||||||
|
"""16 位 RGB 没有标准编码;宁可报错也不要按 8 位打包(会静默错位)。"""
|
||||||
|
with pytest.raises(UnsupportedImageError) as exc:
|
||||||
|
infer_encoding(np.zeros((4, 5, 3), np.uint16))
|
||||||
|
assert "形状" in str(exc.value)
|
||||||
|
|
||||||
|
def test_float64_is_rejected(self):
|
||||||
|
"""float64 图很常见(默认 dtype),必须显式转换,不能猜。"""
|
||||||
|
with pytest.raises(UnsupportedImageError):
|
||||||
|
infer_encoding(np.zeros((4, 5), np.float64))
|
||||||
|
|
||||||
|
def test_error_lists_supported_combinations(self):
|
||||||
|
with pytest.raises(UnsupportedImageError) as exc:
|
||||||
|
infer_encoding(np.zeros((3, 3, 5), np.uint8))
|
||||||
|
assert "mono8" in str(exc.value) and "rgb8" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 打包
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackaging:
|
||||||
|
def test_mono8_layout(self):
|
||||||
|
arr = np.arange(12, dtype=np.uint8).reshape(3, 4)
|
||||||
|
msg = ndarray_to_image(arr, frame_id="left_tactile")
|
||||||
|
assert (msg.height, msg.width) == (3, 4)
|
||||||
|
assert msg.encoding == "mono8"
|
||||||
|
assert msg.step == 4
|
||||||
|
assert msg.header.frame_id == "left_tactile"
|
||||||
|
assert msg.is_bigendian is False
|
||||||
|
assert bytes(msg.data) == arr.tobytes()
|
||||||
|
|
||||||
|
def test_mono16_step_and_bytes(self):
|
||||||
|
arr = np.arange(12, dtype=np.uint16).reshape(3, 4)
|
||||||
|
msg = ndarray_to_image(arr)
|
||||||
|
assert msg.encoding == "mono16"
|
||||||
|
assert msg.step == 4 * 2 # 每像素 2 字节
|
||||||
|
assert len(msg.data) == 3 * 4 * 2
|
||||||
|
assert bytes(msg.data) == arr.tobytes()
|
||||||
|
|
||||||
|
def test_rgb8_step_is_three_bytes_per_pixel(self):
|
||||||
|
arr = np.zeros((5, 7, 3), np.uint8)
|
||||||
|
msg = ndarray_to_image(arr)
|
||||||
|
assert msg.encoding == "rgb8"
|
||||||
|
assert msg.step == 7 * 3
|
||||||
|
assert msg.height == 5 and msg.width == 7
|
||||||
|
|
||||||
|
def test_rgba8_step(self):
|
||||||
|
msg = ndarray_to_image(np.zeros((5, 7, 4), np.uint8))
|
||||||
|
assert msg.step == 7 * 4
|
||||||
|
|
||||||
|
def test_vector_field_32fc2(self):
|
||||||
|
"""矢量场:每像素 2 个 float32,step 必须是 W*8。"""
|
||||||
|
arr = np.zeros((6, 8, 2), np.float32)
|
||||||
|
msg = ndarray_to_image(arr)
|
||||||
|
assert msg.encoding == "32FC2"
|
||||||
|
assert msg.step == 8 * 4 * 2
|
||||||
|
assert len(msg.data) == 6 * 8 * 2 * 4
|
||||||
|
|
||||||
|
def test_float_depth_32fc1(self):
|
||||||
|
msg = ndarray_to_image(np.zeros((6, 8), np.float32))
|
||||||
|
assert msg.encoding == "32FC1"
|
||||||
|
assert msg.step == 8 * 4
|
||||||
|
|
||||||
|
def test_3d_single_channel_flattens_fine(self):
|
||||||
|
"""(H,W,1) 压平后与 (H,W) 的字节完全一致。"""
|
||||||
|
a = np.arange(12, dtype=np.uint8).reshape(3, 4, 1)
|
||||||
|
b = np.arange(12, dtype=np.uint8).reshape(3, 4)
|
||||||
|
assert bytes(ndarray_to_image(a).data) == bytes(ndarray_to_image(b).data)
|
||||||
|
|
||||||
|
def test_non_contiguous_input_is_handled(self):
|
||||||
|
"""切片产生的非连续数组必须先转连续,否则 tobytes 的顺序是错的。"""
|
||||||
|
base = np.arange(24, dtype=np.uint8).reshape(4, 6)
|
||||||
|
view = base[:, ::2] # 非连续
|
||||||
|
assert not view.flags["C_CONTIGUOUS"]
|
||||||
|
msg = ndarray_to_image(view)
|
||||||
|
assert msg.width == 3
|
||||||
|
assert bytes(msg.data) == np.ascontiguousarray(view).tobytes()
|
||||||
|
|
||||||
|
def test_width_and_height_are_ints_not_numpy(self):
|
||||||
|
"""ROS 消息字段是 int32;塞 numpy 标量在某些版本会失败。"""
|
||||||
|
msg = ndarray_to_image(np.zeros((3, 4), np.uint8))
|
||||||
|
assert isinstance(msg.height, int) and isinstance(msg.width, int)
|
||||||
|
assert isinstance(msg.step, int)
|
||||||
|
|
||||||
|
def test_stamp_is_forwarded(self):
|
||||||
|
"""stamp 必须原样传下去 —— 下游靠它做时间对齐。"""
|
||||||
|
from builtin_interfaces.msg import Time
|
||||||
|
|
||||||
|
stamp = Time(sec=123, nanosec=456)
|
||||||
|
msg = ndarray_to_image(np.zeros((2, 2), np.uint8), stamp=stamp)
|
||||||
|
assert msg.header.stamp.sec == 123
|
||||||
|
assert msg.header.stamp.nanosec == 456
|
||||||
|
|
||||||
|
def test_stamp_defaults_to_zero_when_omitted(self):
|
||||||
|
msg = ndarray_to_image(np.zeros((2, 2), np.uint8))
|
||||||
|
assert msg.header.stamp.sec == 0 and msg.header.stamp.nanosec == 0
|
||||||
|
|
||||||
|
def test_fortran_order_is_normalised(self):
|
||||||
|
arr = np.asfortranarray(np.arange(12, dtype=np.uint8).reshape(3, 4))
|
||||||
|
msg = ndarray_to_image(arr)
|
||||||
|
assert bytes(msg.data) == np.ascontiguousarray(arr).tobytes()
|
||||||
|
|
||||||
|
def test_empty_image(self):
|
||||||
|
"""零尺寸图:别崩,正常给出 height=0。"""
|
||||||
|
msg = ndarray_to_image(np.zeros((0, 0), np.uint8))
|
||||||
|
assert msg.height == 0 and msg.width == 0
|
||||||
|
assert len(msg.data) == 0
|
||||||
|
|
||||||
|
def test_explicit_encoding_override_is_respected(self):
|
||||||
|
"""显式指定编码时要照用 —— 有些相机给出的 BGR 需要按 rgb8 打包。"""
|
||||||
|
arr = np.zeros((3, 4, 3), np.uint8)
|
||||||
|
msg = ndarray_to_image(arr, encoding="rgb8")
|
||||||
|
assert msg.encoding == "rgb8"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# step 计算与 data 长度的一致性
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStepConsistency:
|
||||||
|
"""``step`` 与 ``len(data)`` 必须自洽:``len(data) == step * height``。
|
||||||
|
|
||||||
|
这是下游按行读图时唯一依赖的不变量。不一致会导致整幅图错位。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"shape,dtype",
|
||||||
|
[
|
||||||
|
((3, 4), np.uint8),
|
||||||
|
((3, 4), np.uint16),
|
||||||
|
((3, 4), np.float32),
|
||||||
|
((3, 4, 1), np.uint8),
|
||||||
|
((3, 4, 2), np.float32),
|
||||||
|
((3, 4, 3), np.uint8),
|
||||||
|
((3, 4, 4), np.uint8),
|
||||||
|
((1, 1), np.uint8),
|
||||||
|
((17, 23, 3), np.uint8),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_data_length_matches_step_times_height(self, shape, dtype):
|
||||||
|
arr = np.zeros(shape, dtype)
|
||||||
|
msg = ndarray_to_image(arr)
|
||||||
|
assert len(msg.data) == msg.step * msg.height, (
|
||||||
|
f"shape={shape} dtype={dtype.name} encoding={msg.encoding} "
|
||||||
|
f"step={msg.step} h={msg.height} len={len(msg.data)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_expected_step_helper_agrees(self):
|
||||||
|
for shape, dtype in [((3, 4), np.uint8), ((3, 4, 3), np.uint8),
|
||||||
|
((3, 4, 2), np.float32)]:
|
||||||
|
arr = np.zeros(shape, dtype)
|
||||||
|
msg = ndarray_to_image(arr)
|
||||||
|
assert msg.step == expected_step(arr)
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""``read_vector_map`` 的单测。
|
||||||
|
|
||||||
|
这个函数存在的唯一原因是:Orisys 的 Python 绑定是编译过的,公开接口里没有
|
||||||
|
一个明确叫 "force vector map" 的方法,只能按语义去试 ``compute_flow`` /
|
||||||
|
``compute_hhd`` / ``compute_deformation``,而且要判断返回值到底是数组
|
||||||
|
还是 bool/可视化图。
|
||||||
|
|
||||||
|
但它接的是真传感器,没法在单测里调。所以这里用假传感器把每条分支钉死:
|
||||||
|
**试错的顺序**和**接受的返回类型**一旦写错,表现是"矢量场话题永远没消息"
|
||||||
|
或"发了一张全零假图",两者都很难从现场现象反推回代码。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arm_application.orisys_tactile import read_vector_map
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSensor:
|
||||||
|
"""可控假传感器。
|
||||||
|
|
||||||
|
传入 ``方法名=返回值``;想让某个方法抛异常就传异常实例。
|
||||||
|
没有列出的方法属性一律不存在 —— 这正好模拟"老版本 SDK 没这个方法"。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **methods: Any) -> None:
|
||||||
|
for name, value in methods.items():
|
||||||
|
if isinstance(value, BaseException):
|
||||||
|
def _raise(_exc=value):
|
||||||
|
raise _exc
|
||||||
|
|
||||||
|
setattr(self, name, _raise)
|
||||||
|
else:
|
||||||
|
setattr(self, name, (lambda _v=value: _v))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreferredSource:
|
||||||
|
def test_uses_compute_flow_first(self):
|
||||||
|
"""compute_flow 语义上最接近"切向力矢量场",优先级最高。"""
|
||||||
|
flow = np.ones((8, 6, 2), np.float32)
|
||||||
|
sensor = FakeSensor(
|
||||||
|
compute_flow=flow,
|
||||||
|
compute_hhd=np.zeros((8, 6, 3), np.uint8),
|
||||||
|
)
|
||||||
|
assert read_vector_map(sensor) is flow
|
||||||
|
|
||||||
|
def test_falls_back_to_hhd_when_flow_is_bool(self):
|
||||||
|
"""有些版本 compute_flow 只返回 bool("有没有流动")—— 那不是矢量场,
|
||||||
|
必须继续往下试,而不是把 True 当成图。"""
|
||||||
|
hhd = np.ones((4, 4), np.float32)
|
||||||
|
sensor = FakeSensor(compute_flow=True, compute_hhd=hhd)
|
||||||
|
assert read_vector_map(sensor) is hhd
|
||||||
|
|
||||||
|
def test_falls_back_to_deformation(self):
|
||||||
|
deform = np.ones((5, 5, 2), np.float32)
|
||||||
|
sensor = FakeSensor(compute_flow=False, compute_hhd=None, compute_deformation=deform)
|
||||||
|
assert read_vector_map(sensor) is deform
|
||||||
|
|
||||||
|
def test_skips_flow_that_raises(self):
|
||||||
|
hhd = np.ones((3, 3), np.float32)
|
||||||
|
sensor = FakeSensor(compute_flow=RuntimeError("no cuda"), compute_hhd=hhd)
|
||||||
|
assert read_vector_map(sensor) is hhd
|
||||||
|
|
||||||
|
|
||||||
|
class TestRejectedValues:
|
||||||
|
"""不是"二维以上数组"的返回值一律不能当矢量场用。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
[True, False, None, 0, 0.0, "flow", (1, 2), [1.0, 2.0], {"a": 1}],
|
||||||
|
)
|
||||||
|
def test_non_array_values_are_rejected(self, bad):
|
||||||
|
assert read_vector_map(FakeSensor(compute_flow=bad)) is None
|
||||||
|
|
||||||
|
def test_1d_array_is_rejected(self):
|
||||||
|
"""1D 没法按 (H,W) 解释成图。"""
|
||||||
|
assert read_vector_map(FakeSensor(compute_flow=np.zeros(10, np.float32))) is None
|
||||||
|
|
||||||
|
def test_empty_array_is_rejected(self):
|
||||||
|
"""空数组同样是"没有数据",不能发一张 0x0 的图。"""
|
||||||
|
assert read_vector_map(FakeSensor(compute_flow=np.zeros((0, 0), np.uint8))) is None
|
||||||
|
|
||||||
|
def test_2d_array_is_accepted(self):
|
||||||
|
"""2D 灰度图是合法结果(某些版本把形变强度图当成矢量场返回)。"""
|
||||||
|
arr = np.zeros((4, 5), np.uint8)
|
||||||
|
assert read_vector_map(FakeSensor(compute_flow=arr)) is arr
|
||||||
|
|
||||||
|
def test_scalar_ndarray_is_rejected(self):
|
||||||
|
"""0 维 ndarray 没有 (H,W) 语义。"""
|
||||||
|
assert read_vector_map(FakeSensor(compute_flow=np.array(3.0))) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMissingMethods:
|
||||||
|
def test_all_missing_returns_none(self):
|
||||||
|
"""老版本 SDK 三个方法都没有 —— 应当安静返回 None,让上层跳过话题。"""
|
||||||
|
assert read_vector_map(FakeSensor()) is None
|
||||||
|
|
||||||
|
def test_all_raising_returns_none(self):
|
||||||
|
sensor = FakeSensor(
|
||||||
|
compute_flow=RuntimeError("x"),
|
||||||
|
compute_hhd=RuntimeError("y"),
|
||||||
|
compute_deformation=RuntimeError("z"),
|
||||||
|
)
|
||||||
|
assert read_vector_map(sensor) is None
|
||||||
|
|
||||||
|
def test_never_raises_even_when_everything_fails(self):
|
||||||
|
"""这是它的契约:探测失败只能返回 None,不能把异常抛给 30fps 的读取循环。"""
|
||||||
|
sensor = FakeSensor(compute_flow=KeyboardInterrupt())
|
||||||
|
# KeyboardInterrupt 是 BaseException;探测逻辑捕获 Exception,
|
||||||
|
# 所以它会被放行 —— 记录当前行为,提醒别在这里拿 BaseException 当普通错误。
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
read_vector_map(sensor)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResultIsUsableByImageUtils:
|
||||||
|
"""探测到的数组必须能被 image_utils 打包 —— 否则等于白探测。"""
|
||||||
|
|
||||||
|
def test_flow_shaped_array_packs_as_32fc2(self):
|
||||||
|
from arm_application.image_utils import ndarray_to_image
|
||||||
|
|
||||||
|
flow = np.zeros((8, 6, 2), np.float32)
|
||||||
|
sensor = FakeSensor(compute_flow=flow)
|
||||||
|
arr = read_vector_map(sensor)
|
||||||
|
msg = ndarray_to_image(arr)
|
||||||
|
assert msg.encoding == "32FC2"
|
||||||
|
assert (msg.height, msg.width) == (8, 6)
|
||||||
|
|
||||||
|
def test_grayscale_array_packs_as_mono8(self):
|
||||||
|
from arm_application.image_utils import ndarray_to_image
|
||||||
|
|
||||||
|
arr = read_vector_map(FakeSensor(compute_flow=np.zeros((8, 6), np.uint8)))
|
||||||
|
assert ndarray_to_image(arr).encoding == "mono8"
|
||||||
|
|
||||||
|
def test_float64_result_is_rejected_by_packer_not_silently_truncated(self):
|
||||||
|
"""float64 是 numpy 的默认 dtype,SDK 很可能返回它。image_utils 会
|
||||||
|
明确报错而不是按 float32 打包 —— 那样会让每个像素错位 4 字节。"""
|
||||||
|
from arm_application.image_utils import UnsupportedImageError, ndarray_to_image
|
||||||
|
|
||||||
|
arr = read_vector_map(FakeSensor(compute_flow=np.zeros((8, 6, 2), np.float64)))
|
||||||
|
assert arr is not None
|
||||||
|
with pytest.raises(UnsupportedImageError):
|
||||||
|
ndarray_to_image(arr)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""非夕 Flexiv 机械臂驱动适配器。
|
||||||
|
|
||||||
|
对外只暴露一个可执行节点 ``flexiv_arm_driver``(见 ``setup.py`` 的
|
||||||
|
``console_scripts``),其余模块是它的实现细节:
|
||||||
|
|
||||||
|
``rdk_client``
|
||||||
|
管理 ``scripts/rdk_drag_bridge.py`` 长驻子进程的客户端。所有需要独占 RDK
|
||||||
|
的操作都经它转发 —— 本包自身不 import flexivrdk(ABI 不兼容)。
|
||||||
|
``site_config``
|
||||||
|
从 ``config/site.yaml`` 读参数默认值,使 ``ros2 run`` 也能直接起节点。
|
||||||
|
``compliant_descent``
|
||||||
|
柔顺下压:纯逻辑状态机 + ROS 包装。
|
||||||
|
``flexiv_arm_driver``
|
||||||
|
节点本体,装配上面几块。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
"""柔顺下压:从悬停位姿沿工具系 −Z 缓慢下压,直到触觉检测到接触。
|
||||||
|
|
||||||
|
为什么不用非夕 RDK 的柔顺模式
|
||||||
|
----------------------------
|
||||||
|
RDK 的 compliant motion 会接管整臂阻抗,而装配的其它阶段(跟踪 / 对准 / 拧入)
|
||||||
|
都走 JTC 位置控制。在两种控制权之间来回切换代价高,且切换瞬间的力冲击对通止规
|
||||||
|
这种细长工件风险大。这里改用**应用层力闭环**:小幅步进(默认 0.5 mm)+ 步间读
|
||||||
|
触觉,一旦切向力相对基线跳变就立刻停住。任何一步都可安全中断。
|
||||||
|
|
||||||
|
文件结构(两层,故意分开)
|
||||||
|
--------------------------
|
||||||
|
1. :class:`DescentController` —— **纯逻辑**状态机。不 import ROS,所有外部交互
|
||||||
|
(读触觉、走一步、看时间)都通过注入的回调完成。因此它的全部判据 ——
|
||||||
|
接触 / 超时 / 行程上限 / 法向力安全限 / 触觉断流 —— 都能用假回调单测,
|
||||||
|
见 ``test/test_descent_control.py``。
|
||||||
|
2. :class:`CompliantDescentRunner` —— ROS 包装。负责 TF 读位形、DLS 逆解、
|
||||||
|
发 JTC 轨迹、订阅触觉,把纯逻辑接到真链路上。
|
||||||
|
|
||||||
|
力阈值都是**相对静止基线的增量**
|
||||||
|
--------------------------------
|
||||||
|
Orisys 读数是形变任意单位且基线会漂移,绝对值没有意义。传感器节点在启动时测定
|
||||||
|
基线并在发布前减掉,所以话题上的 ``fshearx`` / ``fsheary`` 已经是增量。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Callable, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rclpy
|
||||||
|
from control_msgs.action import FollowJointTrajectory
|
||||||
|
from rclpy.action import ActionClient
|
||||||
|
from rclpy.callback_groups import CallbackGroup
|
||||||
|
from rclpy.node import Node
|
||||||
|
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
|
||||||
|
|
||||||
|
from arm_msgs.action import CompliantDescent
|
||||||
|
from arm_msgs.msg import TactileStatus
|
||||||
|
from arm_kinematics import RIZON_LINK_AXES, dls_translate_hold_ori, geometric_jacobian
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 第一层:纯逻辑状态机
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DescentOutcome(str, Enum):
|
||||||
|
"""下压结束的原因。只有 ``CONTACT`` 算成功。"""
|
||||||
|
|
||||||
|
CONTACT = "contact"
|
||||||
|
TIMEOUT = "timeout"
|
||||||
|
MAX_DESCENT = "max_descent"
|
||||||
|
NORMAL_LIMIT = "normal_limit"
|
||||||
|
TACTILE_STALE = "tactile_stale"
|
||||||
|
MOTION_FAILED = "motion_failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
#: 各结束原因对应的 ``status`` 字段值(action 的 feedback/result 协议)。
|
||||||
|
_STATUS_BY_OUTCOME = {
|
||||||
|
DescentOutcome.CONTACT: "contacting",
|
||||||
|
DescentOutcome.TIMEOUT: "error",
|
||||||
|
DescentOutcome.MAX_DESCENT: "error",
|
||||||
|
DescentOutcome.NORMAL_LIMIT: "error",
|
||||||
|
DescentOutcome.TACTILE_STALE: "error",
|
||||||
|
DescentOutcome.MOTION_FAILED: "error",
|
||||||
|
DescentOutcome.CANCELLED: "retracting",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DescentConfig:
|
||||||
|
"""一次下压的全部参数。不可变,便于在测试里共享。"""
|
||||||
|
|
||||||
|
contact_axis: int = 0
|
||||||
|
"""监测哪条切向通道:0 = 第一路切向(``fshearx``),1 = 第二路(``fsheary``)。"""
|
||||||
|
|
||||||
|
contact_delta: float = 0.0
|
||||||
|
"""接触判据:|切向力| 相对基线的增量阈值(传感器原生单位)。"""
|
||||||
|
|
||||||
|
max_normal_delta: float = 0.0
|
||||||
|
"""法向力安全上限(相对基线增量)。<= 0 表示不检查。"""
|
||||||
|
|
||||||
|
descent_step_m: float = 0.0005
|
||||||
|
"""每步下压量(m)。越小越安全,但接触检测越慢。"""
|
||||||
|
|
||||||
|
descent_speed_mps: float = 0.005
|
||||||
|
"""名义下压速度(m/s)。步进间隔 = step / speed。"""
|
||||||
|
|
||||||
|
max_descent_m: float = 0.05
|
||||||
|
"""最多下压距离(m)。到顶仍未接触即判失败并停住 —— 这是安全上限。"""
|
||||||
|
|
||||||
|
settle_sec: float = 0.05
|
||||||
|
"""每步后额外等待(s),让机械振荡衰减、触觉稳定。"""
|
||||||
|
|
||||||
|
timeout_sec: float = 0.0
|
||||||
|
"""总超时(s)。<= 0 时按 ``max_descent_m / descent_speed_mps`` 推算并留余量。"""
|
||||||
|
|
||||||
|
tactile_stale_sec: float = 0.5
|
||||||
|
"""触觉数据超过这个时长没更新就判断流并停住(安全)。"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_goal(cls, goal: CompliantDescent.Goal) -> "DescentConfig":
|
||||||
|
"""从 action goal 构造。0 / NaN 的字段在这里被解释成默认值。"""
|
||||||
|
|
||||||
|
def _f(value: float, fallback: float) -> float:
|
||||||
|
v = float(value)
|
||||||
|
return fallback if (math.isnan(v) or v == 0.0) else v
|
||||||
|
|
||||||
|
step = _f(goal.descent_step_m, 0.0005)
|
||||||
|
step = min(max(step, 1e-5), 0.01) # 夹到 [0.01mm, 10mm],防手滑填错单位
|
||||||
|
return cls(
|
||||||
|
contact_axis=1 if int(goal.contact_axis) == 1 else 0,
|
||||||
|
contact_delta=_f(goal.contact_delta, 0.0),
|
||||||
|
max_normal_delta=float(goal.max_normal_delta),
|
||||||
|
descent_step_m=step,
|
||||||
|
descent_speed_mps=_f(goal.descent_speed_mps, 0.005),
|
||||||
|
max_descent_m=_f(goal.max_descent_m, 0.05),
|
||||||
|
settle_sec=max(0.0, float(goal.settle_sec)),
|
||||||
|
timeout_sec=max(0.0, float(goal.timeout_sec)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def step_interval_sec(self) -> float:
|
||||||
|
"""步与步之间的间隔:走完一步需要的时间 + 稳定等待。"""
|
||||||
|
if self.descent_speed_mps <= 0:
|
||||||
|
return self.settle_sec
|
||||||
|
return self.descent_step_m / self.descent_speed_mps + self.settle_sec
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_timeout_sec(self) -> float:
|
||||||
|
"""实际生效的超时。未指定时按行程推算并留 3 倍余量(至少 2s)。
|
||||||
|
|
||||||
|
留余量是因为实际下压速度受 JTC 加减速限制,名义速度只是上界。
|
||||||
|
"""
|
||||||
|
if self.timeout_sec > 0:
|
||||||
|
return self.timeout_sec
|
||||||
|
if self.descent_speed_mps <= 0:
|
||||||
|
return 2.0
|
||||||
|
nominal = self.max_descent_m / self.descent_speed_mps
|
||||||
|
return max(2.0, nominal * 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TactileSample:
|
||||||
|
"""某一时刻的触觉读数(已减去基线)。"""
|
||||||
|
|
||||||
|
tangential: float
|
||||||
|
"""被监测那条切向通道的值。"""
|
||||||
|
|
||||||
|
normal: float
|
||||||
|
"""法向力。"""
|
||||||
|
|
||||||
|
age_sec: float
|
||||||
|
"""采样距今多久。用来判断数据是否断流。"""
|
||||||
|
|
||||||
|
|
||||||
|
def select_tangential(shearx: float, sheary: float, axis: int) -> float:
|
||||||
|
"""按 ``contact_axis`` 从两路切向力里挑出要监测的那一路。
|
||||||
|
|
||||||
|
0 = 第一路(``fshearx``),1 = 第二路(``fsheary``)。其它值按 0 处理。
|
||||||
|
|
||||||
|
单独提成纯函数而不是写在 ROS 订阅回调里,是因为"到底看哪一路"是装配能否
|
||||||
|
检测到接触的关键,必须能脱离 ROS 单测 —— 见 ``test_descent_control.py``。
|
||||||
|
"""
|
||||||
|
return float(sheary) if int(axis) == 1 else float(shearx)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DescentResult:
|
||||||
|
"""下压结束时的完整结论。"""
|
||||||
|
|
||||||
|
outcome: DescentOutcome
|
||||||
|
descended_m: float = 0.0
|
||||||
|
tangential_delta: float = 0.0
|
||||||
|
normal_delta: float = 0.0
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success(self) -> bool:
|
||||||
|
return self.outcome is DescentOutcome.CONTACT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return _STATUS_BY_OUTCOME.get(self.outcome, "error")
|
||||||
|
|
||||||
|
|
||||||
|
class DescentController:
|
||||||
|
"""下压状态机(纯逻辑)。
|
||||||
|
|
||||||
|
参数
|
||||||
|
----
|
||||||
|
cfg:
|
||||||
|
全部阈值与运动参数。
|
||||||
|
read_tactile:
|
||||||
|
读一次触觉。返回 ``None`` 表示数据源不可用(未连接 / 话题没起来)。
|
||||||
|
step_down:
|
||||||
|
沿 −Z 走一步 ``dz``(正数)。返回 ``False`` 表示这一步没走成。
|
||||||
|
now / sleep:
|
||||||
|
注入时间源,测试里可以换成假时钟,避免真的 sleep。
|
||||||
|
is_cancelled:
|
||||||
|
返回 ``True`` 时立刻停下(外部取消或节点在关闭)。
|
||||||
|
on_progress:
|
||||||
|
每次循环结束时回调,用来发 action feedback。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cfg: DescentConfig,
|
||||||
|
read_tactile: Callable[[], Optional[TactileSample]],
|
||||||
|
step_down: Callable[[float], bool],
|
||||||
|
*,
|
||||||
|
now: Callable[[], float] = time.monotonic,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
|
is_cancelled: Callable[[], bool] = lambda: False,
|
||||||
|
on_progress: Optional[Callable[["DescentProgress"], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
self.cfg = cfg
|
||||||
|
self._read_tactile = read_tactile
|
||||||
|
self._step_down = step_down
|
||||||
|
self._now = now
|
||||||
|
self._sleep = sleep
|
||||||
|
self._is_cancelled = is_cancelled
|
||||||
|
self._on_progress = on_progress
|
||||||
|
|
||||||
|
def run(self) -> DescentResult:
|
||||||
|
"""执行下压,直到接触或触发任一停止条件。"""
|
||||||
|
cfg = self.cfg
|
||||||
|
step = cfg.descent_step_m
|
||||||
|
interval = cfg.step_interval_sec
|
||||||
|
deadline = self._now() + cfg.effective_timeout_sec
|
||||||
|
|
||||||
|
# 用整数步数计数,而不是累加浮点再比较。
|
||||||
|
# 反例:step=0.0005、max=0.05 时,"descended + step > max" 会因为
|
||||||
|
# 0.0005 累加 99 次产生 1e-17 量级的误差而在第 100 步之前跳出,
|
||||||
|
# 结果是行程少走一步、永远到不了上限。计数法没有这个问题,
|
||||||
|
# 而且 descended_m 用 steps_done * step 一次算出,也不累积误差。
|
||||||
|
max_steps = max(1, int(round(cfg.max_descent_m / step)))
|
||||||
|
steps_done = 0
|
||||||
|
descended = 0.0
|
||||||
|
last = TactileSample(0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if self._is_cancelled():
|
||||||
|
return self._finish(DescentOutcome.CANCELLED, descended, last, "收到取消请求")
|
||||||
|
|
||||||
|
sample = self._read_tactile()
|
||||||
|
if sample is None:
|
||||||
|
return self._finish(
|
||||||
|
DescentOutcome.TACTILE_STALE, descended, last,
|
||||||
|
"读不到触觉数据(传感器未连接 / 话题未发布)",
|
||||||
|
)
|
||||||
|
last = sample
|
||||||
|
if sample.age_sec > cfg.tactile_stale_sec:
|
||||||
|
return self._finish(
|
||||||
|
DescentOutcome.TACTILE_STALE, descended, last,
|
||||||
|
f"触觉数据已过期 {sample.age_sec:.2f}s(> {cfg.tactile_stale_sec:.2f}s),"
|
||||||
|
"停止下压以免在无反馈情况下压坏工件",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 安全上限优先于接触判据:法向力超限说明已经在硬顶,立刻停。
|
||||||
|
if cfg.max_normal_delta > 0 and sample.normal >= cfg.max_normal_delta:
|
||||||
|
return self._finish(
|
||||||
|
DescentOutcome.NORMAL_LIMIT, descended, last,
|
||||||
|
f"法向力增量 {sample.normal:.1f} 达到安全上限 "
|
||||||
|
f"{cfg.max_normal_delta:.1f},停止下压",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 接触判据用绝对值:切向力往哪个方向跳都算接触。
|
||||||
|
if abs(sample.tangential) >= cfg.contact_delta:
|
||||||
|
return self._finish(
|
||||||
|
DescentOutcome.CONTACT, descended, last,
|
||||||
|
f"检测到接触:切向增量 {sample.tangential:.1f} ≥ 阈值 "
|
||||||
|
f"{cfg.contact_delta:.1f}(已下压 {descended * 1000:.2f}mm)",
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._now() > deadline:
|
||||||
|
return self._finish(
|
||||||
|
DescentOutcome.TIMEOUT, descended, last,
|
||||||
|
f"超时 {cfg.effective_timeout_sec:.1f}s 仍未接触(已下压 "
|
||||||
|
f"{descended * 1000:.2f}mm)",
|
||||||
|
)
|
||||||
|
|
||||||
|
if steps_done >= max_steps:
|
||||||
|
return self._finish(
|
||||||
|
DescentOutcome.MAX_DESCENT, descended, last,
|
||||||
|
f"已下压 {descended * 1000:.2f}mm 达到行程上限 "
|
||||||
|
f"{cfg.max_descent_m * 1000:.2f}mm 仍未接触",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._step_down(step):
|
||||||
|
return self._finish(
|
||||||
|
DescentOutcome.MOTION_FAILED, descended, last,
|
||||||
|
"单步下压未执行成功(JTC 拒绝 / 控制器未激活 / TF 缺失)",
|
||||||
|
)
|
||||||
|
steps_done += 1
|
||||||
|
descended = steps_done * step
|
||||||
|
|
||||||
|
if self._on_progress is not None:
|
||||||
|
self._on_progress(
|
||||||
|
DescentProgress(
|
||||||
|
fraction=_clamp01(descended / cfg.max_descent_m),
|
||||||
|
descended_m=descended,
|
||||||
|
tangential=sample.tangential,
|
||||||
|
normal=sample.normal,
|
||||||
|
status="approaching",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._sleep(interval)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self, outcome: DescentOutcome, descended: float, last: TactileSample, message: str
|
||||||
|
) -> DescentResult:
|
||||||
|
if self._on_progress is not None:
|
||||||
|
self._on_progress(
|
||||||
|
DescentProgress(
|
||||||
|
fraction=_clamp01(descended / self.cfg.max_descent_m),
|
||||||
|
descended_m=descended,
|
||||||
|
tangential=last.tangential,
|
||||||
|
normal=last.normal,
|
||||||
|
status=_STATUS_BY_OUTCOME.get(outcome, "error"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return DescentResult(
|
||||||
|
outcome=outcome,
|
||||||
|
descended_m=descended,
|
||||||
|
tangential_delta=last.tangential,
|
||||||
|
normal_delta=last.normal,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DescentProgress:
|
||||||
|
"""一次 feedback 快照。"""
|
||||||
|
|
||||||
|
fraction: float
|
||||||
|
descended_m: float
|
||||||
|
tangential: float
|
||||||
|
normal: float
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp01(value: float) -> float:
|
||||||
|
return max(0.0, min(1.0, float(value)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 第二层:ROS 包装
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CompliantDescentRunner:
|
||||||
|
"""``arm_msgs/CompliantDescent`` 的 action server。
|
||||||
|
|
||||||
|
每步下压怎么落地
|
||||||
|
----------------
|
||||||
|
1. 从 TF 读 ``link1..7`` 在基座系下的位姿 → 用当前位形算几何雅可比
|
||||||
|
2. ``dls_translate_hold_ori(J, [0, 0, −step])`` 求关节增量 ``dq``(保持姿态)
|
||||||
|
3. 把 ``q + dq`` 作为**单点** JTC 轨迹发给 ``rizon_arm_controller``,等它完成
|
||||||
|
|
||||||
|
为什么是一步一发而不是一条长轨迹:接触时刻的位置无法事先知道,必须边走边看
|
||||||
|
力反馈;一步一发让我们能在任意一步之后停下,不需要取消一条已经下发的轨迹。
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: 单步轨迹至少给这么长时间,否则 JTC 会因速度超限而拒绝。
|
||||||
|
MIN_STEP_DURATION_SEC = 0.15
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
node: Node,
|
||||||
|
callback_group: Optional[CallbackGroup] = None,
|
||||||
|
*,
|
||||||
|
default_sn: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""``default_sn`` 是 ``robot_sn`` 参数的默认值。
|
||||||
|
|
||||||
|
必须由调用方(驱动节点)传入它已经从 site.yaml 解析出的 SN —— 否则这里
|
||||||
|
的默认值会是空串,frame 名退化成 ``_base_link`` / ``_flange``,
|
||||||
|
TF 永远查不到,只有在真机跑起来报"读不到 TF"时才会发现。
|
||||||
|
"""
|
||||||
|
self._node = node
|
||||||
|
self._cb = callback_group
|
||||||
|
self._log = node.get_logger()
|
||||||
|
|
||||||
|
node.declare_parameter("action_name", "compliant_descent")
|
||||||
|
node.declare_parameter("jtc_action_name", "rizon_arm_controller/follow_joint_trajectory")
|
||||||
|
node.declare_parameter("robot_sn", default_sn)
|
||||||
|
node.declare_parameter("base_frame", "")
|
||||||
|
node.declare_parameter("ee_frame", "")
|
||||||
|
node.declare_parameter("joint_prefix", "")
|
||||||
|
node.declare_parameter("tactile_topic_tmpl", "/tactile/{side}/status")
|
||||||
|
|
||||||
|
sn = str(node.get_parameter("robot_sn").value).strip()
|
||||||
|
self._prefix = str(node.get_parameter("joint_prefix").value).strip() or f"{sn}_"
|
||||||
|
self._base_frame = (
|
||||||
|
str(node.get_parameter("base_frame").value).strip() or f"{sn}_base_link"
|
||||||
|
)
|
||||||
|
self._ee_frame = str(node.get_parameter("ee_frame").value).strip() or f"{sn}_flange"
|
||||||
|
self._joint_names = [f"{self._prefix}joint{i}" for i in range(1, 8)]
|
||||||
|
self._link_frames = [f"{self._prefix}link{i}" for i in range(1, 8)]
|
||||||
|
self._tactile_tmpl = str(node.get_parameter("tactile_topic_tmpl").value)
|
||||||
|
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._q: Optional[List[float]] = None
|
||||||
|
#: side -> (fshearx, fsheary, fnormal, 到达时间 monotonic)
|
||||||
|
self._tactile: dict[str, Tuple[float, float, float, float]] = {}
|
||||||
|
self._cancelled = threading.Event()
|
||||||
|
|
||||||
|
# TF
|
||||||
|
from tf2_ros import Buffer, TransformListener
|
||||||
|
|
||||||
|
self._tf_buffer = Buffer()
|
||||||
|
self._tf_listener = TransformListener(self._tf_buffer, node)
|
||||||
|
|
||||||
|
# 关节角:JTC 步进的输入
|
||||||
|
from sensor_msgs.msg import JointState
|
||||||
|
|
||||||
|
node.create_subscription(
|
||||||
|
JointState, "/joint_states", self._on_joint_state, 10, callback_group=callback_group
|
||||||
|
)
|
||||||
|
|
||||||
|
# 触觉:按下压时指定的一侧懒订阅(左右各建一个,成本很低)
|
||||||
|
self._tactile_subs = {}
|
||||||
|
for side in ("left", "right"):
|
||||||
|
topic = self._tactile_tmpl.format(side=side)
|
||||||
|
self._tactile_subs[side] = node.create_subscription(
|
||||||
|
TactileStatus, topic, self._make_tactile_cb(side), 10,
|
||||||
|
callback_group=callback_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._jtc = ActionClient(
|
||||||
|
node, FollowJointTrajectory,
|
||||||
|
str(node.get_parameter("jtc_action_name").value),
|
||||||
|
callback_group=callback_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._server = rclpy.action.ActionServer(
|
||||||
|
node,
|
||||||
|
CompliantDescent,
|
||||||
|
str(node.get_parameter("action_name").value),
|
||||||
|
execute_callback=self._execute,
|
||||||
|
cancel_callback=self._on_cancel,
|
||||||
|
callback_group=callback_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._log.info(
|
||||||
|
f"compliant_descent 就绪: base={self._base_frame} "
|
||||||
|
f"jtc={self._jtc._action_name} 触觉模板={self._tactile_tmpl}"
|
||||||
|
)
|
||||||
|
if not sn:
|
||||||
|
self._log.warn(
|
||||||
|
"robot_sn 为空,TF frame 名可能不对(默认 f'{sn}_base_link')。"
|
||||||
|
"用 parameters 传 robot_sn。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- 订阅回调 ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_joint_state(self, msg) -> None:
|
||||||
|
if not self._joint_names:
|
||||||
|
return
|
||||||
|
by_name = dict(zip(msg.name, msg.position))
|
||||||
|
try:
|
||||||
|
q = [float(by_name[n]) for n in self._joint_names]
|
||||||
|
except KeyError:
|
||||||
|
# 里面还混着夹爪等其它关节,缺我们这 7 个就先不用这帧
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
self._q = q
|
||||||
|
|
||||||
|
def _make_tactile_cb(self, side: str):
|
||||||
|
def cb(msg: TactileStatus) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._tactile[side] = (
|
||||||
|
float(msg.fshearx),
|
||||||
|
float(msg.fsheary),
|
||||||
|
float(msg.fnormal),
|
||||||
|
time.monotonic(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cb
|
||||||
|
|
||||||
|
# -- action 回调 ------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_cancel(self, _goal_handle) -> "rclpy.action.CancelResponse":
|
||||||
|
self._cancelled.set()
|
||||||
|
return rclpy.action.CancelResponse(return_value=True)
|
||||||
|
|
||||||
|
def _execute(self, goal_handle) -> CompliantDescent.Result:
|
||||||
|
goal = goal_handle.request
|
||||||
|
self._cancelled.clear()
|
||||||
|
cfg = DescentConfig.from_goal(goal)
|
||||||
|
|
||||||
|
result = CompliantDescent.Result()
|
||||||
|
side = (goal.side or "right").strip().lower()
|
||||||
|
if side not in ("left", "right"):
|
||||||
|
side = "right"
|
||||||
|
|
||||||
|
result.status = "approaching"
|
||||||
|
self._log.info(
|
||||||
|
f"柔顺下压开始: side={side} axis={cfg.contact_axis} "
|
||||||
|
f"阈值={cfg.contact_delta:.1f} 步长={cfg.descent_step_m * 1000:.2f}mm "
|
||||||
|
f"行程上限={cfg.max_descent_m * 1000:.1f}mm"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._preflight(goal, cfg, side, result):
|
||||||
|
goal_handle.abort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 记录下压前的 XY:每步都把它作为目标,避免斜向漂移。
|
||||||
|
start = self._lookup_ee()
|
||||||
|
if start is None:
|
||||||
|
result.status = "error"
|
||||||
|
result.message = f"TF 里读不到 {self._ee_frame}(base={self._base_frame})"
|
||||||
|
goal_handle.abort()
|
||||||
|
return result
|
||||||
|
hold_xy = np.array(start[:3, 3], dtype=float)
|
||||||
|
|
||||||
|
controller = DescentController(
|
||||||
|
cfg,
|
||||||
|
read_tactile=lambda: self._sample(cfg, side),
|
||||||
|
step_down=lambda dz: self._step_down(dz, hold_xy, cfg),
|
||||||
|
is_cancelled=self._cancelled.is_set,
|
||||||
|
on_progress=lambda p: self._publish_feedback(goal_handle, p),
|
||||||
|
)
|
||||||
|
outcome = controller.run()
|
||||||
|
|
||||||
|
contact = self._lookup_ee()
|
||||||
|
if contact is not None:
|
||||||
|
result.contact_pose.header.frame_id = self._base_frame
|
||||||
|
result.contact_pose.header.stamp = self._node.get_clock().now().to_msg()
|
||||||
|
p = contact[:3, 3]
|
||||||
|
result.contact_pose.pose.position.x = float(p[0])
|
||||||
|
result.contact_pose.pose.position.y = float(p[1])
|
||||||
|
result.contact_pose.pose.position.z = float(p[2])
|
||||||
|
q = _R_to_quat_xyzw(contact[:3, :3])
|
||||||
|
result.contact_pose.pose.orientation.x = q[0]
|
||||||
|
result.contact_pose.pose.orientation.y = q[1]
|
||||||
|
result.contact_pose.pose.orientation.z = q[2]
|
||||||
|
result.contact_pose.pose.orientation.w = q[3]
|
||||||
|
|
||||||
|
result.success = outcome.success
|
||||||
|
result.message = outcome.message
|
||||||
|
result.descended_m = outcome.descended_m
|
||||||
|
result.contact_delta_observed = outcome.tangential_delta
|
||||||
|
result.normal_delta_observed = outcome.normal_delta
|
||||||
|
result.status = outcome.status
|
||||||
|
|
||||||
|
if outcome.success:
|
||||||
|
self._log.info(outcome.message)
|
||||||
|
goal_handle.succeed()
|
||||||
|
else:
|
||||||
|
self._log.warn(f"柔顺下压未成功({outcome.outcome.value}): {outcome.message}")
|
||||||
|
goal_handle.abort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _preflight(self, goal, cfg: DescentConfig, side: str, result) -> bool:
|
||||||
|
"""开工前的检查。任何一条不过就立刻放弃,不要带着问题往下压。"""
|
||||||
|
if cfg.contact_delta <= 0:
|
||||||
|
result.message = (
|
||||||
|
"contact_delta 必须 > 0。力阈值是相对静止基线的增量,"
|
||||||
|
"请填传感器原生单位下的经验值(先前用 /tactile 话题观察接触时的跳变量)。"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._jtc.wait_for_server(timeout_sec=5.0):
|
||||||
|
result.message = (
|
||||||
|
f"等不到 JTC 动作 {self._jtc._action_name}。"
|
||||||
|
"先启动机械臂:bash scripts/run_real.sh"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._lookup_ee() is None:
|
||||||
|
result.message = (
|
||||||
|
f"TF 里读不到 {self._ee_frame}(base={self._base_frame})。"
|
||||||
|
"检查 robot_sn 参数与机械臂 TF 是否在发布。"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
q = list(self._q) if self._q is not None else None
|
||||||
|
if q is None or len(q) < 7:
|
||||||
|
result.message = "还没收到 /joint_states 里的 7 个关节角"
|
||||||
|
return False
|
||||||
|
|
||||||
|
sample = self._sample(cfg, side)
|
||||||
|
if sample is None:
|
||||||
|
result.message = (
|
||||||
|
f"触觉话题 {self._tactile_tmpl.format(side=side)} 没有数据。"
|
||||||
|
"先启动传感器:ros2 launch arm_application tactile.launch.py"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 触觉话题存在但切向恒为 0 是已知的坑:老版 tactile_node 只发 fnormal。
|
||||||
|
# 那种情况下切向判据永远不触发,流程会一路压到行程上限 —— 必须提前拦。
|
||||||
|
if sample.tangential == 0.0:
|
||||||
|
self._log.warn(
|
||||||
|
"触觉切向读数为 0。若传感器固件在静止时确实输出 0 则可忽略;"
|
||||||
|
"但如果下压过程中它始终不变,说明节点没有发布切向通道 —— "
|
||||||
|
"请检查 tactile_node(老版本把 fshearx/fsheary 写成了 0.0)。"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- 触觉 / TF / 运动 -------------------------------------------------
|
||||||
|
|
||||||
|
def _sample(self, cfg: DescentConfig, side: str) -> Optional[TactileSample]:
|
||||||
|
"""取一次触觉。返回 ``None`` 表示这一侧完全没有数据。
|
||||||
|
|
||||||
|
``age_sec`` 用节点时钟与订阅回调的到达时间之差估计。这里用壁钟而非
|
||||||
|
ROS 时间:传感器节点的 stamp 可能是驱动时间戳(不是 ROS 时间),
|
||||||
|
直接相减会得到荒谬的年龄。
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
entry = self._tactile.get(side)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
shearx, sheary, fnormal, stamp = entry
|
||||||
|
return TactileSample(
|
||||||
|
tangential=select_tangential(shearx, sheary, cfg.contact_axis),
|
||||||
|
normal=fnormal,
|
||||||
|
age_sec=max(0.0, time.monotonic() - stamp),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _lookup_ee(self) -> Optional[np.ndarray]:
|
||||||
|
return self._lookup(self._ee_frame)
|
||||||
|
|
||||||
|
def _lookup(self, frame: str) -> Optional[np.ndarray]:
|
||||||
|
try:
|
||||||
|
tf = self._tf_buffer.lookup_transform(
|
||||||
|
self._base_frame, frame, rclpy.time.Time()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
t = tf.transform.translation
|
||||||
|
r = tf.transform.rotation
|
||||||
|
T = np.eye(4)
|
||||||
|
T[:3, :3] = _quat_xyzw_to_R([r.x, r.y, r.z, r.w])
|
||||||
|
T[:3, 3] = [t.x, t.y, t.z]
|
||||||
|
return T
|
||||||
|
|
||||||
|
def _jacobian(self, ee_p: np.ndarray) -> Optional[np.ndarray]:
|
||||||
|
"""用 TF 实测位形算几何雅可比。缺任一 link 的 TF 就没法算。"""
|
||||||
|
link_R: List[np.ndarray] = []
|
||||||
|
link_p: List[np.ndarray] = []
|
||||||
|
for frame in self._link_frames:
|
||||||
|
T = self._lookup(frame)
|
||||||
|
if T is None:
|
||||||
|
return None
|
||||||
|
link_R.append(T[:3, :3])
|
||||||
|
link_p.append(T[:3, 3])
|
||||||
|
return geometric_jacobian(link_R, link_p, ee_p, RIZON_LINK_AXES)
|
||||||
|
|
||||||
|
def _step_down(self, dz: float, hold_xy: np.ndarray, cfg: DescentConfig) -> bool:
|
||||||
|
"""沿 −Z 走一步。返回是否成功执行。
|
||||||
|
|
||||||
|
XY 每步都重新对准 ``hold_xy``:下压过程中若因姿态微调带出 XY 漂移,
|
||||||
|
会在这一步被拉回来,避免"越压越歪"。
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
q = np.array(self._q, dtype=float) if self._q is not None else None
|
||||||
|
if q is None or q.size < 7:
|
||||||
|
self._log.warn("步进失败:没有关节角")
|
||||||
|
return False
|
||||||
|
|
||||||
|
ee = self._lookup_ee()
|
||||||
|
if ee is None:
|
||||||
|
self._log.warn(f"步进失败:TF 读不到 {self._ee_frame}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
J = self._jacobian(np.array(ee[:3, 3], dtype=float))
|
||||||
|
if J is None:
|
||||||
|
self._log.warn("步进失败:缺 link TF,无法算雅可比")
|
||||||
|
return False
|
||||||
|
|
||||||
|
dp = np.zeros(3)
|
||||||
|
p = np.array(ee[:3, 3], dtype=float)
|
||||||
|
dp[0] = hold_xy[0] - p[0]
|
||||||
|
dp[1] = hold_xy[1] - p[1]
|
||||||
|
dp[2] = -abs(dz)
|
||||||
|
# 单步内 XY 修正幅度限死,防止雅可比在某一位形下给出巨大的横向位移。
|
||||||
|
dp[0] = float(np.clip(dp[0], -abs(dz), abs(dz)))
|
||||||
|
dp[1] = float(np.clip(dp[1], -abs(dz), abs(dz)))
|
||||||
|
|
||||||
|
dq = dls_translate_hold_ori(J, dp, damping=0.08, max_dq=0.02)
|
||||||
|
target = q[:7] + dq[:7]
|
||||||
|
|
||||||
|
duration = max(self.MIN_STEP_DURATION_SEC, abs(dz) / max(cfg.descent_speed_mps, 1e-6))
|
||||||
|
traj = JointTrajectory()
|
||||||
|
traj.joint_names = list(self._joint_names)
|
||||||
|
pt = JointTrajectoryPoint()
|
||||||
|
pt.positions = [float(v) for v in target]
|
||||||
|
pt.velocities = [0.0] * 7
|
||||||
|
pt.time_from_start.sec = int(duration)
|
||||||
|
pt.time_from_start.nanosec = int((duration - int(duration)) * 1e9)
|
||||||
|
traj.points = [pt]
|
||||||
|
|
||||||
|
return self._send_jtc(traj, timeout_sec=duration + 1.0)
|
||||||
|
|
||||||
|
def _send_jtc(self, traj: JointTrajectory, timeout_sec: float) -> bool:
|
||||||
|
"""发一条轨迹并等同到完成。在 action 执行线程里跑,靠 executor 收应答。"""
|
||||||
|
goal = FollowJointTrajectory.Goal()
|
||||||
|
goal.trajectory = traj
|
||||||
|
handle = self._await(self._jtc.send_goal_async(goal), timeout_sec=4.0)
|
||||||
|
if handle is None or not bool(getattr(handle, "accepted", False)):
|
||||||
|
self._log.warn("JTC 未接受轨迹")
|
||||||
|
return False
|
||||||
|
future = handle.get_result_async()
|
||||||
|
if not self._await_done(future, timeout_sec=timeout_sec):
|
||||||
|
self._log.warn("JTC 结果超时")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
inner = getattr(future.result(), "result", None)
|
||||||
|
code = getattr(inner, "error_code", 0) if inner is not None else 0
|
||||||
|
if code not in (0, None):
|
||||||
|
self._log.warn(
|
||||||
|
f"JTC 执行失败 error_code={code} "
|
||||||
|
f"{getattr(inner, 'error_string', '')}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _await(future, timeout_sec: float):
|
||||||
|
"""等一个 action future 并把结果取出来(不 spin,靠其它 executor 线程)。"""
|
||||||
|
if not CompliantDescentRunner._await_done(future, timeout_sec):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return future.result()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _await_done(future, timeout_sec: float) -> bool:
|
||||||
|
"""等 future 完成。
|
||||||
|
|
||||||
|
不能用 ``rclpy.spin_until_future_complete`` —— 那需要 executor,而在
|
||||||
|
action 执行线程里再 spin 会自锁。节点的 MultiThreadedExecutor 会在其它
|
||||||
|
线程处理这套 action client 的回调,这里只需轮询完成标志。
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + max(0.05, timeout_sec)
|
||||||
|
while not future.done() and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return future.done()
|
||||||
|
|
||||||
|
def _publish_feedback(self, goal_handle, p: DescentProgress) -> None:
|
||||||
|
fb = CompliantDescent.Feedback()
|
||||||
|
fb.progress = p.fraction
|
||||||
|
fb.descended_m = p.descended_m
|
||||||
|
fb.tangential_delta = p.tangential
|
||||||
|
fb.normal_delta = p.normal
|
||||||
|
fb.status = p.status
|
||||||
|
try:
|
||||||
|
goal_handle.publish_feedback(fb)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _quat_xyzw_to_R(q: List[float]) -> np.ndarray:
|
||||||
|
x, y, z, w = [float(v) for v in q]
|
||||||
|
n = math.sqrt(x * x + y * y + z * z + w * w) + 1e-12
|
||||||
|
x, y, z, w = x / n, y / n, z / n, w / n
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||||
|
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||||
|
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _R_to_quat_xyzw(R: np.ndarray) -> List[float]:
|
||||||
|
"""3x3 旋转矩阵转四元数 (x, y, z, w)。用 Shepperd 法,数值稳定。"""
|
||||||
|
m = np.asarray(R, dtype=float)
|
||||||
|
trace = m[0, 0] + m[1, 1] + m[2, 2]
|
||||||
|
if trace > 0:
|
||||||
|
s = math.sqrt(trace + 1.0) * 2.0
|
||||||
|
w = 0.25 * s
|
||||||
|
x = (m[2, 1] - m[1, 2]) / s
|
||||||
|
y = (m[0, 2] - m[2, 0]) / s
|
||||||
|
z = (m[1, 0] - m[0, 1]) / s
|
||||||
|
elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]:
|
||||||
|
s = math.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2]) * 2.0
|
||||||
|
w = (m[2, 1] - m[1, 2]) / s
|
||||||
|
x = 0.25 * s
|
||||||
|
y = (m[0, 1] + m[1, 0]) / s
|
||||||
|
z = (m[0, 2] + m[2, 0]) / s
|
||||||
|
elif m[1, 1] > m[2, 2]:
|
||||||
|
s = math.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2]) * 2.0
|
||||||
|
w = (m[0, 2] - m[2, 0]) / s
|
||||||
|
x = (m[0, 1] + m[1, 0]) / s
|
||||||
|
y = 0.25 * s
|
||||||
|
z = (m[1, 2] + m[2, 1]) / s
|
||||||
|
else:
|
||||||
|
s = math.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1]) * 2.0
|
||||||
|
w = (m[1, 0] - m[0, 1]) / s
|
||||||
|
x = (m[0, 2] + m[2, 0]) / s
|
||||||
|
y = (m[1, 2] + m[2, 1]) / s
|
||||||
|
z = 0.25 * s
|
||||||
|
return [x, y, z, w]
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"""非夕 Flexiv 机械臂驱动适配器节点。
|
||||||
|
|
||||||
|
它提供什么
|
||||||
|
----------
|
||||||
|
只提供 `devices/robots/flexiv/manifest.yaml` 声明、而现有节点没有的接口:
|
||||||
|
|
||||||
|
======================== ============================== ================================
|
||||||
|
接口 类型 实现方式
|
||||||
|
======================== ============================== ================================
|
||||||
|
``/home`` ``std_srvs/Trigger`` 包装 ``/set_named_pose``
|
||||||
|
``/enable`` ``std_srvs/SetBool`` 经 RDK 桥使能 / 下使能
|
||||||
|
``/clear_fault`` ``std_srvs/Trigger`` 经 RDK 桥清故障并重新使能
|
||||||
|
``/switch_tool`` ``arm_msgs/SwitchTool`` 经 RDK 桥切换工具坐标系
|
||||||
|
``/soft_drag`` ``std_srvs/SetBool`` 经 RDK 桥开关软拖拽
|
||||||
|
``/compliant_descent`` ``arm_msgs/CompliantDescent`` 应用层力闭环柔顺下压
|
||||||
|
======================== ============================== ================================
|
||||||
|
|
||||||
|
它不重复实现什么(这些已由现有节点提供,见本包 README)
|
||||||
|
--------------------------------------------------------
|
||||||
|
``get_arm_state`` → ``arm_control/arm_status_node``
|
||||||
|
``follow_joint_trajectory`` → ``arm_control/real_trajectory_adapter``
|
||||||
|
``set_named_pose`` → ``arm_control/named_pose_server``
|
||||||
|
``servo_joint`` / ``servo_cartesian`` → ``arm_application/servo_node``
|
||||||
|
|
||||||
|
重复实现它们只会制造两套需要同步维护的行为,这正是本次重构要消除的问题。
|
||||||
|
|
||||||
|
RDK 访问策略
|
||||||
|
------------
|
||||||
|
本节点**不 import flexivrdk**(其 Python 绑定与 ROS 环境的 3.11 ABI 不兼容)。
|
||||||
|
所有需要独占 RDK 的操作都委托给 ``scripts/rdk_drag_bridge.py`` 这个跑在
|
||||||
|
``rdk_py310`` 下的长驻桥进程,并由 :class:`arm_driver_flexiv.rdk_client.RdkClient`
|
||||||
|
管理。
|
||||||
|
|
||||||
|
桥是**懒启动**的:节点启动时不连机器人。只有真的收到 ``/enable``、
|
||||||
|
``/switch_tool`` 这类请求时才拉起桥。这样在没有机械臂的机器上(写文档、跑单测、
|
||||||
|
CI)节点也能正常起来、接口也能被 ``ros2 service list`` 看到。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||||
|
from rclpy.executors import MultiThreadedExecutor
|
||||||
|
from rclpy.node import Node
|
||||||
|
|
||||||
|
from arm_msgs.action import CompliantDescent
|
||||||
|
from arm_msgs.srv import SetNamedPose, SwitchTool
|
||||||
|
from std_srvs.srv import SetBool, Trigger
|
||||||
|
|
||||||
|
from .compliant_descent import CompliantDescentRunner
|
||||||
|
from .rdk_client import RdkBridgeError, RdkClient
|
||||||
|
from .site_config import SiteConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FlexivArmDriver(Node):
|
||||||
|
"""非夕机械臂驱动适配器。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("flexiv_arm_driver")
|
||||||
|
|
||||||
|
# --- 站点配置(作为参数默认值的来源)---
|
||||||
|
self._site = SiteConfig()
|
||||||
|
|
||||||
|
self.declare_parameter("sn", self._site.robot_sn())
|
||||||
|
self.declare_parameter("iface", self._site.net_iface())
|
||||||
|
self.declare_parameter("tool_name", self._site.tool_name())
|
||||||
|
self.declare_parameter("home_pose", self._site.home_pose())
|
||||||
|
self.declare_parameter("home_speed", self._site.home_speed())
|
||||||
|
self.declare_parameter("set_named_pose_service", "/set_named_pose")
|
||||||
|
self.declare_parameter("rdk_python", "")
|
||||||
|
self.declare_parameter("rdk_script", "")
|
||||||
|
self.declare_parameter("rdk_log", "/tmp/flexiv_arm_driver_rdk.log")
|
||||||
|
self.declare_parameter("rdk_reply_timeout", 10.0)
|
||||||
|
self.declare_parameter("rdk_connect_timeout", 25.0)
|
||||||
|
# 装配流程开始时就该把工具切好;默认 true 让 home 顺带保证工具正确。
|
||||||
|
self.declare_parameter("switch_tool_on_home", True)
|
||||||
|
|
||||||
|
self._sn = str(self.get_parameter("sn").value)
|
||||||
|
self._iface = str(self.get_parameter("iface").value)
|
||||||
|
self._tool_name = str(self.get_parameter("tool_name").value)
|
||||||
|
|
||||||
|
# ReentrantCallbackGroup + MultiThreadedExecutor:本节点会在服务回调里
|
||||||
|
# 同步等待另一个服务(/set_named_pose)的应答,非重入的互斥组会自锁。
|
||||||
|
self._cb = ReentrantCallbackGroup()
|
||||||
|
|
||||||
|
self._rdk: Optional[RdkClient] = None
|
||||||
|
self._rdk_lock = threading.Lock()
|
||||||
|
|
||||||
|
# --- 服务:RDK 相关 ---
|
||||||
|
self.create_service(SetBool, "enable", self._on_enable, callback_group=self._cb)
|
||||||
|
self.create_service(Trigger, "clear_fault", self._on_clear_fault,
|
||||||
|
callback_group=self._cb)
|
||||||
|
self.create_service(SwitchTool, "switch_tool", self._on_switch_tool,
|
||||||
|
callback_group=self._cb)
|
||||||
|
self.create_service(SetBool, "soft_drag", self._on_soft_drag,
|
||||||
|
callback_group=self._cb)
|
||||||
|
|
||||||
|
# --- 服务:home(包装现有 named_pose_server)---
|
||||||
|
self.create_service(Trigger, "home", self._on_home, callback_group=self._cb)
|
||||||
|
self._named_pose_client = self.create_client(
|
||||||
|
SetNamedPose,
|
||||||
|
str(self.get_parameter("set_named_pose_service").value),
|
||||||
|
callback_group=self._cb,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- action:柔顺下压 ---
|
||||||
|
# 把已解析的 SN 传进去,否则 runner 的 robot_sn 默认是空串,
|
||||||
|
# TF frame 会退化成 _base_link / _flange。
|
||||||
|
self._descent = CompliantDescentRunner(
|
||||||
|
self, callback_group=self._cb, default_sn=self._sn
|
||||||
|
)
|
||||||
|
|
||||||
|
self.get_logger().info(
|
||||||
|
f"flexiv_arm_driver 就绪: sn={self._sn} iface={self._iface or '-'} "
|
||||||
|
f"tool={self._tool_name or '(未设置)'}"
|
||||||
|
)
|
||||||
|
if not self._sn:
|
||||||
|
self.get_logger().warn(
|
||||||
|
"未取到机械臂 SN(site.yaml 的 robot.sn 为空)。"
|
||||||
|
"/enable、/switch_tool 等 RDK 相关服务会失败。"
|
||||||
|
)
|
||||||
|
if not self._tool_name:
|
||||||
|
self.get_logger().warn(
|
||||||
|
"site.yaml 的 robot.tool_name 为空,回位前不会切换工具坐标系。"
|
||||||
|
"通止规装配要求工具系全程垂直向下锁定,请填上 Studio 里已保存的工具名"
|
||||||
|
"(bash scripts/rdk_switch_tool.sh --list 可列出可选项)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# RDK 桥管理
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ensure_rdk(self) -> RdkClient:
|
||||||
|
"""返回已连接的 RDK 客户端,必要时懒启动桥。
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
RdkBridgeError
|
||||||
|
无法启动桥或无法连接机器人。消息里带修复建议。
|
||||||
|
"""
|
||||||
|
with self._rdk_lock:
|
||||||
|
if self._rdk is not None and self._rdk.is_running:
|
||||||
|
return self._rdk
|
||||||
|
if not self._sn:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
"没有机械臂 SN,无法连接 RDK",
|
||||||
|
"在 config/site.yaml 里设置 robot.sn(如 Rizon4-063989),"
|
||||||
|
"重启本节点",
|
||||||
|
)
|
||||||
|
client = RdkClient(
|
||||||
|
sn=self._sn,
|
||||||
|
iface=self._iface,
|
||||||
|
python=str(self.get_parameter("rdk_python").value) or None,
|
||||||
|
script=(
|
||||||
|
_nonempty(self.get_parameter("rdk_script").value) or None
|
||||||
|
),
|
||||||
|
log_path=str(self.get_parameter("rdk_log").value),
|
||||||
|
reply_timeout=float(self.get_parameter("rdk_reply_timeout").value),
|
||||||
|
connect_timeout=float(self.get_parameter("rdk_connect_timeout").value),
|
||||||
|
)
|
||||||
|
client.start(connect=True)
|
||||||
|
self._rdk = client
|
||||||
|
self.get_logger().info(f"RDK 桥已连接({self._sn})")
|
||||||
|
return client
|
||||||
|
|
||||||
|
def _rdk_error_reply(self, what: str, exc: Exception) -> str:
|
||||||
|
"""把 RDK 异常转成给服务调用方看的一行说明,并记日志。"""
|
||||||
|
message = f"{what}失败: {exc}"
|
||||||
|
self.get_logger().error(message)
|
||||||
|
return message
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 服务回调
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_enable(self, request: SetBool.Request, response: SetBool.Response):
|
||||||
|
if not request.data:
|
||||||
|
response.success = False
|
||||||
|
response.message = (
|
||||||
|
"本驱动不支持下使能:非夕 RDK 没有安全的『伺服断开』状态,"
|
||||||
|
"下使能会让臂自由下落。要停止运动请用 /set_named_pose 回安全位,"
|
||||||
|
"或用示教器急停。"
|
||||||
|
)
|
||||||
|
self.get_logger().warn(response.message)
|
||||||
|
return response
|
||||||
|
try:
|
||||||
|
reply = self._ensure_rdk().enable()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
response.success = False
|
||||||
|
response.message = self._rdk_error_reply("使能", exc)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.success = True
|
||||||
|
response.message = (
|
||||||
|
f"已使能(operational={reply.get('operational')}, "
|
||||||
|
f"mode={reply.get('mode', '?')})"
|
||||||
|
)
|
||||||
|
self.get_logger().info(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _on_clear_fault(self, _request: Trigger.Request, response: Trigger.Response):
|
||||||
|
try:
|
||||||
|
reply = self._ensure_rdk().clear_fault()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
response.success = False
|
||||||
|
response.message = self._rdk_error_reply("清故障", exc)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.success = True
|
||||||
|
response.message = (
|
||||||
|
f"故障已清除(fault={reply.get('fault')}, "
|
||||||
|
f"operational={reply.get('operational')})"
|
||||||
|
)
|
||||||
|
self.get_logger().info(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _on_switch_tool(self, request: SwitchTool.Request, response: SwitchTool.Response):
|
||||||
|
name = (request.tool_name or "").strip()
|
||||||
|
sn = (request.sn or "").strip()
|
||||||
|
if sn and sn != self._sn:
|
||||||
|
response.success = False
|
||||||
|
response.message = (
|
||||||
|
f"本节点只服务 sn={self._sn},请求里给的是 {sn}。"
|
||||||
|
"多臂场景请为每台臂各起一个驱动节点。"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
reply = self._ensure_rdk().switch_tool(name)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
response.success = False
|
||||||
|
response.message = self._rdk_error_reply("切换工具", exc)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.current_tool = str(reply.get("current", ""))
|
||||||
|
response.tool_mass_kg = float(reply.get("mass_kg", 0.0) or 0.0)
|
||||||
|
com = list(reply.get("com_m", []) or [])
|
||||||
|
response.tool_com_m = [float(v) for v in (com + [0.0, 0.0, 0.0])[:3]]
|
||||||
|
response.success = True
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
response.message = f"当前工具: {response.current_tool or '(空)'}"
|
||||||
|
elif reply.get("already"):
|
||||||
|
response.message = f"已经在工具 [{response.current_tool}] 上,无需切换"
|
||||||
|
else:
|
||||||
|
response.message = f"已切换到工具 [{response.current_tool}]"
|
||||||
|
|
||||||
|
self.get_logger().info(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _on_soft_drag(self, request: SetBool.Request, response: SetBool.Response):
|
||||||
|
try:
|
||||||
|
client = self._ensure_rdk()
|
||||||
|
if request.data:
|
||||||
|
reply = client.drag_on()
|
||||||
|
response.message = (
|
||||||
|
f"软拖拽已开启(mode={reply.get('mode', 'soft')})。"
|
||||||
|
"现在可以手动推动手臂。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
client.drag_off()
|
||||||
|
response.message = "软拖拽已关闭,已切回 IDLE。"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
response.success = False
|
||||||
|
response.message = self._rdk_error_reply("软拖拽", exc)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.success = True
|
||||||
|
self.get_logger().info(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _on_home(self, _request: Trigger.Request, response: Trigger.Response):
|
||||||
|
"""回初始位。
|
||||||
|
|
||||||
|
顺序很重要:先确保工具坐标系正确,再回位。否则回位后的姿态是按错误的
|
||||||
|
TCP 算的,后续装配会从错的位置开始。
|
||||||
|
"""
|
||||||
|
tool_name = (self._tool_name or "").strip()
|
||||||
|
if tool_name and bool(self.get_parameter("switch_tool_on_home").value):
|
||||||
|
try:
|
||||||
|
reply = self._ensure_rdk().switch_tool(tool_name)
|
||||||
|
self.get_logger().info(
|
||||||
|
f"回位前工具确认: {reply.get('current') or tool_name}"
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# 工具切不过去就让调用方知道 —— 静默继续会让装配在错误 TCP 下跑。
|
||||||
|
response.success = False
|
||||||
|
response.message = (
|
||||||
|
f"回位前的工具切换失败,已中止回位以免在错误工具系下运动: {exc}"
|
||||||
|
)
|
||||||
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
if not self._named_pose_client.wait_for_service(timeout_sec=5.0):
|
||||||
|
response.success = False
|
||||||
|
response.message = (
|
||||||
|
f"等不到服务 {self._named_pose_client.srv_name}。"
|
||||||
|
"先启动机械臂控制:bash scripts/run_real.sh(或 mock 控制栈)"
|
||||||
|
)
|
||||||
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
pose = str(self.get_parameter("home_pose").value)
|
||||||
|
speed = float(self.get_parameter("home_speed").value)
|
||||||
|
req = SetNamedPose.Request()
|
||||||
|
req.pose_name = pose
|
||||||
|
req.velocity_scale = speed
|
||||||
|
req.acceleration_scale = 0.0
|
||||||
|
|
||||||
|
future = self._named_pose_client.call_async(req)
|
||||||
|
# 本节点的回调跑在 ReentrantCallbackGroup 上,所以这里的等待不会自锁。
|
||||||
|
if not _wait_future(future, timeout_sec=60.0):
|
||||||
|
response.success = False
|
||||||
|
response.message = f"回初始位超时(pose={pose}, speed={speed})"
|
||||||
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
response.success = False
|
||||||
|
response.message = f"回初始位调用异常: {exc}"
|
||||||
|
self.get_logger().error(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
response.success = bool(result.success)
|
||||||
|
response.message = (
|
||||||
|
f"已回初始位 {pose}: {result.message}"
|
||||||
|
if result.success
|
||||||
|
else f"回初始位失败: {result.message}"
|
||||||
|
)
|
||||||
|
self.get_logger().info(response.message)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _nonempty(value: Any) -> str:
|
||||||
|
"""把 ROS 参数值转成去掉首尾空白的字符串(空串 -> "")。"""
|
||||||
|
return str(value).strip() if value is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_future(future: Any, timeout_sec: float) -> bool:
|
||||||
|
"""在回调线程里等一个 future 完成。
|
||||||
|
|
||||||
|
不能用 ``rclpy.spin_until_future_complete`` —— 那需要拿到 executor,而在服务
|
||||||
|
回调里再 spin 会自锁。这里用事件等待,配合 ReentrantCallbackGroup 让应答能在
|
||||||
|
另一个线程被处理。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
deadline = time.monotonic() + timeout_sec
|
||||||
|
while not future.done() and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return future.done()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
rclpy.init(args=argv)
|
||||||
|
node = FlexivArmDriver()
|
||||||
|
executor = MultiThreadedExecutor(num_threads=4)
|
||||||
|
executor.add_node(node)
|
||||||
|
try:
|
||||||
|
executor.spin()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with node._rdk_lock: # noqa: SLF001 - 关闭时清理子进程
|
||||||
|
if node._rdk is not None:
|
||||||
|
node._rdk.stop()
|
||||||
|
node._rdk = None
|
||||||
|
node.destroy_node()
|
||||||
|
if rclpy.ok():
|
||||||
|
rclpy.shutdown()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""RDK 桥客户端:管理 `rdk_drag_bridge.py` 长驻子进程。
|
||||||
|
|
||||||
|
为什么需要这一层
|
||||||
|
----------------
|
||||||
|
`flexivrdk` 与 ROS 环境的 Python 3.11 ABI 不兼容,且 RDK 对同一台机器人是独占
|
||||||
|
访问。所以驱动节点不直接连 RDK,而是拉起一个跑在 `rdk_py310` 下的长驻桥进程,
|
||||||
|
用 JSON 行协议与它通信。本模块把这套子进程 + 协议交互封装成一个对象。
|
||||||
|
|
||||||
|
设计取舍
|
||||||
|
--------
|
||||||
|
* **同步请求**:每条命令"发一行、等一行"。RDK 操作本来就是串行的,异步化只会
|
||||||
|
引入竞态。用一个锁保证多线程调用安全。
|
||||||
|
* **超时必抛**:桥进程卡住时不能让调用方无限等。超时抛
|
||||||
|
:class:`RdkBridgeError`,消息里带上原始命令,便于定位。
|
||||||
|
* **可以只启动不连接**:没有真机时(CI / 纯接口测试)用
|
||||||
|
``start(connect=False)``,接口照样能起来,只是调用 RDK 的命令会失败。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
#: 桥进程必须用这个解释器 —— RDK 绑定只在这里有。
|
||||||
|
DEFAULT_RDK_PYTHON = "/home/cc/rdk_py310/bin/python"
|
||||||
|
|
||||||
|
#: 桥脚本位置(相对工作区根)。
|
||||||
|
BRIDGE_SCRIPT_REL = "scripts/rdk_drag_bridge.py"
|
||||||
|
|
||||||
|
#: 未指定时使用的默认日志文件。
|
||||||
|
DEFAULT_LOG = "/tmp/flexiv_arm_driver_rdk.log"
|
||||||
|
|
||||||
|
|
||||||
|
class RdkBridgeError(RuntimeError):
|
||||||
|
"""桥进程不可用、命令失败或超时。
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
hint:
|
||||||
|
给操作员的修复建议(文件路径、检查项)。可能为空。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, hint: str = "") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.hint = hint
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.hint:
|
||||||
|
return f"{super().__str__()}\n 怎么修: {self.hint}"
|
||||||
|
return super().__str__()
|
||||||
|
|
||||||
|
|
||||||
|
def default_rdk_python() -> str:
|
||||||
|
"""桥进程解释器路径,可用 ``RDK_PYTHON`` 覆盖。"""
|
||||||
|
return os.environ.get("RDK_PYTHON", "").strip() or DEFAULT_RDK_PYTHON
|
||||||
|
|
||||||
|
|
||||||
|
def default_bridge_script() -> Path:
|
||||||
|
"""桥脚本路径。可用 ``SITE_ROS2_ROOT`` / ``ROS2_WS`` 指向别的工作区。"""
|
||||||
|
root = (
|
||||||
|
os.environ.get("SITE_ROS2_ROOT", "").strip()
|
||||||
|
or os.environ.get("ROS2_WS", "").strip()
|
||||||
|
)
|
||||||
|
base = Path(root).expanduser() if root else Path.home() / "ros2"
|
||||||
|
return base / BRIDGE_SCRIPT_REL
|
||||||
|
|
||||||
|
|
||||||
|
class RdkClient:
|
||||||
|
"""与 RDK 桥子进程的一条长连接。
|
||||||
|
|
||||||
|
典型用法::
|
||||||
|
|
||||||
|
client = RdkClient(sn="Rizon4-063989", iface="enp4s0")
|
||||||
|
client.start() # 拉起桥并 connect
|
||||||
|
client.switch_tool("tool1") # 切到 tool1
|
||||||
|
client.stop()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sn: str,
|
||||||
|
iface: str = "",
|
||||||
|
*,
|
||||||
|
python: Optional[str] = None,
|
||||||
|
script: Optional[Path] = None,
|
||||||
|
log_path: Optional[str] = None,
|
||||||
|
reply_timeout: float = 10.0,
|
||||||
|
connect_timeout: float = 25.0,
|
||||||
|
) -> None:
|
||||||
|
self.sn = sn
|
||||||
|
self.iface = iface
|
||||||
|
self.python = python or default_rdk_python()
|
||||||
|
self.script = Path(script) if script else default_bridge_script()
|
||||||
|
self.log_path = log_path or DEFAULT_LOG
|
||||||
|
self.reply_timeout = reply_timeout
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._log_file = None
|
||||||
|
|
||||||
|
# -- 生命周期 ---------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._proc is not None and self._proc.poll() is None
|
||||||
|
|
||||||
|
def start(self, *, connect: bool = True) -> None:
|
||||||
|
"""拉起桥进程。``connect=True`` 时同时连接机器人。
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
RdkBridgeError
|
||||||
|
解释器或脚本不存在,或 ``connect=True`` 而连接失败。
|
||||||
|
"""
|
||||||
|
if self.is_running:
|
||||||
|
return
|
||||||
|
if not Path(self.python).is_file():
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"找不到 RDK 解释器: {self.python}",
|
||||||
|
f"设置环境变量 RDK_PYTHON 指向装有 flexivrdk 的解释器;"
|
||||||
|
f"默认是 {DEFAULT_RDK_PYTHON}",
|
||||||
|
)
|
||||||
|
if not self.script.is_file():
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"找不到 RDK 桥脚本: {self.script}",
|
||||||
|
"确认工作区完整(scripts/rdk_drag_bridge.py 存在),"
|
||||||
|
"或用 SITE_ROS2_ROOT 指向正确的工作区",
|
||||||
|
)
|
||||||
|
|
||||||
|
Path(self.log_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._log_file = open(self.log_path, "a", encoding="utf-8", buffering=1)
|
||||||
|
self._log_file.write(
|
||||||
|
f"\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} start "
|
||||||
|
f"sn={self.sn} iface={self.iface or '-'} ===\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
[self.python, str(self.script)],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=self._log_file,
|
||||||
|
# 二进制 + 无缓冲:配合 _read_reply 里的 os.read 自己切行。
|
||||||
|
# 用 text=True 的话 readline() 会为了等换行符而阻塞,
|
||||||
|
# select 的"可读"并不等于"有完整一行",超时就会失效。
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 先 ping 确认桥活着(能捕获"解释器起不来/语法错"这类问题)。
|
||||||
|
self.request("ping", timeout=10.0)
|
||||||
|
|
||||||
|
if connect:
|
||||||
|
payload: Dict[str, Any] = {"sn": self.sn}
|
||||||
|
if self.iface:
|
||||||
|
payload["iface"] = self.iface
|
||||||
|
reply = self.request("connect", timeout=self.connect_timeout, **payload)
|
||||||
|
if not reply.get("ok"):
|
||||||
|
error = reply.get("error", "未知原因")
|
||||||
|
self.stop()
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"连接机械臂 {self.sn} 失败: {error}",
|
||||||
|
f"检查网线 / `network.prefer_iface`、示教器是否 AUTO(Remote)、"
|
||||||
|
f"是否已松开急停。桥日志: {self.log_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 5.0) -> None:
|
||||||
|
"""请求桥退出并回收进程。幂等。"""
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if proc.poll() is None:
|
||||||
|
self._write({"cmd": "quit"})
|
||||||
|
try:
|
||||||
|
self._read_reply(timeout)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=2.0)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for stream in (proc.stdin, proc.stdout):
|
||||||
|
try:
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._log_file is not None:
|
||||||
|
try:
|
||||||
|
self._log_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._log_file = None
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
def __enter__(self) -> "RdkClient":
|
||||||
|
self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc: Any) -> None:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
# -- 协议 -------------------------------------------------------------
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self, cmd: str, timeout: Optional[float] = None, **kwargs: Any
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""发一条命令并等应答。
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
RdkBridgeError
|
||||||
|
桥未运行、进程已退出、应答超时,或应答不是合法 JSON。
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if not self.is_running:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
"RDK 桥未运行",
|
||||||
|
f"先调用 RdkClient.start();若刚崩溃,看日志 {self.log_path}",
|
||||||
|
)
|
||||||
|
payload = {"cmd": cmd, **kwargs}
|
||||||
|
self._write(payload)
|
||||||
|
return self._read_reply(
|
||||||
|
timeout if timeout is not None else self.reply_timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
def request_ok(
|
||||||
|
self, cmd: str, timeout: Optional[float] = None, **kwargs: Any
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""同 :meth:`request`,但 ``ok`` 为假时直接抛错。"""
|
||||||
|
reply = self.request(cmd, timeout=timeout, **kwargs)
|
||||||
|
if not reply.get("ok"):
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"命令 {cmd} 失败: {reply.get('error', '未提供原因')}",
|
||||||
|
f"桥日志: {self.log_path}",
|
||||||
|
)
|
||||||
|
return reply
|
||||||
|
|
||||||
|
# -- 便捷包装 ---------------------------------------------------------
|
||||||
|
|
||||||
|
def enable(self) -> Dict[str, Any]:
|
||||||
|
"""使能 + 清故障(幂等)。"""
|
||||||
|
return self.request_ok("enable")
|
||||||
|
|
||||||
|
def clear_fault(self) -> Dict[str, Any]:
|
||||||
|
"""清故障并使能。"""
|
||||||
|
return self.request_ok("clear_fault")
|
||||||
|
|
||||||
|
def switch_tool(self, name: str = "", timeout: float = 20.0) -> Dict[str, Any]:
|
||||||
|
"""切换工具。``name`` 为空时只查询当前状态。"""
|
||||||
|
return self.request_ok("switch_tool", timeout=timeout, name=name)
|
||||||
|
|
||||||
|
def status(self) -> Dict[str, Any]:
|
||||||
|
"""查询 operational / fault / mode / 当前工具。"""
|
||||||
|
return self.request_ok("status")
|
||||||
|
|
||||||
|
def drag_on(self, mode: str = "soft") -> Dict[str, Any]:
|
||||||
|
"""开启软拖拽(手眼走点用)。"""
|
||||||
|
return self.request_ok("drag_on", timeout=15.0, mode=mode)
|
||||||
|
|
||||||
|
def drag_off(self) -> Dict[str, Any]:
|
||||||
|
"""关闭软拖拽并切回 IDLE。"""
|
||||||
|
return self.request_ok("drag_off", timeout=15.0)
|
||||||
|
|
||||||
|
def joints(self) -> List[float]:
|
||||||
|
"""读关节角(拖拽期间也能读,走桥内缓存)。"""
|
||||||
|
reply = self.request_ok("joints")
|
||||||
|
return [float(v) for v in (reply.get("q") or [])]
|
||||||
|
|
||||||
|
# -- 内部 -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _write(self, payload: Dict[str, Any]) -> None:
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None or proc.stdin is None:
|
||||||
|
raise RdkBridgeError("RDK 桥的 stdin 不可用")
|
||||||
|
line = json.dumps(payload, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
proc.stdin.write((line + "\n").encode("utf-8"))
|
||||||
|
proc.stdin.flush()
|
||||||
|
except (BrokenPipeError, OSError) as exc:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"写入 RDK 桥失败(进程可能已退出): {exc}",
|
||||||
|
f"查看桥日志 {self.log_path}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _read_reply(self, timeout: float) -> Dict[str, Any]:
|
||||||
|
"""带超时读一行 JSON。
|
||||||
|
|
||||||
|
为什么不用 ``readline()``
|
||||||
|
-------------------------
|
||||||
|
``select`` 说"可读"只代表**有字节**,不代表有**完整一行**。桥上如果
|
||||||
|
只写了半行(原生库中途崩、或者 JSON 被截断),``readline()`` 会一直
|
||||||
|
阻塞等那个永远不来的换行符 —— 超时机制完全失效,整个节点挂死。
|
||||||
|
所以这里改用 ``os.read`` 按块读,行切分自己做:读多少算多少,
|
||||||
|
没有完整行就回到 ``select`` 继续等,直到超时。
|
||||||
|
|
||||||
|
非 JSON 行(RDK 原生日志混进 stdout)直接跳过,不判协议损坏。
|
||||||
|
"""
|
||||||
|
proc = self._proc
|
||||||
|
if proc is None or proc.stdout is None:
|
||||||
|
raise RdkBridgeError("RDK 桥的 stdout 不可用")
|
||||||
|
|
||||||
|
deadline = time.monotonic() + max(0.1, timeout)
|
||||||
|
buffered = b""
|
||||||
|
while True:
|
||||||
|
# 缓冲里可能已经有完整行(一次 read 常常带回多行),先消化掉。
|
||||||
|
while b"\n" in buffered:
|
||||||
|
raw, buffered = buffered.split(b"\n", 1)
|
||||||
|
reply = self._parse_line(raw)
|
||||||
|
if reply is not None:
|
||||||
|
return reply
|
||||||
|
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"等待 RDK 桥应答超时({timeout:.1f}s)",
|
||||||
|
f"机器人可能未响应;查看桥日志 {self.log_path}",
|
||||||
|
)
|
||||||
|
ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 0.5))
|
||||||
|
if not ready:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"RDK 桥进程已退出(returncode={proc.returncode})",
|
||||||
|
f"查看桥日志 {self.log_path}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
chunk = os.read(proc.stdout.fileno(), 65536)
|
||||||
|
except (BlockingIOError, InterruptedError):
|
||||||
|
continue
|
||||||
|
except OSError as exc:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"读取 RDK 桥失败: {exc}",
|
||||||
|
f"查看桥日志 {self.log_path}",
|
||||||
|
) from exc
|
||||||
|
if not chunk:
|
||||||
|
# EOF:进程走了,或者 stdout 被关了。
|
||||||
|
if proc.poll() is not None:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"RDK 桥进程已退出(returncode={proc.returncode})",
|
||||||
|
f"查看桥日志 {self.log_path}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
buffered += chunk
|
||||||
|
|
||||||
|
def _parse_line(self, raw: bytes) -> Optional[Dict[str, Any]]:
|
||||||
|
"""把一行的原始字节变成应答 dict;该行不是应答时返回 ``None``。
|
||||||
|
|
||||||
|
返回 ``None`` 的两种情况:空行、以及不带 ``{`` 的原生日志行。
|
||||||
|
后者要跳过而不是报错 —— 桥脚本已经把 ``sys.stdout`` 换成 stderr 来
|
||||||
|
隔离 RDK 日志,但原生库可能直接写 fd 1 绕过 Python 层。
|
||||||
|
"""
|
||||||
|
text = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if "{" not in text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
reply = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"RDK 桥返回了非 JSON 内容: {text[:200]!r}",
|
||||||
|
f"桥日志 {self.log_path}(RDK 自身的日志混进 stdout 通常是原因)",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(reply, dict):
|
||||||
|
raise RdkBridgeError(
|
||||||
|
f"RDK 桥的应答不是 JSON 对象: {reply!r}",
|
||||||
|
f"查看桥日志 {self.log_path}",
|
||||||
|
)
|
||||||
|
return reply
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""从 ``config/site.yaml`` 读取驱动需要的默认值。
|
||||||
|
|
||||||
|
为什么不让 launch 注入全部参数
|
||||||
|
------------------------------
|
||||||
|
现有节点的做法是 launch 文件先用 ``export_site_env.py`` 读 site.yaml,再把值塞
|
||||||
|
成 ROS 参数。那条路径的问题是有两套加载逻辑(``site_io.py`` 和
|
||||||
|
``export_site_env.py``),且 ``ros2 run`` 直接起节点时参数全是空的。
|
||||||
|
|
||||||
|
这里改为:节点自己用 ``arm_interfaces`` 的加载器读 site.yaml 作为**参数默认
|
||||||
|
值**,launch 仍然可以覆盖。于是
|
||||||
|
``ros2 run arm_driver_flexiv flexiv_arm_driver`` 直接起也能工作,
|
||||||
|
既没有重复的加载器,也保留了 ROS 的参数覆盖惯例。
|
||||||
|
|
||||||
|
多臂支持
|
||||||
|
--------
|
||||||
|
``robot.sn`` 选定当前服务的臂,臂专属的值(IP / 网卡)去 ``robot.robots[]``
|
||||||
|
里按 SN 找。找不到时回退到全局段 —— 这与现有节点的行为一致。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from arm_interfaces import load_site
|
||||||
|
|
||||||
|
|
||||||
|
class SiteConfig:
|
||||||
|
"""site.yaml 的一个只读视图,只暴露驱动需要的键。"""
|
||||||
|
|
||||||
|
def __init__(self, data: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
if data is None:
|
||||||
|
try:
|
||||||
|
data = load_site()
|
||||||
|
except Exception:
|
||||||
|
# 契约层读不了配置不该让驱动起不来 —— 参数会有默认值,
|
||||||
|
# 真正需要配置的操作会在调用时报出具体缺什么。
|
||||||
|
data = {}
|
||||||
|
self._data: Dict[str, Any] = data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
# -- 机械臂 -----------------------------------------------------------
|
||||||
|
|
||||||
|
def robot_sn(self) -> str:
|
||||||
|
"""当前服务的机械臂 SN(``robot.sn``)。"""
|
||||||
|
return str(self._robot().get("sn", "") or "").strip()
|
||||||
|
|
||||||
|
def tool_name(self) -> str:
|
||||||
|
"""回位前要切换到的工具名(``robot.tool_name``)。空=不切换。"""
|
||||||
|
return str(self._robot().get("tool_name", "") or "").strip()
|
||||||
|
|
||||||
|
def robot_entry(self, sn: str = "") -> Dict[str, Any]:
|
||||||
|
"""取 ``robot.robots[]`` 中匹配 SN 的条目;找不到返回空 dict。"""
|
||||||
|
target = (sn or self.robot_sn()).strip()
|
||||||
|
for entry in self._robot().get("robots") or []:
|
||||||
|
if isinstance(entry, dict) and str(entry.get("sn", "")).strip() == target:
|
||||||
|
return entry
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def net_iface(self) -> str:
|
||||||
|
"""本臂的网卡名:优先 ``robot.robots[].iface``,回退 ``network.prefer_iface``。"""
|
||||||
|
entry = self.robot_entry()
|
||||||
|
iface = str(entry.get("iface", "") or "").strip()
|
||||||
|
if iface:
|
||||||
|
return iface
|
||||||
|
return str((self._data.get("network") or {}).get("prefer_iface", "") or "").strip()
|
||||||
|
|
||||||
|
def robot_ip(self) -> str:
|
||||||
|
"""本臂的控制器 IP(``robot.robots[].ip``)。"""
|
||||||
|
return str(self.robot_entry().get("ip", "") or "").strip()
|
||||||
|
|
||||||
|
# -- 回位 -------------------------------------------------------------
|
||||||
|
|
||||||
|
def home_pose(self) -> str:
|
||||||
|
"""回位用的命名位姿(``go_home.pose``,回退 ``servo.home_pose_name``)。"""
|
||||||
|
pose = str((self._data.get("go_home") or {}).get("pose", "") or "").strip()
|
||||||
|
if pose:
|
||||||
|
return pose
|
||||||
|
return str(self._servo_base().get("home_pose_name", "") or "").strip() or "home"
|
||||||
|
|
||||||
|
def home_speed(self) -> float:
|
||||||
|
"""回位速度比例(``go_home.speed``,回退 ``servo.home_pose_speed``)。"""
|
||||||
|
value = (self._data.get("go_home") or {}).get("speed")
|
||||||
|
if value is None:
|
||||||
|
value = self._servo_base().get("home_pose_speed")
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.25
|
||||||
|
|
||||||
|
# -- 内部 -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _robot(self) -> Dict[str, Any]:
|
||||||
|
robot = self._data.get("robot")
|
||||||
|
return robot if isinstance(robot, dict) else {}
|
||||||
|
|
||||||
|
def _servo_base(self) -> Dict[str, Any]:
|
||||||
|
servo = self._data.get("servo")
|
||||||
|
return servo if isinstance(servo, dict) else {}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>arm_driver_flexiv</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>
|
||||||
|
非夕 Flexiv 机械臂驱动适配器。补齐 manifest 声明但现有节点没提供的能力:
|
||||||
|
home / enable / clear_fault / switch_tool / soft_drag / compliant_descent。
|
||||||
|
已经存在的能力(get_arm_state / follow_joint_trajectory / set_named_pose /
|
||||||
|
视觉伺服)由 arm_control 与 arm_application 的现有节点提供,本包不重复实现。
|
||||||
|
所有需要独占 RDK 的操作都经由 rdk_py310 下的长驻桥进程,本包自身不 import
|
||||||
|
flexivrdk(ABI 不兼容)。
|
||||||
|
</description>
|
||||||
|
<maintainer email="dev@example.com">arm_dev</maintainer>
|
||||||
|
<license>Apache-2.0</license>
|
||||||
|
|
||||||
|
<exec_depend>rclpy</exec_depend>
|
||||||
|
<exec_depend>arm_msgs</exec_depend>
|
||||||
|
<exec_depend>arm_interfaces</exec_depend>
|
||||||
|
<exec_depend>arm_kinematics</exec_depend>
|
||||||
|
<exec_depend>std_srvs</exec_depend>
|
||||||
|
<exec_depend>geometry_msgs</exec_depend>
|
||||||
|
<exec_depend>sensor_msgs</exec_depend>
|
||||||
|
<exec_depend>trajectory_msgs</exec_depend>
|
||||||
|
<exec_depend>control_msgs</exec_depend>
|
||||||
|
<exec_depend>tf2_ros</exec_depend>
|
||||||
|
<exec_depend>python3-numpy</exec_depend>
|
||||||
|
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/arm_driver_flexiv
|
||||||
|
[install]
|
||||||
|
install_scripts=$base/lib/arm_driver_flexiv
|
||||||
|
|
||||||
|
[tool:pytest]
|
||||||
|
# ROS 2 Humble 的两个 launch-testing pytest 插件用了旧版 hook 签名,
|
||||||
|
# 当前 pytest 会在收集阶段直接报错。本包只跑纯 Python 单测,禁掉它们。
|
||||||
|
addopts = -p no:launch_testing -p no:launch_ros
|
||||||
|
testpaths = test
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
|
package_name = 'arm_driver_flexiv'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.1.0',
|
||||||
|
packages=find_packages(exclude=['test']),
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools', 'PyYAML'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='arm_dev',
|
||||||
|
maintainer_email='dev@example.com',
|
||||||
|
description='非夕 Flexiv 机械臂驱动适配器',
|
||||||
|
license='Apache-2.0',
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
# 主驱动节点:home / enable / clear_fault / switch_tool / soft_drag
|
||||||
|
# 以及 compliant_descent action。
|
||||||
|
'flexiv_arm_driver = arm_driver_flexiv.flexiv_arm_driver:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""``DescentController`` 的纯逻辑单测。
|
||||||
|
|
||||||
|
下压逻辑里最容易出错、也最危险的部分全在这里:接触判据的优先级、安全上限、
|
||||||
|
触觉断流时的行为、超时与行程上限。这些都能用注入的假回调完整覆盖,
|
||||||
|
不需要机械臂、JTC、TF。
|
||||||
|
|
||||||
|
假时钟很关键:超时判定如果真用 ``time.sleep`` 测,一套测试要跑几十秒;
|
||||||
|
换成手动推进的假时钟后毫秒级完成,且不依赖机器快慢。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arm_driver_flexiv.compliant_descent import (
|
||||||
|
DescentConfig,
|
||||||
|
DescentController,
|
||||||
|
DescentOutcome,
|
||||||
|
DescentProgress,
|
||||||
|
TactileSample,
|
||||||
|
_R_to_quat_xyzw,
|
||||||
|
_quat_xyzw_to_R,
|
||||||
|
select_tangential,
|
||||||
|
)
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 测试替身
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
"""手动推进的时钟。``sleep`` 只把时间往前拨,不真的等。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.t = 0.0
|
||||||
|
|
||||||
|
def now(self) -> float:
|
||||||
|
return self.t
|
||||||
|
|
||||||
|
def sleep(self, dt: float) -> None:
|
||||||
|
self.t += max(0.0, dt)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedTactile:
|
||||||
|
"""按预设脚本给出触觉读数。
|
||||||
|
|
||||||
|
脚本是一个 ``(切向, 法向)`` 列表,交给 ``Decider`` 决定每次返回什么。
|
||||||
|
用列表而不是固定值,是为了模拟"下压若干步之后才接触"。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, script, *, age: float = 0.0, stale_after: Optional[int] = None):
|
||||||
|
self.script = script
|
||||||
|
self.age = age
|
||||||
|
self.stale_after = stale_after
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def __call__(self) -> Optional[TactileSample]:
|
||||||
|
idx = self.calls
|
||||||
|
self.calls += 1
|
||||||
|
if callable(self.script):
|
||||||
|
value = self.script(idx)
|
||||||
|
else:
|
||||||
|
value = self.script[min(idx, len(self.script) - 1)]
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
tangential, normal = value
|
||||||
|
age = self.age
|
||||||
|
if self.stale_after is not None and idx >= self.stale_after:
|
||||||
|
age = 999.0
|
||||||
|
return TactileSample(tangential=tangential, normal=normal, age_sec=age)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller(
|
||||||
|
cfg: DescentConfig,
|
||||||
|
tactile,
|
||||||
|
*,
|
||||||
|
step_ok=None,
|
||||||
|
clock: Optional[FakeClock] = None,
|
||||||
|
cancelled=None,
|
||||||
|
on_progress=None,
|
||||||
|
log: Optional[List[float]] = None,
|
||||||
|
) -> DescentController:
|
||||||
|
"""搭一个控制器,把"下压一步"换成往 log 里记一笔。"""
|
||||||
|
clock = clock or FakeClock()
|
||||||
|
steps = log if log is not None else []
|
||||||
|
|
||||||
|
def step_down(dz: float) -> bool:
|
||||||
|
if step_ok is not None and not step_ok(len(steps)):
|
||||||
|
return False
|
||||||
|
steps.append(dz)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return DescentController(
|
||||||
|
cfg,
|
||||||
|
read_tactile=tactile,
|
||||||
|
step_down=step_down,
|
||||||
|
now=clock.now,
|
||||||
|
sleep=clock.sleep,
|
||||||
|
is_cancelled=(cancelled or (lambda: False)),
|
||||||
|
on_progress=on_progress,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE = DescentConfig(
|
||||||
|
contact_axis=0,
|
||||||
|
contact_delta=100.0,
|
||||||
|
max_normal_delta=0.0,
|
||||||
|
descent_step_m=0.0005,
|
||||||
|
descent_speed_mps=0.005,
|
||||||
|
max_descent_m=0.05,
|
||||||
|
settle_sec=0.0,
|
||||||
|
timeout_sec=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 接触判据
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestContactDetection:
|
||||||
|
def test_already_in_contact_reports_zero_travel(self):
|
||||||
|
"""起手就已经接触:不该先压一步再判断,否则会多压 0.5mm。"""
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile([(150.0, 5.0)]), log=log)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.CONTACT
|
||||||
|
assert result.descended_m == 0.0
|
||||||
|
assert log == []
|
||||||
|
|
||||||
|
def test_contact_after_several_steps(self):
|
||||||
|
"""前 4 次读数低于阈值,之后跳变 —— 应当正好压 4 步。"""
|
||||||
|
script = [(10.0, 1.0), (20.0, 1.0), (30.0, 1.0), (40.0, 1.0), (200.0, 1.0)]
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile(script), log=log)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.CONTACT
|
||||||
|
# 循环顺序是「先读、再判、最后走步」,所以第 5 次读到时已经走了 4 步。
|
||||||
|
assert len(log) == 4
|
||||||
|
assert result.descended_m == pytest.approx(4 * 0.0005)
|
||||||
|
assert result.tangential_delta == 200.0
|
||||||
|
|
||||||
|
def test_negative_tangential_also_counts(self):
|
||||||
|
"""切向力往负方向跳同样算接触 —— 判据用绝对值。"""
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile([(-150.0, 1.0)]))
|
||||||
|
assert ctrl.run().outcome is DescentOutcome.CONTACT
|
||||||
|
|
||||||
|
def test_exactly_at_threshold_counts(self):
|
||||||
|
"""刚好等于阈值算接触(>= 而不是 >),避免边界上漏检。"""
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile([(100.0, 1.0)]))
|
||||||
|
assert ctrl.run().outcome is DescentOutcome.CONTACT
|
||||||
|
|
||||||
|
def test_axis_does_not_affect_pure_logic(self):
|
||||||
|
"""纯逻辑只接收已经选好的切向值 —— contact_axis 在它这里不改变结果。
|
||||||
|
|
||||||
|
通道选择本身由 ``select_tangential`` 负责(见 TestSelectTangential)。
|
||||||
|
这条测试把边界钉住:如果你试图在纯逻辑里再按 axis 挑一次,就会重复选择
|
||||||
|
并挑错通道。
|
||||||
|
"""
|
||||||
|
for axis in (0, 1):
|
||||||
|
ctrl = make_controller(
|
||||||
|
replace(BASE, contact_axis=axis),
|
||||||
|
ScriptedTactile([(500.0, 1.0)]),
|
||||||
|
)
|
||||||
|
assert ctrl.run().outcome is DescentOutcome.CONTACT
|
||||||
|
|
||||||
|
def test_success_flag_matches_outcome(self):
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile([(150.0, 1.0)]))
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.success is True
|
||||||
|
assert result.status == "contacting"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 安全停止条件
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafetyStops:
|
||||||
|
def test_normal_limit_beats_contact(self):
|
||||||
|
"""法向力超限和切向接触同时满足时,必须先报安全限 —— 硬顶比接触更危险。"""
|
||||||
|
ctrl = make_controller(
|
||||||
|
replace(BASE, max_normal_delta=50.0),
|
||||||
|
ScriptedTactile([(500.0, 80.0)]),
|
||||||
|
)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.NORMAL_LIMIT
|
||||||
|
assert result.success is False
|
||||||
|
assert result.normal_delta == 80.0
|
||||||
|
|
||||||
|
def test_normal_limit_disabled_when_zero(self):
|
||||||
|
"""max_normal_delta <= 0 表示不检查法向力。"""
|
||||||
|
ctrl = make_controller(replace(BASE, max_normal_delta=0.0), ScriptedTactile([(10.0, 99999.0)]))
|
||||||
|
assert ctrl.run().outcome is DescentOutcome.MAX_DESCENT
|
||||||
|
|
||||||
|
def test_max_descent_when_never_contacting(self):
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile([(1.0, 1.0)]), log=log)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.MAX_DESCENT
|
||||||
|
# 行程上限 0.05 / 步长 0.0005 = 100 步;不能超过
|
||||||
|
assert len(log) == 100
|
||||||
|
assert result.descended_m == pytest.approx(0.05)
|
||||||
|
assert "行程上限" in result.message
|
||||||
|
|
||||||
|
def test_timeout_stops_before_travel_limit(self):
|
||||||
|
"""超时必须能提前中断 —— 即使行程还没走完。"""
|
||||||
|
cfg = replace(BASE, timeout_sec=0.6)
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(cfg, ScriptedTactile([(1.0, 1.0)]), log=log)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.TIMEOUT
|
||||||
|
assert len(log) < 100 # 没走到行程上限
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
def test_tactile_disconnect_stops_immediately(self):
|
||||||
|
"""读不到触觉时必须立刻停 —— 无反馈下压是危险的。"""
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile([None]), log=log)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.TACTILE_STALE
|
||||||
|
assert log == []
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
def test_tactile_disconnect_after_some_steps(self):
|
||||||
|
"""前 3 次有数据,之后断流:应当正好走了 3 步就停。"""
|
||||||
|
script = lambda i: (1.0, 1.0) if i < 3 else None # noqa: E731
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile(script), log=log)
|
||||||
|
assert ctrl.run().outcome is DescentOutcome.TACTILE_STALE
|
||||||
|
assert len(log) == 3
|
||||||
|
|
||||||
|
def test_stale_tactile_stops(self):
|
||||||
|
"""数据有值但已经过期(传感器卡死)也必须停。
|
||||||
|
|
||||||
|
循环顺序是「先读触觉、后走步」,所以第 4 次读(idx=3)才发现过期,
|
||||||
|
此时已经走了 3 步。
|
||||||
|
"""
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(
|
||||||
|
BASE, ScriptedTactile([(1.0, 1.0)], stale_after=3), log=log
|
||||||
|
)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.TACTILE_STALE
|
||||||
|
assert "过期" in result.message
|
||||||
|
assert len(log) == 3
|
||||||
|
|
||||||
|
def test_motion_failure_stops(self):
|
||||||
|
"""某一步没走成就停下,不要假装走出了这个距离。"""
|
||||||
|
log: List[float] = []
|
||||||
|
ctrl = make_controller(
|
||||||
|
BASE, ScriptedTactile([(1.0, 1.0)]), step_ok=lambda n: n < 2, log=log
|
||||||
|
)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.MOTION_FAILED
|
||||||
|
assert result.descended_m == pytest.approx(2 * 0.0005)
|
||||||
|
|
||||||
|
def test_cancel_is_honoured(self):
|
||||||
|
"""走 3 步后拉取消标志:应当在下一次循环开头就停下。
|
||||||
|
|
||||||
|
注意取消检查在 ``read_tactile`` **之前**,所以读到第 4 帧、走完第 4 步
|
||||||
|
之后才会真正退出。
|
||||||
|
"""
|
||||||
|
log: List[float] = []
|
||||||
|
flag = {"v": False}
|
||||||
|
|
||||||
|
def read():
|
||||||
|
if len(log) >= 3:
|
||||||
|
flag["v"] = True
|
||||||
|
return TactileSample(1.0, 1.0, 0.0)
|
||||||
|
|
||||||
|
ctrl = make_controller(BASE, read, cancelled=lambda: flag["v"], log=log)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.CANCELLED
|
||||||
|
assert result.success is False
|
||||||
|
assert len(log) == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# feedback
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestProgress:
|
||||||
|
def test_progress_reported_and_monotonic(self):
|
||||||
|
seen: List[DescentProgress] = []
|
||||||
|
ctrl = make_controller(
|
||||||
|
BASE, ScriptedTactile([(1.0, 1.0)]),
|
||||||
|
on_progress=seen.append,
|
||||||
|
)
|
||||||
|
result = ctrl.run()
|
||||||
|
assert result.outcome is DescentOutcome.MAX_DESCENT
|
||||||
|
# 每走一步 1 次 + 结束 1 次
|
||||||
|
assert len(seen) == 101
|
||||||
|
fractions = [p.fraction for p in seen]
|
||||||
|
assert fractions == sorted(fractions)
|
||||||
|
assert fractions[-1] == pytest.approx(1.0)
|
||||||
|
assert seen[-1].status == "error"
|
||||||
|
|
||||||
|
def test_progress_on_immediate_contact_is_zero(self):
|
||||||
|
seen: List[DescentProgress] = []
|
||||||
|
ctrl = make_controller(BASE, ScriptedTactile([(500.0, 1.0)]), on_progress=seen.append)
|
||||||
|
ctrl.run()
|
||||||
|
assert len(seen) == 1
|
||||||
|
assert seen[0].fraction == 0.0
|
||||||
|
assert seen[0].status == "contacting"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 切向通道选择
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectTangential:
|
||||||
|
"""``select_tangential`` 决定装配时"看哪一路切向力"。
|
||||||
|
|
||||||
|
选错通道 = 接触永远检测不到,机械臂会一路压到行程上限 —— 这在真机上可能
|
||||||
|
压坏通止规或工件,所以值得单独测。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_axis_zero_picks_first_channel(self):
|
||||||
|
assert select_tangential(shearx=11.0, sheary=22.0, axis=0) == 11.0
|
||||||
|
|
||||||
|
def test_axis_one_picks_second_channel(self):
|
||||||
|
assert select_tangential(shearx=11.0, sheary=22.0, axis=1) == 22.0
|
||||||
|
|
||||||
|
def test_unexpected_axis_falls_back_to_first(self):
|
||||||
|
"""轴号只可能是 0/1;出现别的值(配置写错)时按 0 处理而不是抛异常。"""
|
||||||
|
assert select_tangential(shearx=11.0, sheary=22.0, axis=7) == 11.0
|
||||||
|
assert select_tangential(shearx=11.0, sheary=22.0, axis=-1) == 11.0
|
||||||
|
|
||||||
|
def test_result_is_always_float(self):
|
||||||
|
"""ROS 消息里是 float32,纯逻辑侧统一成 float,避免 numpy 类型混进来。"""
|
||||||
|
assert isinstance(select_tangential(1, 2, 0), float)
|
||||||
|
|
||||||
|
def test_zero_on_chosen_channel_is_preserved(self):
|
||||||
|
"""选中的那一路恰好是 0(静止)时必须返回 0 —— 这正是"未接触"的正常读数,
|
||||||
|
不能因为 0 是假值就回退到另一路。"""
|
||||||
|
assert select_tangential(shearx=0.0, sheary=99.0, axis=0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 配置解析
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _Goal:
|
||||||
|
"""假的 action goal,字段与 CompliantDescent.Goal 对齐。"""
|
||||||
|
|
||||||
|
def __init__(self, **kw):
|
||||||
|
self.contact_axis = kw.get("contact_axis", 0.0)
|
||||||
|
self.contact_delta = kw.get("contact_delta", 0.0)
|
||||||
|
self.max_normal_delta = kw.get("max_normal_delta", 0.0)
|
||||||
|
self.descent_step_m = kw.get("descent_step_m", 0.0)
|
||||||
|
self.descent_speed_mps = kw.get("descent_speed_mps", 0.0)
|
||||||
|
self.max_descent_m = kw.get("max_descent_m", 0.0)
|
||||||
|
self.settle_sec = kw.get("settle_sec", 0.0)
|
||||||
|
self.timeout_sec = kw.get("timeout_sec", 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigFromGoal:
|
||||||
|
def test_zeros_fall_back_to_defaults(self):
|
||||||
|
cfg = DescentConfig.from_goal(_Goal(contact_delta=250.0))
|
||||||
|
assert cfg.descent_step_m == 0.0005
|
||||||
|
assert cfg.descent_speed_mps == 0.005
|
||||||
|
assert cfg.max_descent_m == 0.05
|
||||||
|
assert cfg.contact_delta == 250.0
|
||||||
|
|
||||||
|
def test_nan_falls_back_to_default(self):
|
||||||
|
cfg = DescentConfig.from_goal(_Goal(descent_step_m=float("nan")))
|
||||||
|
assert cfg.descent_step_m == 0.0005
|
||||||
|
|
||||||
|
def test_step_is_clamped_to_sane_range(self):
|
||||||
|
"""手滑把 mm 当 m 填时要被夹住,不能真的走 5mm 一步。"""
|
||||||
|
big = DescentConfig.from_goal(_Goal(descent_step_m=5.0))
|
||||||
|
assert big.descent_step_m <= 0.01
|
||||||
|
tiny = DescentConfig.from_goal(_Goal(descent_step_m=1e-9))
|
||||||
|
assert tiny.descent_step_m >= 1e-5
|
||||||
|
|
||||||
|
def test_axis_only_accepts_0_or_1(self):
|
||||||
|
assert DescentConfig.from_goal(_Goal(contact_axis=1)).contact_axis == 1
|
||||||
|
assert DescentConfig.from_goal(_Goal(contact_axis=7)).contact_axis == 0
|
||||||
|
|
||||||
|
def test_step_interval_accounts_for_settle(self):
|
||||||
|
cfg = DescentConfig(
|
||||||
|
descent_step_m=0.001, descent_speed_mps=0.01, settle_sec=0.05
|
||||||
|
)
|
||||||
|
assert cfg.step_interval_sec == pytest.approx(0.1 + 0.05)
|
||||||
|
|
||||||
|
def test_step_interval_guards_zero_speed(self):
|
||||||
|
cfg = DescentConfig(descent_speed_mps=0.0, settle_sec=0.03)
|
||||||
|
assert cfg.step_interval_sec == pytest.approx(0.03)
|
||||||
|
|
||||||
|
def test_timeout_derived_when_unspecified(self):
|
||||||
|
cfg = DescentConfig(
|
||||||
|
max_descent_m=0.05, descent_speed_mps=0.01, timeout_sec=0.0
|
||||||
|
)
|
||||||
|
# 名义 5s,留 3 倍余量
|
||||||
|
assert cfg.effective_timeout_sec == pytest.approx(15.0)
|
||||||
|
|
||||||
|
def test_timeout_has_floor(self):
|
||||||
|
cfg = DescentConfig(max_descent_m=0.001, descent_speed_mps=1.0, timeout_sec=0.0)
|
||||||
|
assert cfg.effective_timeout_sec >= 2.0
|
||||||
|
|
||||||
|
def test_explicit_timeout_wins(self):
|
||||||
|
cfg = DescentConfig(timeout_sec=3.5)
|
||||||
|
assert cfg.effective_timeout_sec == pytest.approx(3.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 四元数互转(结果位姿要用)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuaternionRoundTrip:
|
||||||
|
def test_round_trip_arbitrary_rotation(self):
|
||||||
|
R = _quat_xyzw_to_R([0.3, -0.2, 0.5, 0.78])
|
||||||
|
back = _quat_xyzw_to_R(_R_to_quat_xyzw(R))
|
||||||
|
assert np.allclose(R, back, atol=1e-9)
|
||||||
|
|
||||||
|
def test_identity(self):
|
||||||
|
q = _R_to_quat_xyzw(np.eye(3))
|
||||||
|
assert np.allclose(q, [0.0, 0.0, 0.0, 1.0], atol=1e-9)
|
||||||
|
|
||||||
|
def test_180_deg_cases_are_stable(self):
|
||||||
|
"""180° 旋转是 Shepperd 法最容易踩坑的分支(trace <= 0),逐一验证。"""
|
||||||
|
for axis in range(3):
|
||||||
|
R = -np.eye(3)
|
||||||
|
R[axis, axis] = 1.0 # 绕该轴 180°
|
||||||
|
q = _R_to_quat_xyzw(R)
|
||||||
|
assert np.allclose(_quat_xyzw_to_R(q), R, atol=1e-9), f"axis={axis}"
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""``RdkClient`` 的协议层单测。
|
||||||
|
|
||||||
|
为什么值得测
|
||||||
|
------------
|
||||||
|
``RdkClient`` 是驱动与 RDK 之间唯一的通道,而它的失败模式全都很难在真机上
|
||||||
|
复现:桥进程卡住、返回半行 JSON、被 RDK 自身的日志污染 stdout、进程意外退出。
|
||||||
|
这些情况一旦在装配现场发生,表现是"节点卡死"或"莫名其妙的解析错误",
|
||||||
|
排查成本极高。所以这里用一个假桥脚本把这些路径全部走一遍。
|
||||||
|
|
||||||
|
假桥是个真实的子进程(真的走 stdin/stdout 管道与超时逻辑),只是不连机器人。
|
||||||
|
用 ``sys.executable`` 保证在任何环境都能跑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arm_driver_flexiv.rdk_client import RdkBridgeError, RdkClient
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 假桥
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_HANDSHAKE = """
|
||||||
|
def _reply(obj):
|
||||||
|
print(json.dumps(obj))
|
||||||
|
sys.stdout.flush()
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LOOP = """
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
_reply({"ok": False, "error": "bad json"})
|
||||||
|
continue
|
||||||
|
cmd = msg.get("cmd", "")
|
||||||
|
if cmd == "ping":
|
||||||
|
PING()
|
||||||
|
elif cmd == "connect":
|
||||||
|
CONNECT()
|
||||||
|
elif cmd == "quit":
|
||||||
|
_reply({"ok": True})
|
||||||
|
break
|
||||||
|
elif cmd == "hang":
|
||||||
|
pass # 故意不回,用来测超时
|
||||||
|
elif cmd == "half":
|
||||||
|
sys.stdout.write('{"ok": true') # 半行 JSON,永不补全
|
||||||
|
sys.stdout.flush()
|
||||||
|
elif cmd == "noisy":
|
||||||
|
sys.stdout.write("RDK: some native log line\\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
_reply({"ok": True, "after_noise": True})
|
||||||
|
elif cmd == "boom":
|
||||||
|
_reply({"ok": False, "error": "故意失败"})
|
||||||
|
elif cmd == "suicide":
|
||||||
|
sys.exit(7)
|
||||||
|
else:
|
||||||
|
_reply({"ok": True, "cmd": cmd, "echo": msg.get("name", ""),
|
||||||
|
"req": {k: v for k, v in msg.items() if k != "cmd"}})
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MODES = {
|
||||||
|
"ok": dict(ping='_reply({"ok": True, "py": "fake"})',
|
||||||
|
connect='_reply({"ok": True, "sn": msg.get("sn"), "q": [0.0] * 7})'),
|
||||||
|
"connectfail": dict(ping='_reply({"ok": True, "py": "fake"})',
|
||||||
|
connect='_reply({"ok": False, "error": "网线没插"})'),
|
||||||
|
"badjson_on_ping": dict(ping='sys.stdout.write("{not valid json\\n"); sys.stdout.flush()',
|
||||||
|
connect='_reply({"ok": True})'),
|
||||||
|
"die_on_ping": dict(ping="sys.exit(9)", connect='_reply({"ok": True})'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_bridge(tmp_path: Path, mode: str = "ok") -> Path:
|
||||||
|
"""把假桥脚本写进 tmp 目录,返回路径。"""
|
||||||
|
parts = _MODES[mode]
|
||||||
|
src = "import json, sys\n" + _HANDSHAKE + _LOOP
|
||||||
|
src = src.replace("PING()", parts["ping"]).replace("CONNECT()", parts["connect"])
|
||||||
|
path = tmp_path / f"fake_bridge_{mode}.py"
|
||||||
|
path.write_text(src, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def client_for(tmp_path: Path, mode: str = "ok", **kw) -> RdkClient:
|
||||||
|
return RdkClient(
|
||||||
|
sn="Rizon4-TEST",
|
||||||
|
iface="",
|
||||||
|
python=sys.executable,
|
||||||
|
script=make_bridge(tmp_path, mode),
|
||||||
|
log_path=str(tmp_path / "bridge.log"),
|
||||||
|
reply_timeout=kw.pop("reply_timeout", 2.0),
|
||||||
|
connect_timeout=kw.pop("connect_timeout", 5.0),
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 启动与关闭
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLifecycle:
|
||||||
|
def test_start_connects_and_reports_running(self, tmp_path):
|
||||||
|
client = client_for(tmp_path)
|
||||||
|
client.start()
|
||||||
|
try:
|
||||||
|
assert client.is_running
|
||||||
|
finally:
|
||||||
|
client.stop()
|
||||||
|
assert not client.is_running
|
||||||
|
|
||||||
|
def test_start_without_connect_leaves_link_up(self, tmp_path):
|
||||||
|
"""connect=False 用于没有真机的场合:桥要起来,但不连机器人。"""
|
||||||
|
client = client_for(tmp_path)
|
||||||
|
client.start(connect=False)
|
||||||
|
try:
|
||||||
|
assert client.is_running
|
||||||
|
assert client.request("ping")["ok"] is True
|
||||||
|
finally:
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
def test_stop_is_idempotent(self, tmp_path):
|
||||||
|
client = client_for(tmp_path)
|
||||||
|
client.start()
|
||||||
|
client.stop()
|
||||||
|
client.stop() # 不应抛
|
||||||
|
assert not client.is_running
|
||||||
|
|
||||||
|
def test_stop_before_start_is_safe(self, tmp_path):
|
||||||
|
client_for(tmp_path).stop()
|
||||||
|
|
||||||
|
def test_context_manager_closes(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.is_running
|
||||||
|
assert not client.is_running
|
||||||
|
|
||||||
|
def test_missing_interpreter_reports_how_to_fix(self, tmp_path):
|
||||||
|
client = RdkClient(
|
||||||
|
sn="x", python=str(tmp_path / "nope" / "python"), script=make_bridge(tmp_path)
|
||||||
|
)
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.start()
|
||||||
|
# hint 必须可执行:告诉用户设 RDK_PYTHON
|
||||||
|
assert "RDK_PYTHON" in exc.value.hint
|
||||||
|
|
||||||
|
def test_missing_script_reports_where_to_look(self, tmp_path):
|
||||||
|
client = RdkClient(
|
||||||
|
sn="x", python=sys.executable, script=tmp_path / "nope.py"
|
||||||
|
)
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.start()
|
||||||
|
assert "SITE_ROS2_ROOT" in exc.value.hint
|
||||||
|
|
||||||
|
def test_connect_failure_includes_operator_hint(self, tmp_path):
|
||||||
|
client = client_for(tmp_path, "connectfail")
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.start()
|
||||||
|
assert "网线没插" in str(exc.value)
|
||||||
|
assert "示教器" in exc.value.hint or "AUTO" in exc.value.hint
|
||||||
|
|
||||||
|
def test_connect_failure_cleans_up_the_process(self, tmp_path):
|
||||||
|
"""连接失败后不能留下孤儿桥进程 —— 否则 RDK 的独占访问会被它占住。"""
|
||||||
|
client = client_for(tmp_path, "connectfail")
|
||||||
|
with pytest.raises(RdkBridgeError):
|
||||||
|
client.start()
|
||||||
|
assert not client.is_running
|
||||||
|
assert client._proc is None # noqa: SLF001
|
||||||
|
|
||||||
|
def test_dying_bridge_on_start_is_reported(self, tmp_path):
|
||||||
|
client = client_for(tmp_path, "die_on_ping")
|
||||||
|
with pytest.raises(RdkBridgeError):
|
||||||
|
client.start()
|
||||||
|
|
||||||
|
def test_bad_json_from_bridge_is_reported(self, tmp_path):
|
||||||
|
"""RDK 的原生日志混进 stdout 会破坏协议 —— 报错要指向桥日志。"""
|
||||||
|
client = client_for(tmp_path, "badjson_on_ping")
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.start()
|
||||||
|
assert "非 JSON" in str(exc.value)
|
||||||
|
assert "bridge.log" in str(exc.value)
|
||||||
|
|
||||||
|
def test_request_before_start_raises(self, tmp_path):
|
||||||
|
client = client_for(tmp_path)
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.request("ping")
|
||||||
|
assert "未运行" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 协议交互
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtocol:
|
||||||
|
def test_request_returns_parsed_dict(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
reply = client.request("ping")
|
||||||
|
assert reply == {"ok": True, "py": "fake"}
|
||||||
|
|
||||||
|
def test_unknown_command_still_round_trips(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
reply = client.request("whatever", name="hello")
|
||||||
|
assert reply["ok"] is True
|
||||||
|
assert reply["echo"] == "hello"
|
||||||
|
|
||||||
|
def test_request_ok_raises_on_failure(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.request_ok("boom")
|
||||||
|
assert "故意失败" in str(exc.value)
|
||||||
|
assert "bridge.log" in exc.value.hint
|
||||||
|
|
||||||
|
def test_request_without_ok_flag_does_not_raise(self, tmp_path):
|
||||||
|
"""``request`` 是低层的,不替调用方判断 ok —— 有些命令要自己看字段。"""
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
reply = client.request("boom")
|
||||||
|
assert reply["ok"] is False
|
||||||
|
|
||||||
|
def test_timeout_on_silent_bridge(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.request("hang", timeout=0.4)
|
||||||
|
assert "超时" in str(exc.value)
|
||||||
|
|
||||||
|
def test_timeout_mentions_log_path(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.request("hang", timeout=0.3)
|
||||||
|
assert "bridge.log" in exc.value.hint
|
||||||
|
|
||||||
|
def test_half_line_json_is_not_accepted(self, tmp_path):
|
||||||
|
"""只收到半行时不能当成应答 —— 必须等到换行或超时。"""
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.request("half", timeout=0.4)
|
||||||
|
assert "超时" in str(exc.value)
|
||||||
|
|
||||||
|
def test_native_log_lines_before_json_are_skipped(self, tmp_path):
|
||||||
|
"""RDK 会把原生日志写到 stderr,但万一混进 stdout,非 JSON 行要能跳过
|
||||||
|
并继续读到真正的 JSON 应答,而不是直接判协议损坏。"""
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
reply = client.request("noisy", timeout=1.0)
|
||||||
|
assert reply["after_noise"] is True
|
||||||
|
|
||||||
|
def test_bridge_death_during_request_is_detected(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
with pytest.raises(RdkBridgeError) as exc:
|
||||||
|
client.request("suicide", timeout=1.5)
|
||||||
|
assert "已退出" in str(exc.value)
|
||||||
|
|
||||||
|
def test_reply_after_bridge_death_is_refused(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
with pytest.raises(RdkBridgeError):
|
||||||
|
client.request("suicide", timeout=1.5)
|
||||||
|
# 进程已死,后续请求必须立刻失败而不是挂住
|
||||||
|
with pytest.raises(RdkBridgeError):
|
||||||
|
client.request("ping", timeout=0.5)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 便捷方法是否转发到正确的命令名
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvenienceWrappers:
|
||||||
|
def test_enable_sends_enable(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.enable()["cmd"] == "enable"
|
||||||
|
|
||||||
|
def test_clear_fault_sends_clear_fault(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.clear_fault()["cmd"] == "clear_fault"
|
||||||
|
|
||||||
|
def test_status_sends_status(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.status()["cmd"] == "status"
|
||||||
|
|
||||||
|
def test_switch_tool_forwards_the_name(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.switch_tool("tool1")["echo"] == "tool1"
|
||||||
|
|
||||||
|
def test_switch_tool_with_empty_name_queries_only(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.switch_tool("")["echo"] == ""
|
||||||
|
|
||||||
|
def test_drag_on_forwards_mode(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.drag_on("floating")["req"]["mode"] == "floating"
|
||||||
|
|
||||||
|
def test_drag_off_sends_drag_off(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
assert client.drag_off()["cmd"] == "drag_off"
|
||||||
|
|
||||||
|
def test_joints_reads_the_q_list(self, tmp_path):
|
||||||
|
with client_for(tmp_path) as client:
|
||||||
|
q = client.joints()
|
||||||
|
# 假桥对未知命令回 {"ok":True,"cmd":"joints","echo":""},没有 q 字段,
|
||||||
|
# 所以这里应当得到空列表而不是抛异常。
|
||||||
|
assert q == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 默认值解析
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
def test_default_python_is_overridable_by_env(self, monkeypatch):
|
||||||
|
from arm_driver_flexiv import rdk_client
|
||||||
|
|
||||||
|
monkeypatch.setenv("RDK_PYTHON", "/opt/custom/python")
|
||||||
|
assert rdk_client.default_rdk_python() == "/opt/custom/python"
|
||||||
|
|
||||||
|
def test_default_python_falls_back_to_hardcoded(self, monkeypatch):
|
||||||
|
from arm_driver_flexiv import rdk_client
|
||||||
|
|
||||||
|
monkeypatch.delenv("RDK_PYTHON", raising=False)
|
||||||
|
assert rdk_client.default_rdk_python() == rdk_client.DEFAULT_RDK_PYTHON
|
||||||
|
|
||||||
|
def test_bridge_script_follows_workspace_env(self, monkeypatch):
|
||||||
|
from arm_driver_flexiv import rdk_client
|
||||||
|
|
||||||
|
monkeypatch.setenv("SITE_ROS2_ROOT", "/tmp/other_ws")
|
||||||
|
assert str(rdk_client.default_bridge_script()) == str(
|
||||||
|
Path("/tmp/other_ws") / rdk_client.BRIDGE_SCRIPT_REL
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_error_str_includes_hint(self):
|
||||||
|
err = RdkBridgeError("出错了", hint="这么修")
|
||||||
|
assert "出错了" in str(err)
|
||||||
|
assert "这么修" in str(err)
|
||||||
|
|
||||||
|
def test_error_without_hint_is_just_the_message(self):
|
||||||
|
assert str(RdkBridgeError("只有消息")) == "只有消息"
|
||||||
@@ -57,11 +57,11 @@ ROBOT_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
|||||||
"compliant_descent": CapabilityInfo(
|
"compliant_descent": CapabilityInfo(
|
||||||
"robot", "柔顺下臂 —— 带接触力约束的下压。装配接触阶段(S07)依赖它。"
|
"robot", "柔顺下臂 —— 带接触力约束的下压。装配接触阶段(S07)依赖它。"
|
||||||
),
|
),
|
||||||
"set_tool": CapabilityInfo(
|
|
||||||
"robot", "设置工具坐标系(TCP + 负载)。全局工具系锁定依赖它。"
|
|
||||||
),
|
|
||||||
"switch_tool": CapabilityInfo(
|
"switch_tool": CapabilityInfo(
|
||||||
"robot", "在已保存的工具坐标系之间切换(如切到 tool1)。"
|
"robot",
|
||||||
|
"切换到已保存的工具坐标系(如切到 tool1)。"
|
||||||
|
"RDK 只提供按名字切换(Tool.Switch),工具的参数(质量/质心/TCP)在 Studio 里预配好。"
|
||||||
|
"全局工具系锁定依赖它。",
|
||||||
),
|
),
|
||||||
"home": CapabilityInfo("robot", "回到初始位姿。每个装配循环开始与异常复位依赖它。"),
|
"home": CapabilityInfo("robot", "回到初始位姿。每个装配循环开始与异常复位依赖它。"),
|
||||||
"enable": CapabilityInfo("robot", "伺服使能。启动自检依赖它。"),
|
"enable": CapabilityInfo("robot", "伺服使能。启动自检依赖它。"),
|
||||||
@@ -159,7 +159,6 @@ ROS_INTERFACE: Dict[str, RosInterface] = {
|
|||||||
"compliant_descent": RosInterface(
|
"compliant_descent": RosInterface(
|
||||||
"", "compliant_descent", "action", "arm_msgs/action/CompliantDescent"
|
"", "compliant_descent", "action", "arm_msgs/action/CompliantDescent"
|
||||||
),
|
),
|
||||||
"set_tool": RosInterface("", "set_tool", "service", "arm_msgs/srv/SetTool"),
|
|
||||||
"switch_tool": RosInterface("", "switch_tool", "service", "arm_msgs/srv/SwitchTool"),
|
"switch_tool": RosInterface("", "switch_tool", "service", "arm_msgs/srv/SwitchTool"),
|
||||||
"home": RosInterface("", "home", "service", "std_srvs/srv/Trigger"),
|
"home": RosInterface("", "home", "service", "std_srvs/srv/Trigger"),
|
||||||
"enable": RosInterface("", "enable", "service", "std_srvs/srv/SetBool"),
|
"enable": RosInterface("", "enable", "service", "std_srvs/srv/SetBool"),
|
||||||
@@ -235,7 +234,6 @@ REQUIREMENTS: Dict[str, Requirement] = {
|
|||||||
"set_named_pose",
|
"set_named_pose",
|
||||||
"servo_cartesian",
|
"servo_cartesian",
|
||||||
"compliant_descent",
|
"compliant_descent",
|
||||||
"set_tool",
|
|
||||||
"switch_tool",
|
"switch_tool",
|
||||||
"home",
|
"home",
|
||||||
"enable",
|
"enable",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""纯几何 / 运动学工具(只依赖 numpy,不依赖 ROS)。
|
||||||
|
|
||||||
|
目前提供基于 TF 实测位形的几何雅可比与阻尼最小二乘逆解,
|
||||||
|
见 :mod:`arm_kinematics.geom_ik`。
|
||||||
|
|
||||||
|
单独成包的动机:这套东西同时被应用层(视觉伺服、搜孔装配)和驱动层
|
||||||
|
(柔顺下压)需要。原先它放在 ``arm_application`` 里,导致驱动层要反过来依赖
|
||||||
|
应用层 —— 层次倒置。提取到这里之后两边都依赖一个无 ROS 依赖的底层包。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .geom_ik import (
|
||||||
|
RIZON_LINK_AXES,
|
||||||
|
dls_position_step,
|
||||||
|
dls_translate_hold_ori,
|
||||||
|
dls_twist,
|
||||||
|
geometric_jacobian,
|
||||||
|
load_handeye_T,
|
||||||
|
position_jacobian,
|
||||||
|
quat_xyzw_to_R,
|
||||||
|
)
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RIZON_LINK_AXES",
|
||||||
|
"quat_xyzw_to_R",
|
||||||
|
"geometric_jacobian",
|
||||||
|
"position_jacobian",
|
||||||
|
"dls_position_step",
|
||||||
|
"dls_twist",
|
||||||
|
"dls_translate_hold_ori",
|
||||||
|
"load_handeye_T",
|
||||||
|
]
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""基于 TF 实测位形的几何雅可比 + 阻尼最小二乘(DLS)位置级逆解。
|
||||||
|
|
||||||
|
为什么不需要 URDF / 正运动学
|
||||||
|
---------------------------
|
||||||
|
本模块做的是**速度级**逆解,不是位置级解析逆解:
|
||||||
|
|
||||||
|
1. 从 TF 直接读每个关节 link 的当前位姿(``link_R`` / ``link_p``)
|
||||||
|
2. 用固定的关节轴(``RIZON_LINK_AXES``,来自 Rizon4 URDF)构建几何雅可比 J
|
||||||
|
3. 解 ``J dq = dp`` 得到小步 ``dq``,积分即可
|
||||||
|
|
||||||
|
雅可比只是**当前位形**的函数,所以用 TF 实测位形算它是正确且实时的;不需要
|
||||||
|
"给定关节角预测末端位姿"的正运动学。代价是只能走小步(大位移要迭代多次),
|
||||||
|
而这恰好符合装配场景的需求 —— 每次挪 1mm 以内,边动边看力反馈。
|
||||||
|
|
||||||
|
依赖:只依赖 numpy。不 import ROS,因此可被驱动层与测试直接复用。
|
||||||
|
|
||||||
|
坐标系约定:所有输入输出都在**基座系**,末端位置 ``ee_p`` 与各 link 位置
|
||||||
|
``link_p`` 必须同一参考系。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
#: Rizon4 各关节轴在**子 link 系**下的表达(来自 URDF)。
|
||||||
|
#: 顺序对应 joint1..joint7 —— 相邻关节轴交替,标准 7 自由度布局。
|
||||||
|
RIZON_LINK_AXES = [
|
||||||
|
(0.0, 0.0, 1.0), # joint1 -> link1
|
||||||
|
(0.0, 1.0, 0.0), # joint2 -> link2
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
(0.0, 1.0, 0.0),
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
(0.0, 1.0, 0.0),
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def quat_xyzw_to_R(q: Sequence[float]) -> np.ndarray:
|
||||||
|
"""四元数 (x, y, z, w) 转 3x3 旋转矩阵。自动归一化,零四元数也不炸。"""
|
||||||
|
x, y, z, w = [float(v) for v in q]
|
||||||
|
n = np.sqrt(x * x + y * y + z * z + w * w) + 1e-12
|
||||||
|
x, y, z, w = x / n, y / n, z / n, w / n
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||||
|
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||||
|
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def geometric_jacobian(
|
||||||
|
link_R: Sequence[np.ndarray],
|
||||||
|
link_p: Sequence[np.ndarray],
|
||||||
|
ee_p: np.ndarray,
|
||||||
|
axes_local: Sequence[Tuple[float, float, float]] = RIZON_LINK_AXES,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""构建 6x7 基座系几何雅可比。前 3 行是线速度,后 3 行是角速度。
|
||||||
|
|
||||||
|
参数
|
||||||
|
----
|
||||||
|
link_R:
|
||||||
|
各关节 link 在基座系下的 3x3 姿态,长度 = 关节数。
|
||||||
|
link_p:
|
||||||
|
各关节 link 在基座系下的 3 维位置。
|
||||||
|
ee_p:
|
||||||
|
末端(法兰)在基座系下的 3 维位置。
|
||||||
|
"""
|
||||||
|
n = len(axes_local)
|
||||||
|
J = np.zeros((6, n), dtype=float)
|
||||||
|
pee = np.asarray(ee_p, dtype=float).reshape(3)
|
||||||
|
for i in range(n):
|
||||||
|
R = np.asarray(link_R[i], dtype=float).reshape(3, 3)
|
||||||
|
p = np.asarray(link_p[i], dtype=float).reshape(3)
|
||||||
|
z = R @ np.asarray(axes_local[i], dtype=float).reshape(3)
|
||||||
|
# 平动部分:关节轴 × (末端 − 关节点);转动部分:关节轴本身
|
||||||
|
J[:3, i] = np.cross(z, pee - p)
|
||||||
|
J[3:, i] = z
|
||||||
|
return J
|
||||||
|
|
||||||
|
|
||||||
|
def position_jacobian(
|
||||||
|
link_R: Sequence[np.ndarray],
|
||||||
|
link_p: Sequence[np.ndarray],
|
||||||
|
ee_p: np.ndarray,
|
||||||
|
axes_local: Sequence[Tuple[float, float, float]] = RIZON_LINK_AXES,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""只取几何雅可比的平动部分(3x7)。"""
|
||||||
|
return geometric_jacobian(link_R, link_p, ee_p, axes_local)[:3, :]
|
||||||
|
|
||||||
|
|
||||||
|
def dls_position_step(
|
||||||
|
J: np.ndarray,
|
||||||
|
dp_base: np.ndarray,
|
||||||
|
*,
|
||||||
|
damping: float = 0.05,
|
||||||
|
max_dq: float = 0.05,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""解 ``J dq = dp``(阻尼最小二乘),并把 ``|dq|`` 逐元素限幅。
|
||||||
|
|
||||||
|
阻尼项 ``λ²I`` 的作用:接近奇异位形时避免关节速度爆掉。代价是有跟踪误差,
|
||||||
|
但对小幅步进(毫米级)可以忽略。
|
||||||
|
"""
|
||||||
|
dp = np.asarray(dp_base, dtype=float).reshape(3)
|
||||||
|
JT = J.T
|
||||||
|
A = J @ JT + (damping ** 2) * np.eye(3)
|
||||||
|
dq = JT @ np.linalg.solve(A, dp)
|
||||||
|
dq = np.clip(dq, -abs(max_dq), abs(max_dq))
|
||||||
|
return dq
|
||||||
|
|
||||||
|
|
||||||
|
def dls_twist(
|
||||||
|
J6: np.ndarray,
|
||||||
|
v_base: np.ndarray,
|
||||||
|
*,
|
||||||
|
w_base: Optional[np.ndarray] = None,
|
||||||
|
damping: float = 0.08,
|
||||||
|
max_qdot: float = 0.80,
|
||||||
|
ori_weight: float = 1.2,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""解 ``J qdot = [v; w]``,默认把角速度压到 0(保持姿态)。
|
||||||
|
|
||||||
|
``ori_weight`` 给姿态分量额外的权重,让姿态在数值上更"硬",减少平移时
|
||||||
|
末端姿态的漂移。
|
||||||
|
"""
|
||||||
|
dx = np.zeros(6, dtype=float)
|
||||||
|
dx[:3] = np.asarray(v_base, dtype=float).reshape(3)
|
||||||
|
if w_base is not None:
|
||||||
|
dx[3:] = np.asarray(w_base, dtype=float).reshape(3)
|
||||||
|
w = np.array([1.0, 1.0, 1.0, ori_weight, ori_weight, ori_weight], dtype=float)
|
||||||
|
Jw = J6 * w[:, None]
|
||||||
|
dxw = dx * w
|
||||||
|
JT = Jw.T
|
||||||
|
A = Jw @ JT + (damping ** 2) * np.eye(6)
|
||||||
|
qdot = JT @ np.linalg.solve(A, dxw)
|
||||||
|
return np.clip(qdot, -abs(max_qdot), abs(max_qdot))
|
||||||
|
|
||||||
|
|
||||||
|
def dls_translate_hold_ori(
|
||||||
|
J6: np.ndarray,
|
||||||
|
dp_base: np.ndarray,
|
||||||
|
*,
|
||||||
|
damping: float = 0.08,
|
||||||
|
max_dq: float = 0.05,
|
||||||
|
ori_weight: float = 1.2,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""平移末端 ``dp`` 同时保持姿态。"""
|
||||||
|
dx = np.zeros(6, dtype=float)
|
||||||
|
dx[:3] = np.asarray(dp_base, dtype=float).reshape(3)
|
||||||
|
w = np.array([1.0, 1.0, 1.0, ori_weight, ori_weight, ori_weight], dtype=float)
|
||||||
|
Jw = J6 * w[:, None]
|
||||||
|
dxw = dx * w
|
||||||
|
JT = Jw.T
|
||||||
|
A = Jw @ JT + (damping ** 2) * np.eye(6)
|
||||||
|
dq = JT @ np.linalg.solve(A, dxw)
|
||||||
|
return np.clip(dq, -abs(max_dq), abs(max_dq))
|
||||||
|
|
||||||
|
|
||||||
|
def load_handeye_T(path: str) -> np.ndarray:
|
||||||
|
"""从手眼结果 YAML 里读 4x4 齐次变换矩阵(键名 ``T_4x4``)。"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
if not isinstance(data, dict) or "T_4x4" not in data:
|
||||||
|
raise ValueError(f"No T_4x4 in hand-eye file: {path}")
|
||||||
|
T = np.array(data["T_4x4"], dtype=float)
|
||||||
|
if T.shape != (4, 4):
|
||||||
|
raise ValueError(f"T_4x4 shape {T.shape}, expected (4,4)")
|
||||||
|
return T
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RIZON_LINK_AXES",
|
||||||
|
"quat_xyzw_to_R",
|
||||||
|
"geometric_jacobian",
|
||||||
|
"position_jacobian",
|
||||||
|
"dls_position_step",
|
||||||
|
"dls_twist",
|
||||||
|
"dls_translate_hold_ori",
|
||||||
|
"load_handeye_T",
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>arm_kinematics</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>
|
||||||
|
纯几何 / 运动学工具:基于 TF 实测位形的几何雅可比与阻尼最小二乘逆解。
|
||||||
|
只依赖 numpy,不依赖 ROS —— 因此应用层(视觉伺服)与驱动层(柔顺下压)
|
||||||
|
都能复用它,而不会形成层次倒置。
|
||||||
|
</description>
|
||||||
|
<maintainer email="dev@example.com">arm_dev</maintainer>
|
||||||
|
<license>Apache-2.0</license>
|
||||||
|
|
||||||
|
<exec_depend>python3-numpy</exec_depend>
|
||||||
|
<exec_depend>python3-yaml</exec_depend>
|
||||||
|
|
||||||
|
<test_depend>python3-pytest</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[tool:pytest]
|
||||||
|
# ROS 2 Humble 的两个 launch-testing pytest 插件用了旧版 hook 签名,
|
||||||
|
# 当前 pytest 会在收集阶段直接报错。本包只跑纯 Python 单测,禁掉它们。
|
||||||
|
addopts = -p no:launch_testing -p no:launch_ros
|
||||||
|
testpaths = test
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
|
package_name = 'arm_kinematics'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.1.0',
|
||||||
|
packages=find_packages(exclude=['test']),
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools'],
|
||||||
|
zip_safe=True,
|
||||||
|
maintainer='arm_dev',
|
||||||
|
maintainer_email='dev@example.com',
|
||||||
|
description='纯几何 / 运动学工具(几何雅可比 + DLS 逆解)',
|
||||||
|
license='Apache-2.0',
|
||||||
|
tests_require=['pytest'],
|
||||||
|
)
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""``arm_kinematics.geom_ik`` 的数学单测。
|
||||||
|
|
||||||
|
这些函数是整个运动链路的地基(视觉伺服、搜孔装配、柔顺下压都走它),
|
||||||
|
但它们的正确性可以完全脱离硬件验证 —— 用已知的几何算例对答案即可。
|
||||||
|
所以这里覆盖得比较细,尤其是不变量(限幅、奇异不炸、零输入)和解析可算的例子。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arm_kinematics.geom_ik import (
|
||||||
|
RIZON_LINK_AXES,
|
||||||
|
dls_position_step,
|
||||||
|
dls_translate_hold_ori,
|
||||||
|
dls_twist,
|
||||||
|
geometric_jacobian,
|
||||||
|
position_jacobian,
|
||||||
|
quat_xyzw_to_R,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 四元数
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestQuatToR:
|
||||||
|
def test_identity(self):
|
||||||
|
assert np.allclose(quat_xyzw_to_R([0.0, 0.0, 0.0, 1.0]), np.eye(3))
|
||||||
|
|
||||||
|
def test_90_deg_about_z(self):
|
||||||
|
"""90° 绕 z:(x,y,z,w) = (0,0,sin45°,cos45°) → x 轴转到 y 轴。"""
|
||||||
|
s = math.sin(math.pi / 4)
|
||||||
|
c = math.cos(math.pi / 4)
|
||||||
|
R = quat_xyzw_to_R([0.0, 0.0, s, c])
|
||||||
|
assert np.allclose(R @ np.array([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0], atol=1e-9)
|
||||||
|
|
||||||
|
def test_unnormalised_input_is_normalised(self):
|
||||||
|
"""未归一化的四元数也应当得到合法旋转(很多驱动给的就是未归一化的)。"""
|
||||||
|
R = quat_xyzw_to_R([0.0, 0.0, 2.0, 2.0]) # 2x 的 90° 绕 z
|
||||||
|
assert np.allclose(R @ np.array([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0], atol=1e-9)
|
||||||
|
|
||||||
|
def test_zero_quaternion_does_not_explode(self):
|
||||||
|
"""零四元数(数据缺失)必须给出有限值,不能产生 NaN/Inf。"""
|
||||||
|
R = quat_xyzw_to_R([0.0, 0.0, 0.0, 0.0])
|
||||||
|
assert np.all(np.isfinite(R))
|
||||||
|
|
||||||
|
def test_result_is_orthonormal(self):
|
||||||
|
R = quat_xyzw_to_R([0.3, -0.2, 0.5, 0.78])
|
||||||
|
assert np.allclose(R @ R.T, np.eye(3), atol=1e-9)
|
||||||
|
assert np.isclose(np.linalg.det(R), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 几何雅可比
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGeometricJacobian:
|
||||||
|
def test_single_revolute_about_z(self):
|
||||||
|
"""单关节绕 z 轴、关节点在原点、末端在 +x 1m 处。
|
||||||
|
|
||||||
|
关节轴 z=(0,0,1),臂向量 pee - p = (1,0,0),
|
||||||
|
故平动雅可比 = z × arm = (0,1,0):关节正转使末端朝 +y 移动。
|
||||||
|
"""
|
||||||
|
J = geometric_jacobian(
|
||||||
|
link_R=[np.eye(3)],
|
||||||
|
link_p=[np.zeros(3)],
|
||||||
|
ee_p=np.array([1.0, 0.0, 0.0]),
|
||||||
|
axes_local=[(0.0, 0.0, 1.0)],
|
||||||
|
)
|
||||||
|
assert J.shape == (6, 1)
|
||||||
|
assert np.allclose(J[:3, 0], [0.0, 1.0, 0.0], atol=1e-12)
|
||||||
|
assert np.allclose(J[3:, 0], [0.0, 0.0, 1.0], atol=1e-12)
|
||||||
|
|
||||||
|
def test_axis_follows_link_rotation(self):
|
||||||
|
"""关节轴要跟着 link 的姿态转:link 绕 z 转 90° 后,关节轴仍在 z 上
|
||||||
|
(因为轴本身就是 z),但绕 x 轴转 90° 后轴应变成 -y。"""
|
||||||
|
s = math.sin(math.pi / 4)
|
||||||
|
c = math.cos(math.pi / 4)
|
||||||
|
R = quat_xyzw_to_R([s, 0.0, 0.0, c]) # 绕 x 转 90°:(0,0,1) -> (0,-1,0)
|
||||||
|
J = geometric_jacobian(
|
||||||
|
link_R=[R],
|
||||||
|
link_p=[np.zeros(3)],
|
||||||
|
ee_p=np.array([1.0, 0.0, 0.0]),
|
||||||
|
axes_local=[(0.0, 0.0, 1.0)],
|
||||||
|
)
|
||||||
|
assert np.allclose(J[3:, 0], [0.0, -1.0, 0.0], atol=1e-9)
|
||||||
|
|
||||||
|
def test_position_jacobian_is_the_linear_block(self):
|
||||||
|
args = dict(
|
||||||
|
link_R=[np.eye(3), np.eye(3)],
|
||||||
|
link_p=[np.zeros(3), np.array([0.0, 0.0, 0.3])],
|
||||||
|
ee_p=np.array([0.4, 0.0, 0.5]),
|
||||||
|
axes_local=[(0.0, 0.0, 1.0), (0.0, 1.0, 0.0)],
|
||||||
|
)
|
||||||
|
assert np.allclose(
|
||||||
|
position_jacobian(**args), geometric_jacobian(**args)[:3, :]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_seven_axis_shape(self):
|
||||||
|
"""真实 Rizon4 是 7 轴;轴表长度必须与之一致,否则雅可比会错位。"""
|
||||||
|
assert len(RIZON_LINK_AXES) == 7
|
||||||
|
J = geometric_jacobian(
|
||||||
|
link_R=[np.eye(3)] * 7,
|
||||||
|
link_p=[np.zeros(3)] * 7,
|
||||||
|
ee_p=np.array([0.5, 0.0, 0.5]),
|
||||||
|
)
|
||||||
|
assert J.shape == (6, 7)
|
||||||
|
|
||||||
|
def test_axis_table_matches_axis_count(self):
|
||||||
|
"""axes_local 长度决定雅可比列数,传错长度应当反映在形状上(不静默出错)。"""
|
||||||
|
J = geometric_jacobian(
|
||||||
|
link_R=[np.eye(3)] * 3,
|
||||||
|
link_p=[np.zeros(3)] * 3,
|
||||||
|
ee_p=np.array([0.1, 0.0, 0.1]),
|
||||||
|
axes_local=[(0.0, 0.0, 1.0)] * 3,
|
||||||
|
)
|
||||||
|
assert J.shape == (6, 3)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DLS 逆解
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDlsPositionStep:
|
||||||
|
def test_recovers_known_solution(self):
|
||||||
|
"""J = [[0],[1],[0]],要求末端 +y 移动 0.1m → dq ≈ 0.1(阻尼带来的
|
||||||
|
误差极小)。"""
|
||||||
|
J = np.array([[0.0], [1.0], [0.0]])
|
||||||
|
dq = dls_position_step(J, np.array([0.0, 0.1, 0.0]), damping=0.05, max_dq=1.0)
|
||||||
|
assert dq.shape == (1,)
|
||||||
|
assert abs(float(dq[0]) - 0.1) < 1e-3
|
||||||
|
|
||||||
|
def test_direction_is_correct(self):
|
||||||
|
"""要求 +y 移动,关节也必须朝正方向走(符号不能反)。"""
|
||||||
|
J = np.array([[0.0], [1.0], [0.0]])
|
||||||
|
dq = dls_position_step(J, np.array([0.0, 0.05, 0.0]))
|
||||||
|
assert float(dq[0]) > 0
|
||||||
|
|
||||||
|
def test_limits_large_step(self):
|
||||||
|
J = np.array([[0.0], [1.0], [0.0]])
|
||||||
|
dq = dls_position_step(J, np.array([0.0, 10.0, 0.0]), max_dq=0.02)
|
||||||
|
assert abs(float(dq[0])) <= 0.02 + 1e-12
|
||||||
|
|
||||||
|
def test_singular_jacobian_does_not_explode(self):
|
||||||
|
"""奇异位形(J 全零)必须给出零步长而不是 NaN/Inf —— 阻尼项就是为了这个。"""
|
||||||
|
J = np.zeros((3, 7))
|
||||||
|
dq = dls_position_step(J, np.array([0.1, 0.0, 0.0]))
|
||||||
|
assert np.all(np.isfinite(dq))
|
||||||
|
assert np.allclose(dq, 0.0)
|
||||||
|
|
||||||
|
def test_near_singular_is_bounded(self):
|
||||||
|
"""接近奇异时解会被阻尼压住,不应出现巨大关节速度。"""
|
||||||
|
J = np.array([[1e-8], [1e-8], [1e-8]])
|
||||||
|
dq = dls_position_step(J, np.array([0.01, 0.01, 0.01]), max_dq=0.5)
|
||||||
|
assert np.all(np.isfinite(dq))
|
||||||
|
assert abs(float(dq[0])) <= 0.5
|
||||||
|
|
||||||
|
def test_zero_displacement_gives_zero_step(self):
|
||||||
|
J = np.array([[0.0], [1.0], [0.0]])
|
||||||
|
dq = dls_position_step(J, np.zeros(3))
|
||||||
|
assert np.allclose(dq, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDlsTwist:
|
||||||
|
def test_shape_matches_joint_count(self):
|
||||||
|
dq = dls_twist(np.zeros((6, 7)), np.zeros(3))
|
||||||
|
assert dq.shape == (7,)
|
||||||
|
|
||||||
|
def test_respects_qdot_limit(self):
|
||||||
|
J = np.zeros((6, 7))
|
||||||
|
J[0, 0] = 1.0
|
||||||
|
J[1, 1] = 1.0
|
||||||
|
J[2, 2] = 1.0
|
||||||
|
qdot = dls_twist(J, np.array([5.0, 5.0, 5.0]), max_qdot=0.3)
|
||||||
|
assert np.all(np.abs(qdot) <= 0.3 + 1e-12)
|
||||||
|
|
||||||
|
def test_orientation_hold_keeps_angular_velocity_small(self):
|
||||||
|
"""默认 w=0 且姿态权重更高,角运动分量应当明显小于平动分量。"""
|
||||||
|
J = np.zeros((6, 3))
|
||||||
|
J[0, 0] = 1.0 # 关节0 产生 x 平动
|
||||||
|
J[4, 1] = 1.0 # 关节1 产生绕 y 转动
|
||||||
|
qdot = dls_twist(J, np.array([0.1, 0.0, 0.0]), max_qdot=1.0)
|
||||||
|
assert abs(float(qdot[1])) < abs(float(qdot[0]))
|
||||||
|
|
||||||
|
def test_explicit_angular_command_is_applied(self):
|
||||||
|
J = np.zeros((6, 1))
|
||||||
|
J[5, 0] = 1.0 # 关节0 绕 z 转动
|
||||||
|
qdot = dls_twist(J, np.zeros(3), w_base=np.array([0.0, 0.0, 0.2]))
|
||||||
|
assert float(qdot[0]) > 0
|
||||||
|
|
||||||
|
def test_singular_input_is_finite(self):
|
||||||
|
qdot = dls_twist(np.zeros((6, 7)), np.array([0.1, 0.1, 0.1]))
|
||||||
|
assert np.all(np.isfinite(qdot))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDlsTranslateHoldOri:
|
||||||
|
def test_pure_translation_command_ignores_rotation(self):
|
||||||
|
"""只给平动分量时,转动通道不应当被激活。"""
|
||||||
|
J = np.zeros((6, 2))
|
||||||
|
J[0, 0] = 1.0
|
||||||
|
J[5, 1] = 1.0 # 只有关节1 影响转动
|
||||||
|
dq = dls_translate_hold_ori(J, np.array([0.05, 0.0, 0.0]), max_dq=1.0)
|
||||||
|
assert abs(float(dq[1])) < 1e-6
|
||||||
|
|
||||||
|
def test_translation_direction(self):
|
||||||
|
J = np.zeros((6, 1))
|
||||||
|
J[2, 0] = 1.0
|
||||||
|
dq = dls_translate_hold_ori(J, np.array([0.0, 0.0, -0.02]), max_dq=1.0)
|
||||||
|
assert float(dq[0]) < 0
|
||||||
|
|
||||||
|
def test_limit_applies(self):
|
||||||
|
J = np.zeros((6, 1))
|
||||||
|
J[2, 0] = 1.0
|
||||||
|
dq = dls_translate_hold_ori(J, np.array([0.0, 0.0, -1.0]), max_dq=0.01)
|
||||||
|
assert abs(float(dq[0])) <= 0.01 + 1e-12
|
||||||
@@ -23,8 +23,10 @@ rosidl_generate_interfaces(${PROJECT_NAME}
|
|||||||
"srv/SetNamedPose.srv"
|
"srv/SetNamedPose.srv"
|
||||||
"srv/GetArmState.srv"
|
"srv/GetArmState.srv"
|
||||||
"srv/GripperExecute.srv"
|
"srv/GripperExecute.srv"
|
||||||
|
"srv/SwitchTool.srv"
|
||||||
"action/MoveToPose.action"
|
"action/MoveToPose.action"
|
||||||
"action/FollowJointTrajectory.action"
|
"action/FollowJointTrajectory.action"
|
||||||
|
"action/CompliantDescent.action"
|
||||||
DEPENDENCIES std_msgs geometry_msgs builtin_interfaces sensor_msgs
|
DEPENDENCIES std_msgs geometry_msgs builtin_interfaces sensor_msgs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 柔顺下臂:从当前悬停位姿沿工具系 −Z 缓慢下压,直到触觉检测到接触。
|
||||||
|
#
|
||||||
|
# 通止规装配 S07(下臂接触)专用原语。
|
||||||
|
#
|
||||||
|
# 为什么不用 RDK 的柔顺模式:非夕 RDK 的 compliant motion 会接管整臂阻抗,
|
||||||
|
# 而装配的其它阶段(跟踪 / 对准 / 拧入)都走 JTC 位置控制;在两种控制权之间
|
||||||
|
# 来回切换代价高、掉电风险大。这里用「小幅步进 + 力反馈」在应用层做等效:
|
||||||
|
# 每步 0.2~1.0 mm,步间等稳定,一旦切向力相对基线跳变立即停住。
|
||||||
|
# 对通止规这种「碰到就必须停」的场景足够,而且任何一步都可以安全中断。
|
||||||
|
#
|
||||||
|
# 力阈值全部是「相对静止基线的增量」:Orisys 读数是形变任意单位且基线会漂移,
|
||||||
|
# 绝对值没有意义。基线由传感器节点的 measure_baseline 服务在启动时测定。
|
||||||
|
|
||||||
|
# --- 目标与判据 ---
|
||||||
|
float64 target_x_m # 目标法兰 X(基座系,m)。NaN = 保持当前 XY 不动
|
||||||
|
float64 target_y_m # 目标法兰 Y(基座系,m)。NaN = 保持当前 XY 不动
|
||||||
|
float64 contact_axis # 监测哪条切向通道:0 = 第一路切向,1 = 第二路切向
|
||||||
|
float64 contact_delta # 接触判据:|切向力| 相对静止基线的增量(传感器原生单位)
|
||||||
|
float64 max_normal_delta # 法向力安全上限(相对基线增量)。<=0 = 不检查
|
||||||
|
string side # 用哪只手指的触觉:left | right(装配时是持规那只)
|
||||||
|
|
||||||
|
# --- 运动参数 ---
|
||||||
|
float64 descent_step_m # 每步下压量(m),建议 0.0002 ~ 0.0010
|
||||||
|
float64 descent_speed_mps # 名义下压速度(m/s)。步进间隔 = step / speed
|
||||||
|
float64 max_descent_m # 最多下压距离(m)。安全上限,到了判失败并停住
|
||||||
|
float64 settle_sec # 每步后等待稳定(s)。0 = 不等,靠触觉话题新鲜度
|
||||||
|
float64 timeout_sec # 总超时(s)。0 = 由 max_descent_m / descent_speed_mps 推算
|
||||||
|
---
|
||||||
|
bool success # true = 检测到接触且已停住
|
||||||
|
string message # 失败原因:超时 / 触觉断流 / 达到行程上限 / 法向力超限
|
||||||
|
geometry_msgs/PoseStamped contact_pose # 接触瞬间的法兰位姿(基座系)
|
||||||
|
float64 descended_m # 实际下压距离(m)
|
||||||
|
float64 contact_delta_observed # 触发瞬间的切向增量
|
||||||
|
float64 normal_delta_observed # 触发瞬间的法向增量
|
||||||
|
---
|
||||||
|
float64 progress # 0.0 ~ 1.0(descended_m / max_descent_m)
|
||||||
|
float64 descended_m # 当前已下压距离(m)
|
||||||
|
float64 tangential_delta # 当前切向增量(相对基线)
|
||||||
|
float64 normal_delta # 当前法向增量(相对基线)
|
||||||
|
string status # approaching | contacting | retracting | done | error
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 切换到已保存的工具坐标系(如 tool1)。
|
||||||
|
#
|
||||||
|
# 背景:非夕的工具坐标系(TCP + 质量 + 质心)在 Studio 里预先配好并命名,
|
||||||
|
# RDK 只提供「按名字切换」一个操作。通止规装配要求工具系全程垂直向下锁定,
|
||||||
|
# 所以在流程开始前必须确认已切到正确的工具。
|
||||||
|
#
|
||||||
|
# 实现:arm_driver_flexiv 调用 scripts/rdk_switch_tool.py(子进程,rdk_py310)。
|
||||||
|
# 注意 RDK 是独占访问,切换时臂必须处于 IDLE 且没有其它进程连着 RDK。
|
||||||
|
string tool_name # Studio 里已保存的工具名。空字符串 = 只查询当前状态,不切换
|
||||||
|
|
||||||
|
string sn # 目标臂 SN(如 Rizon4-063989)。空 = 用 site.yaml 的 robot.sn
|
||||||
|
---
|
||||||
|
bool success # 是否已达到目标状态(已在该工具上也算成功)
|
||||||
|
string message # 失败原因;成功时为当前工具名说明
|
||||||
|
string current_tool # 操作后的当前工具名
|
||||||
|
float64 tool_mass_kg # 该工具的质量(kg);查询不到为 0
|
||||||
|
float64[3] tool_com_m # 该工具的质心(法兰系,m);查询不到为全 0
|
||||||
Reference in New Issue
Block a user