litegrip-python 1.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litegrip/__init__.py +298 -0
- litegrip/actions.py +1444 -0
- litegrip/calibration_normal.json +9 -0
- litegrip/calibration_reverse.json +9 -0
- litegrip/can/__init__.py +41 -0
- litegrip/can/controller.py +413 -0
- litegrip/can/motor.py +188 -0
- litegrip/can/protocol.py +380 -0
- litegrip/can/transport.py +365 -0
- litegrip/constants.py +146 -0
- litegrip/exceptions.py +47 -0
- litegrip/factory_calibration.json +16 -0
- litegrip/gripper.py +2678 -0
- litegrip/models.py +210 -0
- litegrip/protocols/__init__.py +10 -0
- litegrip/protocols/can_bus.py +621 -0
- litegrip/py.typed +0 -0
- litegrip/teleop.py +1469 -0
- litegrip/trajectory.py +985 -0
- litegrip/zenoh_link.py +400 -0
- litegrip_python-1.1.2.dist-info/METADATA +636 -0
- litegrip_python-1.1.2.dist-info/RECORD +25 -0
- litegrip_python-1.1.2.dist-info/WHEEL +5 -0
- litegrip_python-1.1.2.dist-info/licenses/LICENSE +202 -0
- litegrip_python-1.1.2.dist-info/top_level.txt +1 -0
litegrip/actions.py
ADDED
|
@@ -0,0 +1,1444 @@
|
|
|
1
|
+
"""LiteGrip 动作层 —— open / close / grasp / zero / enable / disable。
|
|
2
|
+
|
|
3
|
+
高层 ``LiteGrip`` 的 ``open()`` / ``close()`` / ``grasp()`` 等方法直接转发到
|
|
4
|
+
这里的 :class:`GripperActions`。单独成模块是为了让「怎么动」这套逻辑只写一遍:
|
|
5
|
+
ROS 2 桥接、RPC 服务、产品代码都能直接调 :attr:`LiteGrip.actions`,而不用各自
|
|
6
|
+
重写一遍斜坡和堵转判据。
|
|
7
|
+
|
|
8
|
+
为什么不用 ``control_mit_stream()``
|
|
9
|
+
-----------------------------------
|
|
10
|
+
``control_mit_stream`` 在整段时长里反复下发**同一个** ``q_target``:伺服几十
|
|
11
|
+
毫秒就贴上去,剩下时间空转 —— 慢速时表现为一顿一停。这里按固定帧间隔推进一条
|
|
12
|
+
线性斜坡,并给 ``dq_target`` 速度前馈,所以是匀速连续运动。
|
|
13
|
+
|
|
14
|
+
``goto`` 一族(``goto`` / ``goto_rad`` / ``move_to`` / ``home``)和
|
|
15
|
+
``move_at_speed`` 一族原来各自就是那个 ``control_mit_stream`` 式的硬阶跃;现在
|
|
16
|
+
它们都转发到这里(:meth:`GripperActions.ramp_to`),「怎么动」只剩这一份实现。
|
|
17
|
+
|
|
18
|
+
指令领先实测位置的部分由领先上限封顶,分两档:行进段用
|
|
19
|
+
:attr:`MotionConfig.max_lead_mm`(大领先量破静摩擦),距限位
|
|
20
|
+
:attr:`MotionConfig.press_zone_mm` 之内切到
|
|
21
|
+
:attr:`MotionConfig.stop_lead_mm`(压紧段轻压)。不封顶的话,被挡住时误差会
|
|
22
|
+
一直累积、力矩顶到危险值;封顶后静摩擦靠满额力矩破,力矩却始终有界(约
|
|
23
|
+
``kp × lead_cap_rad``)。也不能改成「相对实测加一块」——那样一旦夹住,指令跟
|
|
24
|
+
着实测冻结,误差永远涨不上去,会误判堵转。
|
|
25
|
+
|
|
26
|
+
``open()`` / ``close()`` 的目标是**越过**标定限位一点(
|
|
27
|
+
:attr:`MotionConfig.press_overshoot`),好让运动靠堵转结束在限位上;但**下发的
|
|
28
|
+
指令**由 :func:`clamp_to_travel` 封顶在标定限位,夹爪不可能被驱动到标定行程
|
|
29
|
+
之外。「目标越位、指令不越位」是一次移动的两面:目标决定什么时候判堵转,指令
|
|
30
|
+
决定夹爪能走到哪。``grasp()`` 的闭合段瞄的也是**限位本身**(走得慢、带着设定力
|
|
31
|
+
的预算):空载时夹爪在限位上停下,夹到工件时在工件上停下,两种都是停着的状态,
|
|
32
|
+
正是保力段可以接手的前提(见 :meth:`GripperActions.grasp`)。
|
|
33
|
+
|
|
34
|
+
**带设定力的接近段另有一条预算**(``grasp`` 的闭合段,见
|
|
35
|
+
:func:`force_approach_terms`):上面那条 ``kp × 领先上限`` 对默认的 4 mm 上限、
|
|
36
|
+
配置默认的 ``rad_to_mm`` 是 5.30 Nm ≈ 53 N,与这一次夹取设定的力无关,于是
|
|
37
|
+
「夹住工件」和「先拿 53 N 撞一下」是同一件事。所以只要这一段带着设定力,它的力矩预算(设定值 ×
|
|
38
|
+
:attr:`MotionConfig.press_safety`)就分给这一帧的速度、阻尼和领先,撞上工件
|
|
39
|
+
时压出的力矩不超过设定值。代价是领先跟着设定力变窄,而设定力低到一帧的位移
|
|
40
|
+
自己能占满预算时,接近**速度**也由设定力定。
|
|
41
|
+
|
|
42
|
+
堵转判据是软件侧的位置增量判据(电机本身没有堵转保护):每
|
|
43
|
+
:attr:`MotionConfig.sample_interval` 采一次位置,连续
|
|
44
|
+
:attr:`MotionConfig.stall_cycles` 次采样的**窗口净位移**小于阈值即判堵转。
|
|
45
|
+
阈值 = ``max(stall_delta, stall_ratio × 窗口内本该走的距离)``。不看单点,因为
|
|
46
|
+
闭合侧有约 0.010 rad 的机械死区,慢速粘滑时单点忽大忽小。斜坡走完的保压段
|
|
47
|
+
不判(那时夹爪本来就该不动)。
|
|
48
|
+
|
|
49
|
+
保力段(``grasp`` 的第二段)反过来:只下发前馈力矩,不给位置/速度增益。
|
|
50
|
+
力控要的是力,带增益就会随夹爪的位移衰减,详见
|
|
51
|
+
:meth:`GripperActions._hold_force`。
|
|
52
|
+
|
|
53
|
+
纯力矩帧里**没有位置误差**,所以上面那条「指令领先上限」的位置护栏对它无能为
|
|
54
|
+
力 —— 它有自己的那条:越出闭合侧标定限位超过
|
|
55
|
+
:attr:`MotionConfig.hold_runaway_rad` 即撤力中止
|
|
56
|
+
(:func:`closing_travel_excursion_rad`)。两段护栏守的是两件事:那一端封顶的是
|
|
57
|
+
**指令**,这一端止住的是**失控**。
|
|
58
|
+
|
|
59
|
+
本模块**不 print**:进度通过 ``progress`` 回调交给调用方(CLI 打印、ROS 节点
|
|
60
|
+
记日志、RPC 服务转发都行)。
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import logging
|
|
66
|
+
import math
|
|
67
|
+
import time
|
|
68
|
+
from dataclasses import dataclass, field
|
|
69
|
+
from typing import TYPE_CHECKING, Callable, Optional, Tuple
|
|
70
|
+
|
|
71
|
+
from .constants import UnitConversion
|
|
72
|
+
from .exceptions import (
|
|
73
|
+
CommandError,
|
|
74
|
+
CommError,
|
|
75
|
+
LiteGripError,
|
|
76
|
+
NotInitializedError,
|
|
77
|
+
)
|
|
78
|
+
from .models import CalibrationData, GripperConfig, GripperState
|
|
79
|
+
|
|
80
|
+
if TYPE_CHECKING: # 避免运行时循环 import
|
|
81
|
+
from .gripper import LiteGrip
|
|
82
|
+
|
|
83
|
+
log = logging.getLogger("litegrip")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
87
|
+
# 可调量
|
|
88
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class MotionConfig:
|
|
92
|
+
"""open / close / grasp / zero / enable 的全部可调量。
|
|
93
|
+
|
|
94
|
+
默认值即真机上调好的那组(50 mm/s、5 ms 帧、4 mm 领先上限等),
|
|
95
|
+
不改就能用。速度单位一律 mm/s(开口量),内部按
|
|
96
|
+
``GripperConfig.rad_to_mm`` 换算成 rad。
|
|
97
|
+
|
|
98
|
+
``sleep_fn`` / ``monotonic_fn`` 是给测试和仿真留的缝:引擎内部一律走
|
|
99
|
+
这两个,不直接调 ``time.*``。测试里传 ``sleep_fn=lambda _: None`` 就能让
|
|
100
|
+
整条斜坡瞬间跑完,不必 monkeypatch ``time.sleep``。
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# ── 运动 ───────────────────────────────────────────────────────────
|
|
104
|
+
speed_mm_s: float = 50.0 # open/close 速度
|
|
105
|
+
grasp_speed_mm_s: float = 50.0 # grasp 闭合段速度
|
|
106
|
+
margin: float = 0.05 # limit_target 在限位内侧留的余量比例
|
|
107
|
+
frame_interval: float = 0.005 # 200 Hz 斜坡帧间隔 s
|
|
108
|
+
sample_interval: float = 0.05 # 20 Hz 堵转采样间隔 s
|
|
109
|
+
settle_s: float = 0.3 # 斜坡后原地保目标时长 s(不判堵转)
|
|
110
|
+
reach_tol: float = 0.02 # 到位容差 rad(闭合侧死区 0.0103)
|
|
111
|
+
|
|
112
|
+
# ── open/close 顶限位 ──────────────────────────────────────────────
|
|
113
|
+
# 这几个数只影响**目标的排程**和接近段的领先:真正下发的指令被
|
|
114
|
+
# :func:`clamp_to_travel` 封顶在标定限位,所以越位量调多大都不会把夹爪送
|
|
115
|
+
# 到标定行程之外。
|
|
116
|
+
press_overshoot: float = 0.05 # 目标越过标定限位的行程比例(不是指令)
|
|
117
|
+
press_zone_mm: float = 2.0 # 距限位这么近就切到 stop_lead_mm
|
|
118
|
+
stop_lead_mm: float = 0.7 # 压紧段领先上限 mm(≈ kp × 上限)
|
|
119
|
+
# 停稳位置距限位多近算「顶在限位上」rad。这个数必须容得下「记录限位比
|
|
120
|
+
# 压紧能到的地方深一点」:``zero()`` 用 calib_kp × calib_step_rad(≈1.0 Nm)
|
|
121
|
+
# 探测,而 close() 按自己的刚度压,两者用力不同。台架实测缺口 0.92–1.27 mm
|
|
122
|
+
# (@57.55 mm/rad 即 0.016–0.022 rad),0.02 只差 0.001 rad 就够不着,于是
|
|
123
|
+
# 照文档顺序跑 stroke_test 第一遍就 exit 1。0.03 留了约 36% 余量,仍只占
|
|
124
|
+
# 1.41 rad 行程的 2.1%。
|
|
125
|
+
stop_tol: float = 0.03 # 停稳位置距限位多近算「顶在限位上」rad
|
|
126
|
+
|
|
127
|
+
# ── 堵转判据 ───────────────────────────────────────────────────────
|
|
128
|
+
stall_cycles: int = 5 # 窗口采样点数
|
|
129
|
+
stall_ratio: float = 0.2 # 窗口净位移 / 本该走的距离
|
|
130
|
+
stall_delta: float = 0.0015 # 阈值下限 rad
|
|
131
|
+
|
|
132
|
+
# ── 力矩 / 指令上限 ────────────────────────────────────────────────
|
|
133
|
+
max_lead_mm: float = 4.0 # 行进段领先上限 mm(≈ kp × 上限)
|
|
134
|
+
|
|
135
|
+
# ── 带设定力的接近段(grasp 的闭合段)的力矩预算 ───────────────────
|
|
136
|
+
# grasp 的闭合段是**带设定力**的,可它这一段仍然是位置帧:撞上工件时力矩由
|
|
137
|
+
# 驱动器自己算 —— ``kp × 领先 + kd × 指令速度``,和设定的力没关系。默认那
|
|
138
|
+
# 条 4 mm 行进段上限在配置默认值下(``kp=100``、``rad_to_mm=75.44``)是
|
|
139
|
+
# 5.30 Nm ≈ 53 N,所以 5 N 的夹取也是拿 53 N 撞上去的。这一段改成把设定力
|
|
140
|
+
# 的力矩预算分给一帧里的三项,撞上工件时压出的力矩就不超过预算 —— 分配见
|
|
141
|
+
# :func:`force_approach_terms`,与 console(litegrip-studio)的接近段同一
|
|
142
|
+
# 条律、同一个数。
|
|
143
|
+
#
|
|
144
|
+
# 边界:预算直接由 ``force_n × UnitConversion.N_TO_NM`` 折算,再乘这个余量。
|
|
145
|
+
# 夹爪的额定力不在这一层拦 —— 那是调用方给的设定值自己的事,而保力段本来也
|
|
146
|
+
# 照设定的力往下发。
|
|
147
|
+
press_safety: float = 0.9 # 预算余量:撞上时实测力落在设定值之下
|
|
148
|
+
|
|
149
|
+
# ── 行进段堵转保护(≈7 N) ────────────────────────────────────────
|
|
150
|
+
# 普通移动(``press=True`` 的 open/close)行进段的领先上限是 max_lead_mm,
|
|
151
|
+
# 一旦夹爪在半路被硬挡,``kp × 领先上限`` ≈ 7.6 N·m 会一直压着;而位置
|
|
152
|
+
# 窗口判据对「缓慢变形」的硬停会漏判(结构让位让窗口净位移一直够)。这
|
|
153
|
+
# 一路**独立按力矩保护**:行进段里实测速度明显跟不上指令、且 ``|tau|``
|
|
154
|
+
# 连续 stop_torque_cycles 次过阈 → 判堵转并失力。压紧段(已经贴着标定
|
|
155
|
+
# 限位,``lead_cap`` 已切到 stop_lead_mm)不走这一路 —— 那里本来就该顶着
|
|
156
|
+
# 力矩,硬加力矩门槛会让每一次 open/close 都误触发。
|
|
157
|
+
stop_torque_nm: float = 0.7 # 触发保护的力矩阈值 Nm(≈7 N)
|
|
158
|
+
stop_torque_cycles: int = 3 # 连续这么多个采样点都过阈才触发
|
|
159
|
+
stop_speed_ratio: float = 0.5 # 实测速度低于指令速度这个比例才算「没跟上」
|
|
160
|
+
stop_release_s: float = 0.2 # 触发后失力(kp=kd=tau=0)持续时长 s
|
|
161
|
+
|
|
162
|
+
# ── 保力 ───────────────────────────────────────────────────────────
|
|
163
|
+
# 保力帧是**纯力矩源**:kp=kd=0,只有前馈力矩。力控要的是力,位置/速度
|
|
164
|
+
# 增益会让力跟着夹爪的位移和速度走 —— 工件一让位(或闭合侧约 0.010 rad
|
|
165
|
+
# 的粘滑死区一动),``kp × 位移`` 就从设定力里扣掉一截,现象是「先夹到
|
|
166
|
+
# 设定力,过一会儿掉下来」。推导与代价见 GripperActions._hold_force。
|
|
167
|
+
force_n: float = 20.0 # 默认夹持力 N(= 2.0 Nm)
|
|
168
|
+
# 保力力矩的**爬升速率** N/s:保力从进入时飞行中的力矩按这个速率爬到设定值,
|
|
169
|
+
# 而不是一步跳过去、也不是按指数逼近(指数只是把阶跃抹开,第一拍最陡)。一步
|
|
170
|
+
# 踏进接触里就是隔着机构的一次冲击,指爪会被刚碰到的东西弹开;按速率爬升才是
|
|
171
|
+
# 「力均匀地涨、到了设定值就停」。20 N/s 下,从压紧的约 10 N 交接到 20 N 设定值
|
|
172
|
+
# 要半秒,设定值最大到额定 40 N 要两秒。和 console(litegrip-studio)同一个数、
|
|
173
|
+
# 同一个单位,两边保持同步。
|
|
174
|
+
force_ramp_n_s: float = 20.0 # 保力爬升速率 N/s
|
|
175
|
+
hold_interval: float = 0.2 # 保力分片时长 s
|
|
176
|
+
# 保力段的**失控检测**阈值 rad:实测位置越过闭合侧标定限位这么多就失力中止
|
|
177
|
+
# (判据见 closing_travel_excursion_rad,触发后的动作见 _hold_force)。纯力矩
|
|
178
|
+
# 帧里没有位置误差可夹,撤掉力矩是唯一能用的手段,所以这里是判据不是夹子。
|
|
179
|
+
#
|
|
180
|
+
# 这是检测**失控**,不是给保力定精度:正常保力根本走不到这里 —— 夹着工件时
|
|
181
|
+
# 夹爪停在工件上;空载时它顶到真限位就停,而真限位在标定限位附近(zero() 探
|
|
182
|
+
# 测时是压进限位的,所以标定出来的限位比夹爪真正能到的位置还宽约 1 mm,空载
|
|
183
|
+
# 保力的越位量因此接近 0)。0.05 rad 是给「标定略微偏了」和「真限位不在标定
|
|
184
|
+
# 位置上」留的余量,按出厂标定的 rad_to_mm=61.01 折算约 3.05 mm。真出问题时
|
|
185
|
+
# 越位量是毫米级往上走的 —— 2026-10-10 那台是 104.9 mm。
|
|
186
|
+
hold_runaway_rad: float = 0.05
|
|
187
|
+
# [Deprecated] 保力不再用增益(见上)。留着只为兼容老配置,设了也不生效。
|
|
188
|
+
hold_kp: float = 150.0 # 已废弃:保力刚度
|
|
189
|
+
hold_kd: float = 2.0 # 已废弃:保力阻尼
|
|
190
|
+
|
|
191
|
+
# ── 使能 ───────────────────────────────────────────────────────────
|
|
192
|
+
enable_retries: int = 3 # 使能重试次数
|
|
193
|
+
enable_retry_interval: float = 0.2 # 使能重试间隔 s
|
|
194
|
+
|
|
195
|
+
# ── zero() 标定探测 ────────────────────────────────────────────────
|
|
196
|
+
# 探测顶在限位上的力矩就是 calib_kp × 指令领先量,而领先量本身被
|
|
197
|
+
# _find_limit 限成 calib_step_rad(目标只比实测位置多一步),所以这两个
|
|
198
|
+
# 值同时决定「走多快」和「顶多重」:20 × 0.05 ⇒ 空载推进约 1 Nm,
|
|
199
|
+
# 远低于 DM4310 的峰值。calib_tau_limit 是独立于堵转判据的硬上限。
|
|
200
|
+
calib_kp: float = 20.0 # 低刚度更温和(力矩 = kp × 领先量)
|
|
201
|
+
calib_kd: float = 2.0
|
|
202
|
+
calib_step_rad: float = 0.05 # 每步步进 rad(= 指令领先上限)
|
|
203
|
+
calib_tau_limit: float = 2.0 # 力矩上限 Nm,超过即停
|
|
204
|
+
calib_stall_delta: float = 0.0015 # 标定堵转判据 rad
|
|
205
|
+
calib_stall_cycles: int = 5 # 标定连续堵转次数
|
|
206
|
+
calib_max_iter: int = 200 # 单向步数上限
|
|
207
|
+
|
|
208
|
+
# ── 测试/仿真缝 ────────────────────────────────────────────────────
|
|
209
|
+
sleep_fn: Callable[[float], None] = field(
|
|
210
|
+
default=time.sleep, repr=False, compare=False)
|
|
211
|
+
monotonic_fn: Callable[[], float] = field(
|
|
212
|
+
default=time.monotonic, repr=False, compare=False)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
216
|
+
# 结果类型
|
|
217
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
218
|
+
#
|
|
219
|
+
# 都实现 __bool__,所以 ``if gripper.open():`` 这种老写法继续可用。
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class MoveProgress:
|
|
223
|
+
"""一次进展快照,交给 ``progress`` 回调。"""
|
|
224
|
+
|
|
225
|
+
phase: str # "move" 或 "hold"
|
|
226
|
+
i: int # 当前帧号 / 保力片号
|
|
227
|
+
total_steps: int # 总帧数(保力段为 0)
|
|
228
|
+
cmd_rad: float # 本帧下发的指令位置
|
|
229
|
+
pos_rad: float # 本帧实测位置
|
|
230
|
+
delta_rad: float # 距上一次采样的位置增量
|
|
231
|
+
win_delta_rad: Optional[float] # 窗口净位移(采样点数不够时为 None)
|
|
232
|
+
torque_nm: float # 实测力矩
|
|
233
|
+
temperature_coil: int = 0 # 线圈温度 °C
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass
|
|
237
|
+
class MoveResult:
|
|
238
|
+
"""一次移动的结果。
|
|
239
|
+
|
|
240
|
+
``ok`` 是「这次动作算不算成功」,``__bool__`` 用它。注意含义随目标而变:
|
|
241
|
+
|
|
242
|
+
- ``open()`` / ``close()``:成功 = **堵转停在标定限位近旁**(下发的指令
|
|
243
|
+
被封顶在标定限位上,见 :func:`clamp_to_travel`),所以 ``ok=True`` 时
|
|
244
|
+
``stalled=True`` 而 ``reached`` 基本为 ``False``。半路被工件挡住也算
|
|
245
|
+
堵转,但离标定限位很远,``ok=False``。
|
|
246
|
+
- ``grasp()`` 的闭合段:成功 = 走到空载目标且没堵转,即
|
|
247
|
+
``reached and not stalled``,与旧行为一致。
|
|
248
|
+
- 定位(``goto`` 一族、``move_at_speed`` 一族,都走
|
|
249
|
+
:meth:`GripperActions.ramp_to`):成功 = 同上 ``reached and not
|
|
250
|
+
stalled``。半路被挡住、整段一帧都没发出去、或者根本没走到,都是
|
|
251
|
+
``False`` —— 目标在行程内,「顶在限位上」不是它的成功终点。
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
ok: bool # 本次动作是否成功(__bool__ 用它)
|
|
255
|
+
reached: bool # 末端是否在 reach_tol 内到目标
|
|
256
|
+
stalled: bool # 是否判到堵转
|
|
257
|
+
state: GripperState # 末端状态
|
|
258
|
+
target_rad: float # 目标位置
|
|
259
|
+
limit_rad: float # 这一端标定出的机械限位
|
|
260
|
+
final_cmd_rad: float # 最后一帧下发的指令位置
|
|
261
|
+
steps: int # 实际走了多少帧
|
|
262
|
+
# 行进段堵转保护(≈7 N 力矩)触发 —— 触发即失力,``ok`` 必为 False。
|
|
263
|
+
# 与 ``stalled`` 的区别:``stalled`` 也会由「顶到标定限位」成立(那是
|
|
264
|
+
# open/close 的正常成功终点),``protected`` 只报这一路力矩保护。
|
|
265
|
+
protected: bool = False
|
|
266
|
+
# 没到位之后的撤力(只有 ramp_to 会置上):定位半路被挡住时,最后一帧的
|
|
267
|
+
# 指令停在 ``实测位置 + 领先上限`` 上,留着它驱动器会按约 6.6 N·m 一直压着。
|
|
268
|
+
# 只报「撤了力」,不改变 ``ok``。
|
|
269
|
+
released: bool = False
|
|
270
|
+
|
|
271
|
+
def __bool__(self) -> bool:
|
|
272
|
+
return self.ok
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@dataclass
|
|
276
|
+
class GraspResult:
|
|
277
|
+
"""grasp 的结果。"""
|
|
278
|
+
|
|
279
|
+
ok: bool # 保力是否正常结束(没被故障/中止打断)
|
|
280
|
+
reached: bool # 闭合段是否到空载目标位置
|
|
281
|
+
stalled: bool # 闭合段是否判到堵转(= 夹到工件)
|
|
282
|
+
state: GripperState # 末端状态
|
|
283
|
+
target_rad: float # 闭合段的空载目标位置
|
|
284
|
+
force_n: float # 实际用的夹持力 N
|
|
285
|
+
cycles: int # 保力片数
|
|
286
|
+
# 保力段被**位置护栏**中止:实测位置越过闭合侧标定限位超过
|
|
287
|
+
# MotionConfig.hold_runaway_rad,于是撤力返回。触发即 ``ok=False``。
|
|
288
|
+
# 含义与 :attr:`MoveResult.protected` 对称 —— 那一路守的是移动段的力矩,
|
|
289
|
+
# 这一路守的是保力段的越位。正常夹取不会置位:夹着工件时夹爪停在工件上,
|
|
290
|
+
# 空载时顶到真限位就停。置位说明闭合侧没有东西拦着它。
|
|
291
|
+
travel_guard: bool = False
|
|
292
|
+
|
|
293
|
+
def __bool__(self) -> bool:
|
|
294
|
+
return self.ok
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@dataclass
|
|
298
|
+
class HoldResult:
|
|
299
|
+
"""保力段(:meth:`GripperActions._hold_force`)的结果。
|
|
300
|
+
|
|
301
|
+
引擎内部的返回类型,``grasp`` 把它折进 :class:`GraspResult`。
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
ok: bool # 是否保到时间自然结束(没被故障/护栏打断)
|
|
305
|
+
cycles: int # 保力片数
|
|
306
|
+
state: Optional[GripperState] # 最后一次状态
|
|
307
|
+
guarded: bool = False # 是否被位置护栏中止(详见 GraspResult)
|
|
308
|
+
|
|
309
|
+
def __bool__(self) -> bool:
|
|
310
|
+
return self.ok
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class EnableResult:
|
|
315
|
+
"""enable 的结果。"""
|
|
316
|
+
|
|
317
|
+
ok: bool # 状态帧是否回读到 err == 1
|
|
318
|
+
state: Optional[GripperState] # 最后一次状态
|
|
319
|
+
tries: int # 实际尝试次数
|
|
320
|
+
|
|
321
|
+
def __bool__(self) -> bool:
|
|
322
|
+
return self.ok
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class WriteZeroResult:
|
|
327
|
+
"""write_zero 的结果。"""
|
|
328
|
+
|
|
329
|
+
before_rad: float # 写入前的角度
|
|
330
|
+
after_rad: float # 重新使能后回读的角度
|
|
331
|
+
tolerance_rad: float = 1e-3 # 回读视为 ~0 的容差
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def ok(self) -> bool:
|
|
335
|
+
"""回读是否 ~0(0xFE 是否被电机接受)。"""
|
|
336
|
+
return abs(self.after_rad) <= self.tolerance_rad
|
|
337
|
+
|
|
338
|
+
def __bool__(self) -> bool:
|
|
339
|
+
return self.ok
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
343
|
+
# 目标位置
|
|
344
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
345
|
+
|
|
346
|
+
def _check_calibrated(config: GripperConfig) -> None:
|
|
347
|
+
"""确认配置真的带上了标定,并有一段非零行程。
|
|
348
|
+
|
|
349
|
+
两种限位顺序都合法(``pos_closed_rad`` 可以小于 ``pos_open_rad``,那就是
|
|
350
|
+
反装),所以这里**不**看谁大谁小 —— 只看有没有标定过的实数。没标定的
|
|
351
|
+
配置里所有方向都是猜的,必须拦住。
|
|
352
|
+
"""
|
|
353
|
+
if not config.calibrated:
|
|
354
|
+
raise CommandError(
|
|
355
|
+
"配置尚未标定:pos_closed_rad / pos_open_rad 还是占位默认值,"
|
|
356
|
+
"无法判断方向。先 load_calibration()(可选 "
|
|
357
|
+
"CALIB_TEMPLATES[\"reverse\"])或跑一次 zero()。")
|
|
358
|
+
if abs(config.pos_closed_rad - config.pos_open_rad) <= 1e-6:
|
|
359
|
+
raise CommandError(
|
|
360
|
+
f"行程为零:pos_closed_rad={config.pos_closed_rad} 与 "
|
|
361
|
+
f"pos_open_rad={config.pos_open_rad} 相同,重新标定。")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def clamp_to_travel(config: GripperConfig, q: float) -> float:
|
|
365
|
+
"""把位置指令夹进标定行程 ``[pos_closed_rad, pos_open_rad]``。
|
|
366
|
+
|
|
367
|
+
两端谁大谁小取决于安装方向(``close_sign``),所以这里取 ``min`` / ``max``,
|
|
368
|
+
不假设顺序 —— 与 :func:`limit_target` 一样,正装、反装通用。
|
|
369
|
+
|
|
370
|
+
这是引擎**最后一道**位置护栏::func:`press_target` 故意把 ``open`` / ``close``
|
|
371
|
+
的**目标**放在标定限位之外,但**下发**的指令不许越过去。指令一旦越过去,夹爪
|
|
372
|
+
就朝那边一直走 —— 真限位不在那儿(标定偏了、机械限位已经损坏、或者本来就
|
|
373
|
+
没有)时,没有东西拦得住它。
|
|
374
|
+
|
|
375
|
+
非有限值不归这里兜底:``min`` / ``max`` 与 NaN 的比较全为假,NaN 会被静默
|
|
376
|
+
折到量程的一端。调用方必须在进来之前拒绝非有限值 —— CAN 层
|
|
377
|
+
:meth:`LiteGripCAN._check_motion_command` 已经在做这件事。
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
config: 夹爪配置(用 ``pos_closed_rad`` / ``pos_open_rad``)。
|
|
381
|
+
q: 待下发的指令位置 rad。
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
夹进标定行程后的位置 rad。
|
|
385
|
+
"""
|
|
386
|
+
lo = min(config.pos_closed_rad, config.pos_open_rad)
|
|
387
|
+
hi = max(config.pos_closed_rad, config.pos_open_rad)
|
|
388
|
+
return max(lo, min(hi, q))
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def closing_travel_excursion_rad(config: GripperConfig, q: float) -> float:
|
|
392
|
+
"""``q`` 越过闭合侧标定限位、朝闭合方向又多走出来的量 rad(没越过为 0)。
|
|
393
|
+
|
|
394
|
+
力控段(``grasp`` 的保力段)的护栏用的就是它。那一段下发的是
|
|
395
|
+
``kp = kd = 0`` 的纯前馈力矩帧:帧里只有力矩,没有位置误差,
|
|
396
|
+
:func:`clamp_to_travel` 那条位置护栏也就无从下手(它夹的是指令位置)。纯力矩源
|
|
397
|
+
能用的手段只剩一个 —— **撤掉力矩**。所以那一段的护栏是**判据**,不是夹子:
|
|
398
|
+
越位即失力,而不是把夹爪按在某个位置上。两条护栏守的是两件事:位置指令那条
|
|
399
|
+
封顶的是**指令**,这一条止住的是**失控**。
|
|
400
|
+
|
|
401
|
+
只判闭合侧。帧里下的是 ``close_sign × force``,力矩朝闭合方向压,所以越过闭合
|
|
402
|
+
限位才是失控 —— 那里一旦没有真限位(标定偏了、机械限位已经损坏),就是一路走
|
|
403
|
+
到底。越过张开限位会被同一份力矩自己推回来,不算失控;把那边也算进来只会让
|
|
404
|
+
「松手把夹爪推过张开限位、接着合上」这种正常操作误触发。
|
|
405
|
+
|
|
406
|
+
反装(``pos_closed_rad < pos_open_rad``)由 :attr:`GripperConfig.close_sign`
|
|
407
|
+
统一处理,正装反装共用这一份写法。
|
|
408
|
+
|
|
409
|
+
配置没标定时**不要用**:占位默认值(``pos_closed_rad=1.14`` /
|
|
410
|
+
``pos_open_rad=0.0``)描述的不是任何真实行程,由它算出来的越位量没有意义。
|
|
411
|
+
调用方负责先看 :attr:`GripperConfig.calibrated`。
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
config: 夹爪配置(用 ``pos_closed_rad`` 与 ``close_sign``)。
|
|
415
|
+
q: 实测位置 rad。
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
越位量 rad;没越过为 ``0.0``。
|
|
419
|
+
"""
|
|
420
|
+
return max(0.0, config.close_sign * (q - config.pos_closed_rad))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def limit_target(
|
|
424
|
+
config: GripperConfig,
|
|
425
|
+
toward: str,
|
|
426
|
+
margin: float,
|
|
427
|
+
) -> Tuple[float, float, float, float]:
|
|
428
|
+
"""算出一端的目标位置:标定限位往行程内侧退 ``margin`` 比例的余量。
|
|
429
|
+
|
|
430
|
+
不让夹爪真顶到机械限位(那里 kp=100 会压出 ~4 Nm),而是停在限位内侧。
|
|
431
|
+
|
|
432
|
+
方向来自 :attr:`GripperConfig.close_sign`,所以反装的机器(
|
|
433
|
+
``pos_closed_rad < pos_open_rad``)同样成立。
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
config: 夹爪配置(用 ``pos_closed_rad`` / ``pos_open_rad``)。
|
|
437
|
+
toward: ``"close"`` 或 ``"open"``。
|
|
438
|
+
margin: 行程余量比例,0.05 = 两端各留 5%。
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
``(目标位置, 标定限位, 余量 rad, 行程 rad)``
|
|
442
|
+
|
|
443
|
+
Raises:
|
|
444
|
+
CommandError: 配置还没标定(``calibrated=False``),或行程为零。
|
|
445
|
+
这多半是没加载标定,用了 :class:`GripperConfig` 的默认值。
|
|
446
|
+
"""
|
|
447
|
+
_check_calibrated(config)
|
|
448
|
+
|
|
449
|
+
s = config.close_sign
|
|
450
|
+
travel = abs(config.pos_closed_rad - config.pos_open_rad)
|
|
451
|
+
margin_rad = margin * travel
|
|
452
|
+
if toward == "close":
|
|
453
|
+
limit = config.pos_closed_rad
|
|
454
|
+
return limit - s * margin_rad, limit, margin_rad, travel
|
|
455
|
+
limit = config.pos_open_rad
|
|
456
|
+
return limit + s * margin_rad, limit, margin_rad, travel
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def press_target(
|
|
460
|
+
config: GripperConfig,
|
|
461
|
+
toward: str,
|
|
462
|
+
overshoot: float,
|
|
463
|
+
) -> Tuple[float, float, float, float]:
|
|
464
|
+
"""算出一端的目标位置:**越过**标定限位 ``overshoot`` 比例的行程。
|
|
465
|
+
|
|
466
|
+
与 :func:`limit_target` 相反 —— 目标是「压过去」,让运动靠堵转结束在限位上。
|
|
467
|
+
它算的只是**目标**:真正下发的指令由 :func:`clamp_to_travel` 封顶在标定
|
|
468
|
+
限位,所以终点是标定限位,不是物理限位。标定偏了(或真实限位不在标定位置
|
|
469
|
+
上)只会让夹爪停得更早,不会让它走得更远。
|
|
470
|
+
|
|
471
|
+
压紧段的领先上限由 :attr:`MotionConfig.stop_lead_mm` 收窄,接近段的力矩
|
|
472
|
+
因此不超过 ``kp × stop_lead_mm / rad_to_mm``;指令封顶在限位上之后,顶住
|
|
473
|
+
限位时的残余领先更小,力矩只会更低。
|
|
474
|
+
|
|
475
|
+
方向来自 :attr:`GripperConfig.close_sign`,正向与反装都成立。
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
config: 夹爪配置(用 ``pos_closed_rad`` / ``pos_open_rad``)。
|
|
479
|
+
toward: ``"close"`` 或 ``"open"``。
|
|
480
|
+
overshoot: 越位比例,0.05 = 往限位外侧再走 5% 行程。
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
``(越过限位的目标, 标定限位, 越位 rad, 行程 rad)``
|
|
484
|
+
|
|
485
|
+
Raises:
|
|
486
|
+
CommandError: 配置还没标定(``calibrated=False``),或行程为零。
|
|
487
|
+
"""
|
|
488
|
+
_check_calibrated(config)
|
|
489
|
+
|
|
490
|
+
s = config.close_sign
|
|
491
|
+
travel = abs(config.pos_closed_rad - config.pos_open_rad)
|
|
492
|
+
over_rad = overshoot * travel
|
|
493
|
+
if toward == "close":
|
|
494
|
+
limit = config.pos_closed_rad
|
|
495
|
+
return limit + s * over_rad, limit, over_rad, travel
|
|
496
|
+
limit = config.pos_open_rad
|
|
497
|
+
return limit - s * over_rad, limit, over_rad, travel
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def work_limit_target(
|
|
501
|
+
config: GripperConfig,
|
|
502
|
+
work_stroke_mm: float,
|
|
503
|
+
) -> Tuple[float, float, float]:
|
|
504
|
+
"""张开侧「工作行程」目标:从闭合零点起算 ``work_stroke_mm`` 处的指令位置。
|
|
505
|
+
|
|
506
|
+
与 :func:`press_target` 相反 —— 它**不**越位压到机械限位,而是在限位内侧
|
|
507
|
+
留出一段余量(现场口径:两爪行程 85 mm,工作只用到 80 mm,开口端留 5 mm)。
|
|
508
|
+
余量是**显式**的毫米数,不是 :func:`limit_target` 那种按行程比例的 ``margin``。
|
|
509
|
+
|
|
510
|
+
方向来自 :attr:`GripperConfig.close_sign`,所以反装的机器同样成立。目标按
|
|
511
|
+
机械行程 clamp,``work_stroke_mm`` 不小于机械行程时就是「张开到底」。
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
config: 夹爪配置(用 ``pos_closed_rad`` / ``pos_open_rad`` / ``rad_to_mm``)。
|
|
515
|
+
work_stroke_mm: 工作行程 mm(自闭合零点起算)。
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
``(目标 rad, 张开侧标定限位 rad, 实际工作行程 rad)``
|
|
519
|
+
|
|
520
|
+
Raises:
|
|
521
|
+
CommandError: 配置还没标定(``calibrated=False``),或行程为零。
|
|
522
|
+
"""
|
|
523
|
+
_check_calibrated(config)
|
|
524
|
+
|
|
525
|
+
s = config.close_sign
|
|
526
|
+
travel_mm = abs(config.pos_open_rad - config.pos_closed_rad) * config.rad_to_mm
|
|
527
|
+
stroke_mm = max(0.0, min(work_stroke_mm, travel_mm))
|
|
528
|
+
work_rad = stroke_mm / config.rad_to_mm
|
|
529
|
+
return config.pos_closed_rad - s * work_rad, config.pos_open_rad, work_rad
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def force_approach_terms(
|
|
533
|
+
kp: float,
|
|
534
|
+
kd: float,
|
|
535
|
+
speed_rad_s: float,
|
|
536
|
+
frame_interval: float,
|
|
537
|
+
budget_nm: float,
|
|
538
|
+
lead_ceiling_rad: float,
|
|
539
|
+
) -> Tuple[float, float, float]:
|
|
540
|
+
"""把**带设定力**的接近段的力矩预算分给一帧里的三项。
|
|
541
|
+
|
|
542
|
+
返回 ``(速度 rad/s, 阻尼, 领先上限 rad)``。
|
|
543
|
+
|
|
544
|
+
``grasp`` 的闭合段带着设定力走,可它这一段还是位置帧:撞上工件时力矩由驱
|
|
545
|
+
动器自己算,是 ``kp × 领先 + kd × 指令速度`` —— 只由这一段的速度和引擎自己
|
|
546
|
+
给的领先决定,与设定的力无关。默认那条 ``max_lead_mm`` 在配置默认值下
|
|
547
|
+
(``kp=100``、``rad_to_mm=75.44``)是 5.30 Nm ≈ 53 N,所以 5 N 的夹取也是
|
|
548
|
+
拿 53 N 撞上去的。
|
|
549
|
+
|
|
550
|
+
这里把设定力的力矩预算 ``force_n × UnitConversion.N_TO_NM ×
|
|
551
|
+
MotionConfig.press_safety`` 分给这一帧能出力的每一项,接近段撞上工件时压出
|
|
552
|
+
的力矩就不超过预算:
|
|
553
|
+
|
|
554
|
+
* **一帧自己那一格**(``kp × v × dt``)只能用速度封:指令再怎么跟,本帧的
|
|
555
|
+
位移也让指令领先实测一格,撞上就是这么多。所以预算先划出这一格,只够这
|
|
556
|
+
一格时把速度降下来 —— 这是唯一一处设定力仍然决定接近**速度**的地方,而
|
|
557
|
+
且是这一帧的算术,不是策略;
|
|
558
|
+
* **阻尼**(``kd × v``)接着分,按预算收窄,但不高于调用方给的上限;
|
|
559
|
+
* **领先**拿剩下的。它的下限是一帧的位移(``min_cap_rad``,引擎本来就靠这
|
|
560
|
+
条保住斜坡自己那一格),而这个下限的力矩恰好就是上面那一格,所以不会把
|
|
561
|
+
预算撑破。
|
|
562
|
+
|
|
563
|
+
顺序是有意的:阻尼先分、位置项(领先)后分,预算不够时先砍领先。反过来先给
|
|
564
|
+
领先、剩下的才给阻尼,在低速端(``kd × v`` 只有一两牛)会把阻尼砍到零,那样
|
|
565
|
+
一帧里只剩位置项,工件让位时力就跟着掉。
|
|
566
|
+
|
|
567
|
+
``budget_nm <= 0``(没带设定力)时原样返回,两个上限都不动。
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
kp: 位置刚度(驱动器那一档,默认 100)。
|
|
571
|
+
kd: 调用方给的阻尼上限。
|
|
572
|
+
speed_rad_s: 这一段原本的指令速度 rad/s。
|
|
573
|
+
frame_interval: 一帧的时长 s。
|
|
574
|
+
budget_nm: 这一段允许压出的力矩 N·m。
|
|
575
|
+
lead_ceiling_rad: 不进预算时用的领先上限 rad(如 ``max_lead_mm``)。
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
``(速度, 阻尼, 领先上限)``,满足
|
|
579
|
+
``kp × 领先上限 + 阻尼 × 速度 ≤ 预算``。
|
|
580
|
+
"""
|
|
581
|
+
if budget_nm <= 0.0 or kp <= 0.0 or speed_rad_s <= 0.0:
|
|
582
|
+
return speed_rad_s, kd, lead_ceiling_rad
|
|
583
|
+
|
|
584
|
+
# 一帧的位移本身就是指令领先实测的下限,速度不能快到它自己就吃掉预算。
|
|
585
|
+
speed_rad_s = min(speed_rad_s, budget_nm / (kp * frame_interval))
|
|
586
|
+
tick_nm = kp * speed_rad_s * frame_interval
|
|
587
|
+
tick_rad = speed_rad_s * frame_interval
|
|
588
|
+
used_kd = max(0.0, min(kd, (budget_nm - tick_nm) / speed_rad_s))
|
|
589
|
+
lead_budget_rad = (budget_nm - used_kd * speed_rad_s) / kp
|
|
590
|
+
return (speed_rad_s, used_kd,
|
|
591
|
+
min(lead_ceiling_rad, max(tick_rad, lead_budget_rad)))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
595
|
+
# 动作层
|
|
596
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
600
|
+
# 动作层
|
|
601
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
602
|
+
|
|
603
|
+
def _toward(value: float, target: float, step: float) -> float:
|
|
604
|
+
"""把 ``value`` 朝 ``target`` 挪最多 ``step``,并且**正好落在** ``target`` 上。
|
|
605
|
+
|
|
606
|
+
落在靶点上、而不是逼近它,是关键:只按剩余距离的某个比例前进的斜坡永远到不了
|
|
607
|
+
终点,而保力的结果,操作者就是按它最终停在的数值来读的。
|
|
608
|
+
"""
|
|
609
|
+
if value < target:
|
|
610
|
+
return min(value + step, target)
|
|
611
|
+
return max(value - step, target)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _raise_send_failure(g: "LiteGrip") -> None:
|
|
615
|
+
"""抛出一个带原因的异常,说明为什么 ``send_mit_frame`` 回了 ``False``。
|
|
616
|
+
|
|
617
|
+
原因在 :attr:`LiteGrip.last_send_error` 里,三种情况分开报:
|
|
618
|
+
|
|
619
|
+
- 没连上 / 没使能 —— 下层记的就是 ``NotInitializedError``,原样抛回去:
|
|
620
|
+
它的消息说的是该补哪一步,重新包一层只会把那个结论弄丢。
|
|
621
|
+
- 总线写失败 —— 下层记的是它自己那条异常(``OSError`` 等)。这一层看不到
|
|
622
|
+
总线,只能照搬,按 README 的错误表归到 ``CommError``,并把原异常挂在
|
|
623
|
+
``__cause__`` 上。
|
|
624
|
+
- 什么都没记 —— 替身只回 ``False``,或者失败在别处。照实说「原因未记录」,
|
|
625
|
+
不替下层猜一个「未连接或未使能」:猜错了,诊断就去错了地方。
|
|
626
|
+
"""
|
|
627
|
+
error = getattr(g, "last_send_error", None)
|
|
628
|
+
if isinstance(error, NotInitializedError):
|
|
629
|
+
raise error
|
|
630
|
+
if error is None:
|
|
631
|
+
raise CommError("MIT 帧下发失败(原因未记录)")
|
|
632
|
+
raise CommError(f"MIT 帧下发失败:{error!r}") from error
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class GripperActions:
|
|
636
|
+
"""夹爪的七个动作:``open`` / ``close`` / ``grasp`` / ``ramp_to`` /
|
|
637
|
+
``zero`` / ``enable`` / ``disable``。
|
|
638
|
+
|
|
639
|
+
一般不直接构造,用 :attr:`LiteGrip.actions`::
|
|
640
|
+
|
|
641
|
+
with LiteGrip("can0") as g:
|
|
642
|
+
g.load_calibration()
|
|
643
|
+
g.actions.enable()
|
|
644
|
+
g.actions.open()
|
|
645
|
+
g.actions.grasp(force_n=20.0, hold_s=3.0)
|
|
646
|
+
|
|
647
|
+
进度回调 ``progress`` 收到 :class:`MoveProgress`,本模块自己不打印任何东西。
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
def __init__(self, gripper: "LiteGrip", config: Optional[MotionConfig] = None):
|
|
651
|
+
self._g = gripper
|
|
652
|
+
self.config = config if config is not None else MotionConfig()
|
|
653
|
+
|
|
654
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
655
|
+
# 六个接口
|
|
656
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
657
|
+
|
|
658
|
+
def open(
|
|
659
|
+
self,
|
|
660
|
+
speed_mm_s: Optional[float] = None,
|
|
661
|
+
*,
|
|
662
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
663
|
+
) -> MoveResult:
|
|
664
|
+
"""全开。
|
|
665
|
+
|
|
666
|
+
默认走到张开侧标定限位上堵转停下,``ok=True`` 时 ``stalled=True``、
|
|
667
|
+
``reached`` 基本为 ``False``。下发的指令被封顶在标定限位
|
|
668
|
+
(:func:`clamp_to_travel`),所以标定偏了、真实限位不在标定位置上、甚至
|
|
669
|
+
限位已经损坏,夹爪也不会被驱动到标定行程之外。
|
|
670
|
+
|
|
671
|
+
但若配置带了**工作行程**(:attr:`GripperConfig.work_stroke_mm` 且小于
|
|
672
|
+
机械行程),只走到那里就停 —— 开口端留出余量,不再走向机械限位。这时是
|
|
673
|
+
一次普通定位(``ok = reached and not stalled``),``limit_rad`` 仍是
|
|
674
|
+
张开侧标定限位,示意目标停在它内侧。
|
|
675
|
+
"""
|
|
676
|
+
cfg = self.config
|
|
677
|
+
gcfg = self._g.config
|
|
678
|
+
speed = cfg.speed_mm_s if speed_mm_s is None else speed_mm_s
|
|
679
|
+
travel_mm = abs(gcfg.pos_open_rad - gcfg.pos_closed_rad) * gcfg.rad_to_mm
|
|
680
|
+
if 0.0 < gcfg.work_stroke_mm < travel_mm:
|
|
681
|
+
target, _limit, _rad = work_limit_target(gcfg, gcfg.work_stroke_mm)
|
|
682
|
+
return self._move_to_limit("open", speed, target_rad=target,
|
|
683
|
+
progress=progress)
|
|
684
|
+
return self._move_to_limit("open", speed, press=True, progress=progress)
|
|
685
|
+
|
|
686
|
+
def close(
|
|
687
|
+
self,
|
|
688
|
+
speed_mm_s: Optional[float] = None,
|
|
689
|
+
*,
|
|
690
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
691
|
+
) -> MoveResult:
|
|
692
|
+
"""全合:走到闭合侧标定限位上堵转停下。
|
|
693
|
+
|
|
694
|
+
下发的指令被封顶在标定限位(:func:`clamp_to_travel`),所以标定偏了、
|
|
695
|
+
真实限位不在标定位置上、甚至限位已经损坏,夹爪也不会被驱动到标定行程
|
|
696
|
+
之外。``ok=True`` 时 ``stalled=True``、``reached`` 基本为 ``False`` ——
|
|
697
|
+
见 :class:`MoveResult`。
|
|
698
|
+
"""
|
|
699
|
+
speed = self.config.speed_mm_s if speed_mm_s is None else speed_mm_s
|
|
700
|
+
return self._move_to_limit("close", speed, press=True, progress=progress)
|
|
701
|
+
|
|
702
|
+
def grasp(
|
|
703
|
+
self,
|
|
704
|
+
force_n: Optional[float] = None,
|
|
705
|
+
hold_s: float = 0.0,
|
|
706
|
+
*,
|
|
707
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
708
|
+
) -> GraspResult:
|
|
709
|
+
"""夹取:先闭合到工件上(或空载到限位),再持续输出 ``force_n`` 大小的力。
|
|
710
|
+
|
|
711
|
+
闭合段**带着设定力走**:它的力矩预算就是这一次夹取的力(见
|
|
712
|
+
:func:`force_approach_terms`),撞上工件时压出的力矩不超过它 —— 空载时
|
|
713
|
+
它才一路走到底。
|
|
714
|
+
|
|
715
|
+
**交接只在「到位或顶上东西」时发生**(``reached or stalled``)。这两条
|
|
716
|
+
在真机上是同一件事的两面:夹爪都停着。这不是保守,是唯一的正确条件 ——
|
|
717
|
+
保力段是纯力矩源,帧里没有位置项也没有速度项,把它接到一个**还在自由
|
|
718
|
+
行程里**的夹爪上,位置环一撤它就只有加速这一条路(2026-10-13 实测:
|
|
719
|
+
0.82 rad/s → 5.12 rad/s,312 mm/s,且到 ``hold_s`` 用完仍在加速)。
|
|
720
|
+
所以闭合段的终止条件也是位置而不是帧数:``ramp_steps`` 跑完只说明排程
|
|
721
|
+
跑完了,不等于夹爪到了 —— 带设定力时领先被压到零点几毫米,它追不追得上
|
|
722
|
+
排程由摩擦和读数延迟定。两条都没成立时**如实报 ``ok=False``** 并撤力
|
|
723
|
+
返回(``reached`` / ``stalled`` 都是 ``False``,``cycles=0``),不放跑。
|
|
724
|
+
|
|
725
|
+
保力段是纯力矩源,没有位置指令可夹,所以它有自己的一条护栏
|
|
726
|
+
(:attr:`MotionConfig.hold_runaway_rad`):越出闭合侧标定限位太多就撤力
|
|
727
|
+
返回,结果里 ``ok=False`` / ``travel_guard=True``。正常夹取不会触发。
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
force_n: 夹持力 N,``None`` = 用 :attr:`MotionConfig.force_n`。
|
|
731
|
+
按 SDK 近似换算 1 N = 0.1 Nm。
|
|
732
|
+
hold_s: 保力时长 s。``0`` = 一直保到出错、被护栏中止或 Ctrl+C。
|
|
733
|
+
progress: 进度回调。
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
:class:`GraspResult`。夹住工件时 ``stalled=True`` 且
|
|
737
|
+
``reached=False``(压不到空载目标位置是正常的)。闭合段结束时夹爪
|
|
738
|
+
既没到位也没堵转时 ``ok=False``、``cycles=0`` —— 见上。
|
|
739
|
+
|
|
740
|
+
Raises:
|
|
741
|
+
CommandError: ``force_n`` / ``hold_s`` 不是有限数。两条都不是
|
|
742
|
+
「夹得轻/夹得久」的边界,而是没有意义的输入:NaN 的力会让闭合段
|
|
743
|
+
的力矩预算(:func:`force_approach_terms`)整段变成 NaN,NaN 的
|
|
744
|
+
``hold_s`` 会让保力段一帧不保就返回 —— 偏偏报「成功」。
|
|
745
|
+
一直保力请用 ``hold_s=0``,不是 ``inf``。
|
|
746
|
+
"""
|
|
747
|
+
cfg = self.config
|
|
748
|
+
force = cfg.force_n if force_n is None else force_n
|
|
749
|
+
for name, value in (("force_n", force), ("hold_s", hold_s)):
|
|
750
|
+
if not math.isfinite(value):
|
|
751
|
+
raise CommandError(
|
|
752
|
+
f"grasp 的 {name} 不是有限数:{value!r}。拒绝下发。")
|
|
753
|
+
|
|
754
|
+
# 顺序与 ramp_to 一致:先「使能了吗」再「标定了吗」,两条都是拒绝而不
|
|
755
|
+
# 是夹紧。``target_rad`` 那条支路不走 limit_target,标定检查得自己来;
|
|
756
|
+
# 少了它,未标定时的拒绝会静默消失。
|
|
757
|
+
self._g._check_enabled()
|
|
758
|
+
gcfg = self._g.config
|
|
759
|
+
_check_calibrated(gcfg)
|
|
760
|
+
|
|
761
|
+
# 闭合段瞄的是**闭合侧标定限位本身**,不是 limit_target 在限位内侧留的
|
|
762
|
+
# 那 5% 行程。留白那几毫米是自由行程:夹爪到了点上、前面什么都没有,保
|
|
763
|
+
# 力段随后只能用纯力矩把它推过去 —— 那是真机上「末端飞快一下」的一半来
|
|
764
|
+
# 源。瞄限位时夹爪是顶在东西上或停在限位上的,交接从静止开始。
|
|
765
|
+
# ``target_rad`` 会强制 press=False,ok 与 reached 的判据都不动。
|
|
766
|
+
move = self._move_to_limit(
|
|
767
|
+
"close", cfg.grasp_speed_mm_s,
|
|
768
|
+
target_rad=gcfg.pos_closed_rad,
|
|
769
|
+
force_n=force, progress=progress)
|
|
770
|
+
if not (move.reached or move.stalled):
|
|
771
|
+
# 既没到位也没顶上东西 —— 夹爪还在自由行程里,而位置环是此刻唯一
|
|
772
|
+
# 拦着它的东西(kp × 领先 + kd × 速度误差)。保力段的帧里这两项全
|
|
773
|
+
# 没有,交过去就是把设定力矩直接接到一个还在走的夹爪上:真机实测速
|
|
774
|
+
# 度从被速度环限住的 0.82 rad/s 跳到 5.12 rad/s,且到 hold_s 用完
|
|
775
|
+
# 还在加速。宁可如实报失败,也不交。
|
|
776
|
+
self._release(move.state.position_rad)
|
|
777
|
+
log.warning("夹取未交接:闭合段结束时夹爪既未到位也未堵转(停在 "
|
|
778
|
+
"%+.5f rad,目标 %+.5f rad)—— 它还在自由行程里,而保力"
|
|
779
|
+
"段没有位置项也没有速度项,交过去会一路加速。撤力返回。",
|
|
780
|
+
move.state.position_rad, move.target_rad)
|
|
781
|
+
return GraspResult(
|
|
782
|
+
ok=False,
|
|
783
|
+
reached=move.reached,
|
|
784
|
+
stalled=move.stalled,
|
|
785
|
+
state=move.state,
|
|
786
|
+
target_rad=move.target_rad,
|
|
787
|
+
force_n=force,
|
|
788
|
+
cycles=0,
|
|
789
|
+
travel_guard=False,
|
|
790
|
+
)
|
|
791
|
+
hold = self._hold_force(force, hold_s, progress=progress)
|
|
792
|
+
return GraspResult(
|
|
793
|
+
ok=hold.ok,
|
|
794
|
+
reached=move.reached,
|
|
795
|
+
stalled=move.stalled,
|
|
796
|
+
state=hold.state if hold.state is not None else move.state,
|
|
797
|
+
target_rad=move.target_rad,
|
|
798
|
+
force_n=force,
|
|
799
|
+
cycles=hold.cycles,
|
|
800
|
+
travel_guard=hold.guarded,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def ramp_to(
|
|
804
|
+
self,
|
|
805
|
+
target_rad: float,
|
|
806
|
+
speed_mm_s: float = 0.0,
|
|
807
|
+
*,
|
|
808
|
+
duration_s: Optional[float] = None,
|
|
809
|
+
kp: Optional[float] = None,
|
|
810
|
+
kd: Optional[float] = None,
|
|
811
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
812
|
+
) -> MoveResult:
|
|
813
|
+
"""斜坡走到行程内的任意一点:普通定位,不顶限位。
|
|
814
|
+
|
|
815
|
+
``open`` / ``close`` 的终点在标定限位外侧(靠堵转停下);这里走的是
|
|
816
|
+
``frame_interval`` 一格的连续斜坡,每一帧的指令领先实测位置不超过
|
|
817
|
+
:attr:`MotionConfig.max_lead_mm`,指令力矩因此有界在 ``kp × 领先上限``
|
|
818
|
+
附近(默认 4 mm、kp=100 时约 6.6 N·m,远低于 DM4310 的 ±10 N·m 帧量程)。
|
|
819
|
+
|
|
820
|
+
这个上限不是装饰。kp=100 下误差过约 0.1 rad 就把帧量程顶到饱和,而饱和
|
|
821
|
+
的那一帧会冲过目标 —— 2026-10-12 真机上 ``goto(40.0, duration=0.5)``
|
|
822
|
+
冲过目标 42.7 mm、越过标定闭合零点 2.75 mm,就是这么来的。
|
|
823
|
+
|
|
824
|
+
到位判据是 :attr:`MotionConfig.reach_tol`:``ok = reached and not
|
|
825
|
+
stalled``。半路被挡住、整段一帧都没发出去、或者根本没走到,都是
|
|
826
|
+
``ok=False``。
|
|
827
|
+
|
|
828
|
+
与 ``open`` / ``close`` 还有一处不同:**没到位就撤力**。压紧段那点领先是
|
|
829
|
+
意图,普通定位半路停住的领先不是 —— 最后一帧的指令停在「实测位置 + 领先
|
|
830
|
+
上限」上,留在总线上驱动器会按约 6.6 N·m 一直压着。所以 ``ok=False`` 时
|
|
831
|
+
补一段 ``kp=kd=tau=0`` 的释放帧,并把 ``released`` 置上。
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
target_rad: 目标位置 rad(调用方负责先 clamp 进标定行程)。
|
|
835
|
+
speed_mm_s: 斜坡速度 mm/s,必须为正。与 ``duration_s`` 二选一。
|
|
836
|
+
duration_s: 整段斜坡的时长 s,速度由实测距离反推。
|
|
837
|
+
kp: 这一段的刚度,``None`` = :attr:`GripperConfig.kp`。
|
|
838
|
+
kd: 这一段的阻尼,``None`` = :attr:`GripperConfig.kd`。
|
|
839
|
+
progress: 进度回调。
|
|
840
|
+
|
|
841
|
+
Raises:
|
|
842
|
+
CommandError: 目标不是有限数、没标定、速度不合法,或者速度与时长
|
|
843
|
+
同时给了。
|
|
844
|
+
NotInitializedError: 未连接或未使能。
|
|
845
|
+
"""
|
|
846
|
+
g = self._g
|
|
847
|
+
g._check_enabled()
|
|
848
|
+
gcfg = g.config
|
|
849
|
+
_check_calibrated(gcfg)
|
|
850
|
+
|
|
851
|
+
if not math.isfinite(target_rad):
|
|
852
|
+
raise CommandError(
|
|
853
|
+
f"目标位置不是有限数:target_rad={target_rad!r}。拒绝下发:"
|
|
854
|
+
"NaN 一进斜坡算术就会变成帧数。")
|
|
855
|
+
if duration_s is None and not (math.isfinite(speed_mm_s)
|
|
856
|
+
and speed_mm_s > 0.0):
|
|
857
|
+
raise CommandError(
|
|
858
|
+
f"斜坡速度必须是正的有限数:speed_mm_s={speed_mm_s!r};或者给 "
|
|
859
|
+
"duration_s 说明这段路走多久。速度为 0 时斜坡只剩一帧,而那一帧"
|
|
860
|
+
"就是阶跃 —— 正是这条路要消掉的东西。")
|
|
861
|
+
|
|
862
|
+
# ``toward`` 只用来给这次移动取名(上报的 ``limit_rad`` 与日志):目标是
|
|
863
|
+
# 行程内一点,按它离哪一端的标定限位更近取名。反装时两端的数值顺序是反
|
|
864
|
+
# 的,所以比距离,不比大小。
|
|
865
|
+
toward = ("close"
|
|
866
|
+
if abs(target_rad - gcfg.pos_closed_rad)
|
|
867
|
+
<= abs(target_rad - gcfg.pos_open_rad) else "open")
|
|
868
|
+
|
|
869
|
+
res = self._move_to_limit(toward, speed_mm_s, target_rad=target_rad,
|
|
870
|
+
duration_s=duration_s, kp=kp, kd=kd,
|
|
871
|
+
progress=progress)
|
|
872
|
+
if not res.ok and not res.protected:
|
|
873
|
+
# 没到位就没到位:不能让「压着误差」的那一帧留在总线上。
|
|
874
|
+
self._release(res.state.position_rad)
|
|
875
|
+
res.released = True
|
|
876
|
+
return res
|
|
877
|
+
|
|
878
|
+
def zero(self) -> CalibrationData:
|
|
879
|
+
"""完整标定:探闭合 + 张开两个限位,算出行程与换算系数,并存盘。
|
|
880
|
+
|
|
881
|
+
过程中夹爪会主动顶住两端机械限位(低刚度探测)。确保行程内无物。
|
|
882
|
+
探测自带两道护栏:指令领先量不超过 ``calib_step_rad``,且 ``|tau|`` 一到
|
|
883
|
+
``calib_tau_limit`` 立即停止推进 —— 顶住限位时结构让位(背隙/弹性变形)
|
|
884
|
+
会让位置读数一直在动,只靠「位置不再变化」是停不下来的。
|
|
885
|
+
|
|
886
|
+
结果落在 :func:`~litegrip.default_calib_path` 那个文件里,但无参
|
|
887
|
+
``load_calibration()`` 只读出厂标定 —— 下次要用这份,得写
|
|
888
|
+
``load_calibration(path=default_calib_path(channel))`` 或设
|
|
889
|
+
``LITEGRIP_CALIB``。
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
:class:`CalibrationData`。
|
|
893
|
+
"""
|
|
894
|
+
cfg = self.config
|
|
895
|
+
data = self._g.calibrate(
|
|
896
|
+
kp=cfg.calib_kp,
|
|
897
|
+
kd=cfg.calib_kd,
|
|
898
|
+
step_rad=cfg.calib_step_rad,
|
|
899
|
+
stall_delta=cfg.calib_stall_delta,
|
|
900
|
+
stall_cycles=cfg.calib_stall_cycles,
|
|
901
|
+
max_iter=cfg.calib_max_iter,
|
|
902
|
+
tau_limit=cfg.calib_tau_limit,
|
|
903
|
+
)
|
|
904
|
+
self._g.save_calibration()
|
|
905
|
+
return data
|
|
906
|
+
|
|
907
|
+
def enable(self, retries: Optional[int] = None) -> EnableResult:
|
|
908
|
+
"""反复 enable 直到状态帧回读到 ``err == 1``(真使能)。
|
|
909
|
+
|
|
910
|
+
为什么需要重试:``enable`` 是单向命令、无确认,CAN 上丢一帧就白发了。
|
|
911
|
+
这里把「发 enable → 回读状态帧 → 不是 1 就重发」做成显式重试。
|
|
912
|
+
``err`` 属于真实故障(非 0/1)时先 ``clear_fault()`` 再重试。
|
|
913
|
+
|
|
914
|
+
Args:
|
|
915
|
+
retries: 重试次数,``None`` = 用 :attr:`MotionConfig.enable_retries`。
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
:class:`EnableResult`(``ok`` / ``state`` / ``tries``)。
|
|
919
|
+
"""
|
|
920
|
+
cfg = self.config
|
|
921
|
+
tries_max = cfg.enable_retries if retries is None else retries
|
|
922
|
+
st: Optional[GripperState] = None
|
|
923
|
+
|
|
924
|
+
for i in range(1, tries_max + 1):
|
|
925
|
+
try:
|
|
926
|
+
self._g._enable_once()
|
|
927
|
+
except LiteGripError as e:
|
|
928
|
+
log.warning("enable() 第 %d/%d 次抛错:%s: %s",
|
|
929
|
+
i, tries_max, type(e).__name__, e)
|
|
930
|
+
|
|
931
|
+
st = self._g.get_state()
|
|
932
|
+
if st.error_code == 1:
|
|
933
|
+
return EnableResult(ok=True, state=st, tries=i)
|
|
934
|
+
|
|
935
|
+
if st.error_code not in (0, 1):
|
|
936
|
+
# 真实故障(欠压/过流/过温等):先清故障再重试
|
|
937
|
+
try:
|
|
938
|
+
self._g.clear_fault()
|
|
939
|
+
except LiteGripError as e:
|
|
940
|
+
log.warning("clear_fault() 失败:%s", e)
|
|
941
|
+
|
|
942
|
+
if i < tries_max:
|
|
943
|
+
log.warning("第 %d/%d 次未使能(状态帧 err=%d,0=未使能)"
|
|
944
|
+
"—— %.2fs 后重发 enable",
|
|
945
|
+
i, tries_max, st.error_code,
|
|
946
|
+
cfg.enable_retry_interval)
|
|
947
|
+
cfg.sleep_fn(cfg.enable_retry_interval)
|
|
948
|
+
|
|
949
|
+
return EnableResult(ok=False, state=st, tries=tries_max)
|
|
950
|
+
|
|
951
|
+
def disable(self) -> bool:
|
|
952
|
+
"""失能电机。"""
|
|
953
|
+
return self._g._disable_once()
|
|
954
|
+
|
|
955
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
956
|
+
# 引擎
|
|
957
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
958
|
+
|
|
959
|
+
def _emit(self, q: float, dq: float = 0.0, tau: float = 0.0,
|
|
960
|
+
kp: Optional[float] = None, kd: Optional[float] = None) -> float:
|
|
961
|
+
"""下发一帧 MIT 并等待一帧的时间。
|
|
962
|
+
|
|
963
|
+
位置先过 :func:`clamp_to_travel`:这是引擎所有帧的必经之路,所以「指令
|
|
964
|
+
不越标定行程」是引擎级不变式,而不是某一处算术的性质。``open`` /
|
|
965
|
+
``close`` 的目标本来就在限位外(:func:`press_target`),夹的就是它。
|
|
966
|
+
|
|
967
|
+
Returns:
|
|
968
|
+
真正下发的那个 ``q``(夹过之后的值)—— 调用方拿它填
|
|
969
|
+
``MoveResult.final_cmd_rad`` / ``MoveProgress.cmd_rad``,报的是发出去
|
|
970
|
+
的指令,不是算出来又没发的。
|
|
971
|
+
|
|
972
|
+
Raises:
|
|
973
|
+
NotInitializedError: 夹爪没连上,或者没使能 —— 原样抛出
|
|
974
|
+
:attr:`LiteGrip.last_send_error` 里记的那一条,它说的是该补哪
|
|
975
|
+
一步(``connect()`` / ``enable()``)。
|
|
976
|
+
CommError: 帧没发出去,而下层给的原因是传输层失败(如
|
|
977
|
+
``OSError``),或者什么都没给。消息里带原样的原因 —— 这一层
|
|
978
|
+
看不到总线,替下层猜一个「未连接或未使能」就是谎报。
|
|
979
|
+
"""
|
|
980
|
+
cfg = self.config
|
|
981
|
+
g = self._g
|
|
982
|
+
q = clamp_to_travel(g.config, q)
|
|
983
|
+
sent = g.send_mit_frame(
|
|
984
|
+
q,
|
|
985
|
+
g.config.kp if kp is None else kp,
|
|
986
|
+
g.config.kd if kd is None else kd,
|
|
987
|
+
dq=dq,
|
|
988
|
+
tau=tau,
|
|
989
|
+
)
|
|
990
|
+
if not sent:
|
|
991
|
+
_raise_send_failure(g)
|
|
992
|
+
cfg.sleep_fn(cfg.frame_interval)
|
|
993
|
+
return q
|
|
994
|
+
|
|
995
|
+
def _release(self, pos: float) -> int:
|
|
996
|
+
"""失力:连发 :attr:`MotionConfig.stop_release_s` 的 ``kp=kd=tau=0`` 帧。
|
|
997
|
+
|
|
998
|
+
两处保护性中止(行进段的力矩保护、保力段的位置护栏)共用这一份 —— 触发即
|
|
999
|
+
失力,夹爪能被手掰动,而不是继续压着。``q`` 用当前读数:零增益下 ``q`` 不
|
|
1000
|
+
产生任何力,只是给个位置合理的指令,免得下游把它当成一次正常定位。
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
pos: 当前实测位置 rad,作为释放帧的 ``q``。
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
实际下发的帧数。
|
|
1007
|
+
"""
|
|
1008
|
+
cfg = self.config
|
|
1009
|
+
frames = max(1, int(round(cfg.stop_release_s / cfg.frame_interval)))
|
|
1010
|
+
for _ in range(frames):
|
|
1011
|
+
self._emit(pos, 0.0, 0.0, kp=0.0, kd=0.0)
|
|
1012
|
+
return frames
|
|
1013
|
+
|
|
1014
|
+
def _move_to_limit(
|
|
1015
|
+
self,
|
|
1016
|
+
toward: str,
|
|
1017
|
+
speed_mm_s: float = 0.0,
|
|
1018
|
+
*,
|
|
1019
|
+
press: bool = False,
|
|
1020
|
+
target_rad: Optional[float] = None,
|
|
1021
|
+
duration_s: Optional[float] = None,
|
|
1022
|
+
force_n: Optional[float] = None,
|
|
1023
|
+
kp: Optional[float] = None,
|
|
1024
|
+
kd: Optional[float] = None,
|
|
1025
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
1026
|
+
) -> MoveResult:
|
|
1027
|
+
"""走到一端的限位:整段是一条 frame_interval 一格的连续斜坡(MIT 帧 +
|
|
1028
|
+
速度前馈),边走边按位置判堵转。
|
|
1029
|
+
|
|
1030
|
+
``press=False``:目标是「限位内侧留 :attr:`MotionConfig.margin`」,
|
|
1031
|
+
到位即成功。**现在没有调用方走这一条** —— ``open`` / ``close`` 走
|
|
1032
|
+
``press=True``,``grasp`` 和 ``ramp_to`` 一族给 ``target_rad``,所以
|
|
1033
|
+
``margin`` 目前只在直接调用本方法时起作用。
|
|
1034
|
+
``press=True``:目标是「越过限位 :attr:`MotionConfig.press_overshoot`」,
|
|
1035
|
+
靠堵转停在限位上;距限位 :attr:`MotionConfig.press_zone_mm` 之内领先
|
|
1036
|
+
上限切到 :attr:`MotionConfig.stop_lead_mm`,接近段的力矩有界。
|
|
1037
|
+
|
|
1038
|
+
无论走哪一条,每一帧真正下发的指令都经 :func:`clamp_to_travel` 封顶在
|
|
1039
|
+
标定行程内(见 :meth:`_emit`)—— 目标可以越位,指令不许。
|
|
1040
|
+
|
|
1041
|
+
``target_rad``:显式目标(如 :func:`work_limit_target` 算出的工作行程
|
|
1042
|
+
点)。给了它就走**普通定位**语义(``press`` 视为 False),``limit`` 取该
|
|
1043
|
+
方向的标定限位用于上报。
|
|
1044
|
+
|
|
1045
|
+
``duration_s``:整段斜坡**走多久**,速度由实测距离除以它反推 —— 距离
|
|
1046
|
+
要读了状态才知道,所以它必须在同一个 ``before`` 读数上算,不能在调用方
|
|
1047
|
+
先算一遍。``speed_mm_s`` 与它二选一(两个都给就拒绝,不是忽略一个)。
|
|
1048
|
+
|
|
1049
|
+
``force_n``:这一段**带着多大的力走**。``None`` = 不带(普通移动,压出
|
|
1050
|
+
多少由领先上限和速度决定)。给了它,它的力矩预算就分给这一帧的三项(速
|
|
1051
|
+
度、阻尼、领先,见 :func:`force_approach_terms`),所以撞上工件时压出的
|
|
1052
|
+
力矩不超过这个设定值 —— ``grasp`` 的闭合段给的就是它这次的夹持力。
|
|
1053
|
+
|
|
1054
|
+
带设定力时段的**终止条件也换了一条**:判据是「到位或顶上」,``ramp_steps``
|
|
1055
|
+
只是排程、不是它的终止条件。领先被预算压小之后夹爪追不上排程是常态,按帧
|
|
1056
|
+
数收尾会在它还差几十毫米时把它交给下一段(真机实测差 21 mm),而那一段
|
|
1057
|
+
如果是纯力矩的保力段,接下来就是一路加速。所以这一段的帧数只作兜底,上界
|
|
1058
|
+
取「按仍然算进展的最慢速度走完全程要几帧」。
|
|
1059
|
+
|
|
1060
|
+
``kp`` / ``kd``:这一段的增益,``None`` = 用 :attr:`GripperConfig.kp` /
|
|
1061
|
+
``.kd``。``open`` / ``close`` / ``grasp`` 不给(就是配置值),定位那几条
|
|
1062
|
+
透传调用方的。
|
|
1063
|
+
|
|
1064
|
+
返回 :class:`MoveResult`。
|
|
1065
|
+
"""
|
|
1066
|
+
cfg = self.config
|
|
1067
|
+
g = self._g
|
|
1068
|
+
g._check_enabled()
|
|
1069
|
+
gcfg = g.config
|
|
1070
|
+
|
|
1071
|
+
if not math.isfinite(speed_mm_s):
|
|
1072
|
+
raise CommandError(
|
|
1073
|
+
f"移动速度不是有限数:speed_mm_s={speed_mm_s!r}。拒绝下发:"
|
|
1074
|
+
"斜坡的帧数与速度前馈都由它算出来。")
|
|
1075
|
+
if duration_s is not None:
|
|
1076
|
+
if speed_mm_s > 0.0:
|
|
1077
|
+
raise CommandError(
|
|
1078
|
+
f"speed_mm_s={speed_mm_s!r} 与 duration_s={duration_s!r} "
|
|
1079
|
+
"只能给一个:前者说「走多快」,后者说「这段路走多久」。")
|
|
1080
|
+
if not math.isfinite(duration_s) or duration_s <= 0.0:
|
|
1081
|
+
raise CommandError(
|
|
1082
|
+
f"duration_s 必须是正的有限数:{duration_s!r}。速度由实测"
|
|
1083
|
+
"距离除以它算出来 —— 0 或负数没有对应的斜坡。")
|
|
1084
|
+
if force_n is not None:
|
|
1085
|
+
raise CommandError(
|
|
1086
|
+
"duration_s 与 force_n 不能同时给:带设定力的接近段,速度"
|
|
1087
|
+
"预算由设定力分配(见 force_approach_terms),先定时长会把"
|
|
1088
|
+
"那条预算反过来推翻。")
|
|
1089
|
+
|
|
1090
|
+
if target_rad is not None:
|
|
1091
|
+
press = False
|
|
1092
|
+
limit = (gcfg.pos_closed_rad if toward == "close"
|
|
1093
|
+
else gcfg.pos_open_rad)
|
|
1094
|
+
target = target_rad
|
|
1095
|
+
elif press:
|
|
1096
|
+
target, limit, _over_rad, _travel = press_target(
|
|
1097
|
+
gcfg, toward, cfg.press_overshoot)
|
|
1098
|
+
else:
|
|
1099
|
+
target, limit, _margin_rad, _travel = limit_target(
|
|
1100
|
+
gcfg, toward, cfg.margin)
|
|
1101
|
+
|
|
1102
|
+
before = g.get_state()
|
|
1103
|
+
dist_rad = target - before.position_rad
|
|
1104
|
+
dist_mm = abs(dist_rad) * gcfg.rad_to_mm
|
|
1105
|
+
sign = 1.0 if dist_rad >= 0 else -1.0
|
|
1106
|
+
if duration_s is not None:
|
|
1107
|
+
# 「这段路走多久」:距离读了状态才知道,所以速度在这里才反推得出。
|
|
1108
|
+
# dist_mm == 0 时速度为 0 —— 斜坡退化成一帧 + 保压,正好是「已经在
|
|
1109
|
+
# 那儿了」,不必为它写特例。
|
|
1110
|
+
speed_mm_s = dist_mm / duration_s
|
|
1111
|
+
speed_rad_s = speed_mm_s / gcfg.rad_to_mm
|
|
1112
|
+
|
|
1113
|
+
interval = cfg.frame_interval
|
|
1114
|
+
# 不带力的移动用配置里那条行进段上限;带了力,上限和这一帧的阻尼、速
|
|
1115
|
+
# 度一起由设定值的预算定(见 force_approach_terms)。
|
|
1116
|
+
kp_frame = gcfg.kp if kp is None else kp
|
|
1117
|
+
kd_frame = gcfg.kd if kd is None else kd
|
|
1118
|
+
lead_ceiling_rad = cfg.max_lead_mm / gcfg.rad_to_mm
|
|
1119
|
+
if force_n is not None:
|
|
1120
|
+
speed_rad_s, kd_frame, lead_ceiling_rad = force_approach_terms(
|
|
1121
|
+
kp=kp_frame,
|
|
1122
|
+
kd=kd_frame,
|
|
1123
|
+
speed_rad_s=speed_rad_s,
|
|
1124
|
+
frame_interval=interval,
|
|
1125
|
+
budget_nm=force_n * UnitConversion.N_TO_NM * cfg.press_safety,
|
|
1126
|
+
lead_ceiling_rad=lead_ceiling_rad,
|
|
1127
|
+
)
|
|
1128
|
+
# 预算只降不升,但降下来以后斜坡的帧数、速度前馈和堵转阈值都得跟着
|
|
1129
|
+
# 走那一条速度,否则斜坡跑完了夹爪还在半路。
|
|
1130
|
+
speed_mm_s = speed_rad_s * gcfg.rad_to_mm
|
|
1131
|
+
|
|
1132
|
+
ramp_s = dist_mm / speed_mm_s if speed_mm_s > 0 else 0.0
|
|
1133
|
+
ramp_steps = max(1, int(round(ramp_s / interval)))
|
|
1134
|
+
settle_steps = max(1, int(round(cfg.settle_s / interval)))
|
|
1135
|
+
total_steps = ramp_steps + settle_steps
|
|
1136
|
+
|
|
1137
|
+
sample_every = max(1, int(round(cfg.sample_interval / interval)))
|
|
1138
|
+
win = max(cfg.stall_cycles, 1)
|
|
1139
|
+
win_s = win * sample_every * interval
|
|
1140
|
+
win_expect_rad = speed_rad_s * win_s
|
|
1141
|
+
win_thresh_rad = max(cfg.stall_delta, cfg.stall_ratio * win_expect_rad)
|
|
1142
|
+
# 帧数上界。不带力的移动按排程走,排程跑完就是它的终止条件之一;带设定
|
|
1143
|
+
# 力的接近段不行 —— 领先被预算压小(见 force_approach_terms),夹爪追不
|
|
1144
|
+
# 追得上排程由摩擦和读数延迟定,``ramp_steps`` 到了它可能还差几十毫米。
|
|
1145
|
+
# 所以这一段的帧数只是兜底,上界取「按仍然算进展的最慢速度走完全程要几
|
|
1146
|
+
# 帧」:比这更慢时堵转判据必然已经在 win_s 内触发,这个上界到不了。
|
|
1147
|
+
ceiling = total_steps
|
|
1148
|
+
if force_n is not None:
|
|
1149
|
+
floor_rad_s = win_thresh_rad / win_s if win_s > 0.0 else 0.0
|
|
1150
|
+
if 0.0 < floor_rad_s < speed_rad_s:
|
|
1151
|
+
ceiling = max(total_steps,
|
|
1152
|
+
int(math.ceil(abs(dist_rad) / floor_rad_s
|
|
1153
|
+
/ interval)) + settle_steps)
|
|
1154
|
+
# 「这一趟本来就没事可做」不判堵转。目标落在到位容差里时,夹爪原地不动
|
|
1155
|
+
# 就是到位,读成堵转会把一次成功的定位报成失败 —— 而它会真的发生:排程
|
|
1156
|
+
# 的帧数由 duration 定、不随距离缩短,间距趋近 0 时排程速度也趋近 0,
|
|
1157
|
+
# 整段斜坡都是「不动」。`home()` 紧跟在 `close()` 之后(两个都瞄着闭合
|
|
1158
|
+
# 端,只剩一点点齿隙)就是这个形状。压紧路径不受影响:那里目标在限位
|
|
1159
|
+
# 外侧,「到了还不动」正是要判的堵转。
|
|
1160
|
+
judge_stall = press or dist_mm > cfg.reach_tol * gcfg.rad_to_mm
|
|
1161
|
+
|
|
1162
|
+
# 两档领先上限:行进段用 max_lead_mm 破静摩擦,贴近限位后收窄到
|
|
1163
|
+
# stop_lead_mm,压紧力矩 ≈ kp × stop_lead_mm。下限都是一帧的位移,
|
|
1164
|
+
# 免得斜坡自己那一格被切掉。
|
|
1165
|
+
min_cap_rad = speed_rad_s * interval
|
|
1166
|
+
travel_cap_rad = max(lead_ceiling_rad, min_cap_rad)
|
|
1167
|
+
stop_cap_rad = max(cfg.stop_lead_mm / gcfg.rad_to_mm, min_cap_rad)
|
|
1168
|
+
press_zone_rad = cfg.press_zone_mm / gcfg.rad_to_mm
|
|
1169
|
+
|
|
1170
|
+
log.info("%s %.4f → %.4f rad(%.1f mm/s,%.1f mm,%d+%d 帧,"
|
|
1171
|
+
"堵转阈值 %.5f rad,领先上限 %.5f/%.5f rad,"
|
|
1172
|
+
"标定行程 [%.4f, %.4f] 封顶%s%s%s)",
|
|
1173
|
+
"闭合" if toward == "close" else "张开",
|
|
1174
|
+
before.position_rad, target, speed_mm_s, dist_mm,
|
|
1175
|
+
ramp_steps, settle_steps, win_thresh_rad,
|
|
1176
|
+
travel_cap_rad, stop_cap_rad,
|
|
1177
|
+
min(gcfg.pos_closed_rad, gcfg.pos_open_rad),
|
|
1178
|
+
max(gcfg.pos_closed_rad, gcfg.pos_open_rad),
|
|
1179
|
+
",顶限位" if press else "",
|
|
1180
|
+
"" if force_n is None else
|
|
1181
|
+
",力预算 %.3f Nm(kd %.2f)" % (
|
|
1182
|
+
force_n * UnitConversion.N_TO_NM * cfg.press_safety,
|
|
1183
|
+
kd_frame),
|
|
1184
|
+
"" if ceiling == total_steps else
|
|
1185
|
+
",帧数上界 %d(兜底,见上)" % ceiling)
|
|
1186
|
+
|
|
1187
|
+
hist = [before.position_rad]
|
|
1188
|
+
stalled = False
|
|
1189
|
+
protected = False
|
|
1190
|
+
over_torque = 0 # 连续过阈的采样点数
|
|
1191
|
+
prev_sample_pos = before.position_rad
|
|
1192
|
+
st = before
|
|
1193
|
+
last_cmd = before.position_rad
|
|
1194
|
+
last_i = 0
|
|
1195
|
+
arrived_at = None # 首次进入顶住段的帧号(见下)
|
|
1196
|
+
clamped_once = False # 本次移动只记一条封顶 debug 日志
|
|
1197
|
+
|
|
1198
|
+
for i in range(1, ceiling + 1):
|
|
1199
|
+
st = g.get_state(wait=False)
|
|
1200
|
+
pos = st.position_rad
|
|
1201
|
+
# 排程相位。不带力时按计时切 —— ``ramp_steps`` 就是排程跑完的那一
|
|
1202
|
+
# 帧。带设定力时不行:领先被预算压小,夹爪落在排程后面是常态,而
|
|
1203
|
+
# ``ramp_steps`` 到了不等于它到了,按计时切会在它还在自由行程时就
|
|
1204
|
+
# 判它进了保压段(不再判堵转、撤掉速度前馈)。所以这一段由**位置**
|
|
1205
|
+
# 说:没到目标就还在行进段,到了才进保压。ramp_steps 之后 q_sched
|
|
1206
|
+
# 停在 target,下面两种写法一致。
|
|
1207
|
+
arrived = abs(pos - target) < cfg.reach_tol
|
|
1208
|
+
in_ramp = i <= ramp_steps or (force_n is not None and not arrived)
|
|
1209
|
+
if in_ramp:
|
|
1210
|
+
q_sched = before.position_rad + dist_rad * min(1.0, i / ramp_steps)
|
|
1211
|
+
dq = sign * speed_rad_s
|
|
1212
|
+
else:
|
|
1213
|
+
# 到位后的顶住段(``q_sched = target``、``dq = 0``):把还在动
|
|
1214
|
+
# 的夹爪收住。带设定力时这段同时是**交接前的静止窗口** —— 顶够
|
|
1215
|
+
# ``settle_steps`` 帧之后位置环的任务就完了,保力段从静止接手
|
|
1216
|
+
# (真机事故就是没等这一段,把 0.82 rad/s 的夹爪直接交给了纯
|
|
1217
|
+
# 力矩源)。不带力时下面这个 break 到不了:循环上界就是
|
|
1218
|
+
# ramp_steps + settle_steps。
|
|
1219
|
+
if arrived_at is None:
|
|
1220
|
+
arrived_at = i
|
|
1221
|
+
if i - arrived_at >= settle_steps:
|
|
1222
|
+
break
|
|
1223
|
+
q_sched = target # 保压:原地顶住目标
|
|
1224
|
+
dq = 0.0
|
|
1225
|
+
# 压紧段判据按 q_sched 算:斜坡上贴近限位、保压段越过限位,
|
|
1226
|
+
# 两种情况都要切到 stop_lead_mm,否则保压段领先会涨到
|
|
1227
|
+
# kp × 越位量(≈8 Nm)。
|
|
1228
|
+
lead_cap_rad = travel_cap_rad
|
|
1229
|
+
if press and (limit - q_sched) * sign <= press_zone_rad:
|
|
1230
|
+
lead_cap_rad = stop_cap_rad
|
|
1231
|
+
lead = (q_sched - pos) * sign
|
|
1232
|
+
cmd = pos + sign * lead_cap_rad if lead > lead_cap_rad else q_sched
|
|
1233
|
+
# _emit 会把位置夹进标定行程,返回值才是真正下发的指令:领先项
|
|
1234
|
+
# 会让 cmd 跑到限位外侧,尤其 press=True 时(目标本身就在外侧)。
|
|
1235
|
+
last_cmd = self._emit(cmd, dq, 0.0, kp=kp_frame, kd=kd_frame)
|
|
1236
|
+
if last_cmd != cmd and not clamped_once:
|
|
1237
|
+
clamped_once = True
|
|
1238
|
+
log.debug("%s 指令封顶标定行程:%.5f → %.5f rad(标定 [%.5f, %.5f])",
|
|
1239
|
+
toward, cmd, last_cmd,
|
|
1240
|
+
min(gcfg.pos_closed_rad, gcfg.pos_open_rad),
|
|
1241
|
+
max(gcfg.pos_closed_rad, gcfg.pos_open_rad))
|
|
1242
|
+
last_i = i
|
|
1243
|
+
|
|
1244
|
+
if i % sample_every and i != ceiling:
|
|
1245
|
+
continue
|
|
1246
|
+
# 本采样的实测速度(rad/s):用于堵转力矩保护的「有没有跟上」佐证。
|
|
1247
|
+
rate_rad_s = abs(pos - prev_sample_pos) / cfg.sample_interval
|
|
1248
|
+
prev_sample_pos = pos
|
|
1249
|
+
hist.append(pos)
|
|
1250
|
+
win_delta = (abs(hist[-1] - hist[-1 - win])
|
|
1251
|
+
if len(hist) > win else None)
|
|
1252
|
+
if progress is not None:
|
|
1253
|
+
progress(MoveProgress(
|
|
1254
|
+
phase="move",
|
|
1255
|
+
i=i,
|
|
1256
|
+
total_steps=ceiling,
|
|
1257
|
+
cmd_rad=last_cmd,
|
|
1258
|
+
pos_rad=pos,
|
|
1259
|
+
delta_rad=hist[-1] - hist[-2],
|
|
1260
|
+
win_delta_rad=win_delta,
|
|
1261
|
+
torque_nm=st.torque_nm,
|
|
1262
|
+
temperature_coil=st.temperature_coil,
|
|
1263
|
+
))
|
|
1264
|
+
# 保压段 (i > ramp_steps) 本来就该不动,所以默认不判堵转。
|
|
1265
|
+
# 但 press=True 时保压段的**目标**在限位外侧(下发的指令被夹在限位上),
|
|
1266
|
+
# 夹爪到了那里就该顶着不动 —— 那里的「不动」正是我们要的堵转。而且
|
|
1267
|
+
# 越位量(5% 行程)往往短于一个采样窗口,只在斜坡段判会永远判不到,
|
|
1268
|
+
# 动作得白跑完保压段。
|
|
1269
|
+
if (judge_stall and win_delta is not None
|
|
1270
|
+
and (press or in_ramp)
|
|
1271
|
+
and win_delta < win_thresh_rad):
|
|
1272
|
+
stalled = True
|
|
1273
|
+
log.info("堵转:最近 %d 次采样(%.2f s)净位移仅 %.5f rad "
|
|
1274
|
+
"(< %.5f),停在 %+.5f rad",
|
|
1275
|
+
win, win_s, win_delta, win_thresh_rad, pos)
|
|
1276
|
+
break
|
|
1277
|
+
|
|
1278
|
+
# 行进段堵转力矩保护(≈7 N):与位置窗口判据**互补** —— 位置窗口对
|
|
1279
|
+
# 「硬停但结构一直缓慢让位」会漏判(净位移始终够),这一路只看力矩。
|
|
1280
|
+
# 门槛只在**行进段**生效(leading 还是 max_lead_mm):那里持续高力矩
|
|
1281
|
+
# 意味着真的顶上了东西。压紧段的领先已收窄到 stop_lead_mm,本来就该
|
|
1282
|
+
# 顶着力矩,硬加力矩门槛会让每一次 open/close 都误触发。
|
|
1283
|
+
if press and lead_cap_rad == travel_cap_rad and speed_rad_s > 0.0:
|
|
1284
|
+
slow = rate_rad_s < cfg.stop_speed_ratio * speed_rad_s
|
|
1285
|
+
if abs(st.torque_nm) >= cfg.stop_torque_nm and slow:
|
|
1286
|
+
over_torque += 1
|
|
1287
|
+
else:
|
|
1288
|
+
over_torque = 0
|
|
1289
|
+
if over_torque >= cfg.stop_torque_cycles:
|
|
1290
|
+
protected = True
|
|
1291
|
+
stalled = True
|
|
1292
|
+
log.info("堵转保护:|tau|=%.3f Nm ≥ %.3f 且速度 %.4f rad/s 只"
|
|
1293
|
+
"有指令的 %.0f%%,连续 %d 次采样 —— 判堵转并失力",
|
|
1294
|
+
st.torque_nm, cfg.stop_torque_nm, rate_rad_s,
|
|
1295
|
+
100.0 * rate_rad_s / speed_rad_s, over_torque)
|
|
1296
|
+
break
|
|
1297
|
+
|
|
1298
|
+
if protected:
|
|
1299
|
+
self._release(pos)
|
|
1300
|
+
|
|
1301
|
+
st = g.get_state() # 阻塞等一帧新状态再判到位
|
|
1302
|
+
reached = abs(st.position_rad - target) < cfg.reach_tol
|
|
1303
|
+
if press:
|
|
1304
|
+
# 顶到限位(堵转 + 停在标定限位附近)才算成功;半路撞工件
|
|
1305
|
+
# 也是堵转,但离限位很远 —— 保留这个区分,不丢信号。
|
|
1306
|
+
ok = stalled and abs(st.position_rad - limit) <= cfg.stop_tol
|
|
1307
|
+
else:
|
|
1308
|
+
ok = reached and not stalled
|
|
1309
|
+
if protected:
|
|
1310
|
+
ok = False # 保护性堵转永远不算成功
|
|
1311
|
+
return MoveResult(
|
|
1312
|
+
ok=ok,
|
|
1313
|
+
reached=reached,
|
|
1314
|
+
stalled=stalled,
|
|
1315
|
+
state=st,
|
|
1316
|
+
target_rad=target,
|
|
1317
|
+
limit_rad=limit,
|
|
1318
|
+
final_cmd_rad=last_cmd,
|
|
1319
|
+
steps=last_i,
|
|
1320
|
+
protected=protected,
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
def _hold_force(
|
|
1324
|
+
self,
|
|
1325
|
+
force_n: float,
|
|
1326
|
+
hold_s: float,
|
|
1327
|
+
*,
|
|
1328
|
+
progress: Optional[Callable[[MoveProgress], None]] = None,
|
|
1329
|
+
) -> HoldResult:
|
|
1330
|
+
"""持续输出夹持力:整段只下发前馈力矩。
|
|
1331
|
+
|
|
1332
|
+
帧里 ``kp=kd=0`` —— 保力要的是**力**,而 MIT 律里 ``kp × (q - 实测位置)``
|
|
1333
|
+
与 ``kd × (0 - 实测速度)`` 都随夹爪的位置和速度变化:工件在设定力下让位
|
|
1334
|
+
(或闭合侧约 0.010 rad 的粘滑死区走一格),实测位置就往前挪,``kp × 位移``
|
|
1335
|
+
立刻从前馈里扣掉一截,读数表现为「先夹到设定力,过一会儿掉到某个更小的
|
|
1336
|
+
值」。上一版锚在 200 ms 前的位置读数上、``kp=150``,工件以 3 mm/s 让位就能
|
|
1337
|
+
把 20 N 读成 5 N。所以**不要**为了「顶得更硬」把增益加回来;
|
|
1338
|
+
:attr:`MotionConfig.hold_kp` / :attr:`MotionConfig.hold_kd` 已废弃,设了也不
|
|
1339
|
+
生效。
|
|
1340
|
+
|
|
1341
|
+
力矩不是一步跳到设定值,而是按 :attr:`MotionConfig.force_ramp_n_s`
|
|
1342
|
+
(N/s)从**进入保力时飞行中的力矩**爬上去,且每下发一帧走一步、正好落在
|
|
1343
|
+
设定值上。这一步不是可有可无的:交接时电机的力矩本就带着闭合压紧量
|
|
1344
|
+
(真机上约 10 N),一步跳到设定值就是隔着机构的一次冲击,指爪会被刚碰到
|
|
1345
|
+
的东西弹开 —— 现场看到的是「夹爪停在 10 N,然后跳到 20 N,边跳边往里
|
|
1346
|
+
收」。从飞行值开始爬,交接是连续的;爬升率是常量,力就是均匀地涨。定的是
|
|
1347
|
+
**速率**不是时长:按时长定的斜坡第一拍最陡,等于带慢尾的阶跃。
|
|
1348
|
+
|
|
1349
|
+
每片 :attr:`MotionConfig.hold_interval` 回读一次状态查故障(一帧一步的爬升
|
|
1350
|
+
都在片内完成,见上面的速率)。
|
|
1351
|
+
|
|
1352
|
+
代价:零增益下夹爪可以被外力推动,所以保力段**没有位置指令可夹**
|
|
1353
|
+
(:func:`closing_travel_excursion_rad` 的 docstring 讲了为什么)。这一路
|
|
1354
|
+
的护栏因此是**判据**:每回读一片就看实测位置是否越出闭合侧标定限位超过
|
|
1355
|
+
:attr:`MotionConfig.hold_runaway_rad`,越了就撤力返回。空载时它仍然会顶
|
|
1356
|
+
到真限位(和 ``close()`` 的压紧段一样,只是力矩小得多),但真限位不在那
|
|
1357
|
+
儿时不会再一路走到底 —— 那正是 2026-10-10 把夹爪顶坏的工况。
|
|
1358
|
+
|
|
1359
|
+
护栏在**两处**判:进循环之前(已经在护栏之外就直接失力,不往里再推)和
|
|
1360
|
+
每个回读片。配置没标定时不启用 —— 占位限位描述的不是真实行程。经
|
|
1361
|
+
``grasp()`` 进来时标定一定在:它的闭合段先过 ``_move_to_limit``。
|
|
1362
|
+
|
|
1363
|
+
``hold_s <= 0`` 表示不限时长(直到出错、被护栏中止或 Ctrl+C)。
|
|
1364
|
+
|
|
1365
|
+
Returns:
|
|
1366
|
+
:class:`HoldResult`。
|
|
1367
|
+
"""
|
|
1368
|
+
cfg = self.config
|
|
1369
|
+
g = self._g
|
|
1370
|
+
gcfg = g.config
|
|
1371
|
+
# 夹紧方向的力矩符号随安装方向翻转:正装时 rad 增大是闭合,
|
|
1372
|
+
# 反装时反过来。力矩大小不变,只是要让前馈往「夹」而不是「撑」。
|
|
1373
|
+
target_nm = gcfg.close_sign * force_n * UnitConversion.N_TO_NM
|
|
1374
|
+
# 位置护栏。没标定时不启用:占位限位(pos_closed_rad=1.14 /
|
|
1375
|
+
# pos_open_rad=0.0)描述的不是任何真实行程,拿它算越位量比没有更糟。
|
|
1376
|
+
guard_rad = cfg.hold_runaway_rad
|
|
1377
|
+
guarded = gcfg.calibrated
|
|
1378
|
+
|
|
1379
|
+
deadline = None if hold_s <= 0 else cfg.monotonic_fn() + hold_s
|
|
1380
|
+
frames_per_slice = max(1, int(round(cfg.hold_interval
|
|
1381
|
+
/ cfg.frame_interval)))
|
|
1382
|
+
# 一帧一步。一片是 frames_per_slice 帧,只在片首走一步的话,20 N/s 在 0.2 s
|
|
1383
|
+
# 的片里就成了 4 N 一级的台阶,不是爬升。
|
|
1384
|
+
step_nm = cfg.force_ramp_n_s * cfg.frame_interval * UnitConversion.N_TO_NM
|
|
1385
|
+
cycles = 0
|
|
1386
|
+
st = g.get_state()
|
|
1387
|
+
pos = st.position_rad
|
|
1388
|
+
# 进入保力:从飞行中的力矩接着爬,交接才连续。飞行力矩已经越过设定值时
|
|
1389
|
+
# 直接从设定值起步(那一步是往下走到设定值,不是往上,不构成冲击)。
|
|
1390
|
+
tau_cmd = st.torque_nm if abs(st.torque_nm) < abs(target_nm) else target_nm
|
|
1391
|
+
|
|
1392
|
+
# 护栏判在**第一帧之前**:已经在外面就直接失力。放进循环里判就晚了 ——
|
|
1393
|
+
# 那一片 frames_per_slice 帧的力矩已经发出去了,正是要拦住的东西。
|
|
1394
|
+
if guarded:
|
|
1395
|
+
out_rad = closing_travel_excursion_rad(gcfg, pos)
|
|
1396
|
+
if out_rad > guard_rad:
|
|
1397
|
+
log.warning("保力未开始:实测位置 %+.5f rad 已经越出闭合侧标定"
|
|
1398
|
+
"限位 %+.5f rad 共 %.5f rad(> %.5f)—— 判定闭合侧"
|
|
1399
|
+
"没有东西拦着它,撤力。",
|
|
1400
|
+
pos, gcfg.pos_closed_rad, out_rad, guard_rad)
|
|
1401
|
+
self._release(pos)
|
|
1402
|
+
return HoldResult(ok=False, cycles=0, state=st, guarded=True)
|
|
1403
|
+
|
|
1404
|
+
while deadline is None or cfg.monotonic_fn() < deadline:
|
|
1405
|
+
# 用上一次读到的位置当 q 下发(kp=0 时 q 不产生任何力,只是给下游
|
|
1406
|
+
# 一个不越位的指令),每帧把力矩朝设定值推一步,再回读状态查故障
|
|
1407
|
+
for _ in range(frames_per_slice):
|
|
1408
|
+
tau_cmd = _toward(tau_cmd, target_nm, step_nm)
|
|
1409
|
+
self._emit(pos, 0.0, tau_cmd, kp=0.0, kd=0.0)
|
|
1410
|
+
cycles += 1
|
|
1411
|
+
|
|
1412
|
+
st = g.get_state()
|
|
1413
|
+
if st.error_code != 1:
|
|
1414
|
+
log.warning("保力中断:状态帧 err=%d"
|
|
1415
|
+
"(1=使能中;0=被禁用;其他=故障)", st.error_code)
|
|
1416
|
+
return HoldResult(ok=False, cycles=cycles, state=st)
|
|
1417
|
+
pos = st.position_rad
|
|
1418
|
+
|
|
1419
|
+
if guarded:
|
|
1420
|
+
out_rad = closing_travel_excursion_rad(gcfg, pos)
|
|
1421
|
+
if out_rad > guard_rad:
|
|
1422
|
+
log.warning("保力中止:实测位置 %+.5f rad 越出闭合侧标定限位"
|
|
1423
|
+
" %+.5f rad 共 %.5f rad(> %.5f)—— 判定闭合侧"
|
|
1424
|
+
"没有东西拦着它,撤力。保力 %.1f N 到此为止。",
|
|
1425
|
+
pos, gcfg.pos_closed_rad, out_rad, guard_rad,
|
|
1426
|
+
force_n)
|
|
1427
|
+
self._release(pos)
|
|
1428
|
+
return HoldResult(ok=False, cycles=cycles, state=st,
|
|
1429
|
+
guarded=True)
|
|
1430
|
+
|
|
1431
|
+
if progress is not None:
|
|
1432
|
+
progress(MoveProgress(
|
|
1433
|
+
phase="hold",
|
|
1434
|
+
i=cycles,
|
|
1435
|
+
total_steps=0,
|
|
1436
|
+
cmd_rad=pos,
|
|
1437
|
+
pos_rad=pos,
|
|
1438
|
+
delta_rad=0.0,
|
|
1439
|
+
win_delta_rad=None,
|
|
1440
|
+
torque_nm=st.torque_nm,
|
|
1441
|
+
temperature_coil=st.temperature_coil,
|
|
1442
|
+
))
|
|
1443
|
+
|
|
1444
|
+
return HoldResult(ok=True, cycles=cycles, state=st)
|