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/trajectory.py
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
"""Trajectory record and replay — teach the jaws a motion once, repeat it later.
|
|
2
|
+
|
|
3
|
+
Read this if you want to capture a motion off a real gripper and play it back:
|
|
4
|
+
the approach path that seats a part, the wiggle that shakes it loose, the squeeze
|
|
5
|
+
profile you found by hand. ``gripper.record(...)`` puts the motor into
|
|
6
|
+
zero-gravity, lets you push the jaws through the motion, and samples what they
|
|
7
|
+
did; ``gripper.play(trajectory)`` streams it back as MIT command frames.
|
|
8
|
+
|
|
9
|
+
Two halves, two classes:
|
|
10
|
+
|
|
11
|
+
* :class:`TrajectoryRecorder` — a background sampling loop.
|
|
12
|
+
* :class:`TrajectoryPlayer` — a background command loop.
|
|
13
|
+
|
|
14
|
+
and :class:`Trajectory` / :class:`TrajectorySample` — the captured data and its
|
|
15
|
+
on-disk format.
|
|
16
|
+
|
|
17
|
+
Why the recording stores ``openness`` and not radians
|
|
18
|
+
----------------------------------------------------
|
|
19
|
+
Each gripper has its own zero, direction and calibration (one unit opens toward
|
|
20
|
+
-1.42 rad, another toward +1.14 rad), so a raw angle is meaningless on a
|
|
21
|
+
different unit. A sample therefore carries the opening normalised by the
|
|
22
|
+
*recording* unit's travel — dimensionless, direction-free — and replay converts
|
|
23
|
+
it back with the *local* calibration. A trajectory taught on a normal-mount
|
|
24
|
+
gripper replays correctly on a reverse-mounted one. The recorded angle,
|
|
25
|
+
velocity and torque are kept as diagnostics only.
|
|
26
|
+
|
|
27
|
+
What replay does not reproduce
|
|
28
|
+
------------------------------
|
|
29
|
+
Replay commands **position**, not force. The opening is clamped to the local
|
|
30
|
+
calibrated travel, and the recorded torque is never fed forward, so a squeeze
|
|
31
|
+
that was recorded against an object replays as a position path that presses
|
|
32
|
+
with whatever ``kp`` yields — the grasp force you taught is *not* preserved.
|
|
33
|
+
For a repeatable grip force, replay the motion and then call
|
|
34
|
+
:meth:`~litegrip.LiteGrip.grasp` with an explicit ``force_n``.
|
|
35
|
+
|
|
36
|
+
Traps this module works around
|
|
37
|
+
------------------------------
|
|
38
|
+
* The DM motor self-locks a communication-loss fault roughly 100 ms after the
|
|
39
|
+
frames stop. A recorder that only *sampled* would therefore fault the motor
|
|
40
|
+
it is teaching, so zero-gravity recording streams a zero-torque frame every
|
|
41
|
+
cycle — the same rule as the teleop master loop.
|
|
42
|
+
* A blocking :meth:`~litegrip.LiteGrip.record` returns only once the capture is
|
|
43
|
+
complete; a capture that did not fill raises and reports how many samples
|
|
44
|
+
landed, rather than handing back a short recording as if it were whole.
|
|
45
|
+
* A file whose length does not match the sample count in its header is rejected,
|
|
46
|
+
not parsed into half a trajectory.
|
|
47
|
+
* A sample whose timestamp did not advance is not stored, and a clock that will
|
|
48
|
+
not advance aborts either loop — rather than filling a "successful" recording
|
|
49
|
+
with rows that all claim the same instant, or replaying forever.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import logging
|
|
55
|
+
import os
|
|
56
|
+
import struct
|
|
57
|
+
import threading
|
|
58
|
+
import time
|
|
59
|
+
from dataclasses import dataclass, field
|
|
60
|
+
from typing import Any, Callable, List, Optional, Tuple
|
|
61
|
+
|
|
62
|
+
from .exceptions import LiteGripError
|
|
63
|
+
from .teleop import openness_to_rad, rad_to_openness
|
|
64
|
+
|
|
65
|
+
log = logging.getLogger("litegrip.trajectory")
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"TrajectorySample",
|
|
69
|
+
"Trajectory",
|
|
70
|
+
"TrajectoryRecorder",
|
|
71
|
+
"TrajectoryPlayer",
|
|
72
|
+
"trajectory_dir",
|
|
73
|
+
"TrajectoryError",
|
|
74
|
+
"TrajectoryBusyError",
|
|
75
|
+
"TrajectoryNotActiveError",
|
|
76
|
+
"TrajectoryEmptyError",
|
|
77
|
+
"TrajectoryRecordingError",
|
|
78
|
+
"TrajectoryFormatError",
|
|
79
|
+
"DEFAULT_RATE_HZ",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
#: Default sampling rate for a hand-taught recording. Fast enough to keep the
|
|
83
|
+
#: shape of a motion a human made, slow enough that a CAN round trip per sample
|
|
84
|
+
#: always fits: 100 Hz means a 10 ms budget, and a status frame takes ~1 ms.
|
|
85
|
+
DEFAULT_RATE_HZ = 100.0
|
|
86
|
+
|
|
87
|
+
#: Two clock readings closer together than this count as "the clock did not
|
|
88
|
+
#: advance" — see :data:`_STALL_CYCLES`. One nanosecond, because the target
|
|
89
|
+
#: platform is Linux, where ``time.monotonic`` has nanosecond resolution: a
|
|
90
|
+
#: running loop always moves further than this between two cycles, and a
|
|
91
|
+
#: stopped clock never moves at all.
|
|
92
|
+
_CLOCK_EPS = 1e-9
|
|
93
|
+
|
|
94
|
+
#: How many consecutive non-advancing cycles abort a sampling or replay loop.
|
|
95
|
+
#: A real monotonic clock never does this — five cycles of Python and a CAN
|
|
96
|
+
#: round trip cannot land inside one nanosecond — so it only ever fires on a
|
|
97
|
+
#: clock that has stopped, where without the guard the loop would spin forever.
|
|
98
|
+
_STALL_CYCLES = 5
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── file format ───────────────────────────────────────────────────────────
|
|
102
|
+
#
|
|
103
|
+
# Little-endian throughout, matching the fixed-layout samples litearm-core
|
|
104
|
+
# writes. (litegrip's teleop wire frames are big-endian for byte compatibility
|
|
105
|
+
# with the litearm implementation; nothing outside this SDK reads this file, so
|
|
106
|
+
# there is no interop to preserve and the native order wins.)
|
|
107
|
+
#
|
|
108
|
+
# header magic 8s | version u16 | n_samples u32 | sample_hz f64 |
|
|
109
|
+
# created f64 | pos_closed_rad f64 | pos_open_rad f64 |
|
|
110
|
+
# rad_to_mm f64 | can_id u32 | mount 8s = 66 B
|
|
111
|
+
# sample t f64 | openness f64 | position_rad f64 | velocity_rad_s f64 |
|
|
112
|
+
# torque_nm f64 = 40 B
|
|
113
|
+
|
|
114
|
+
_MAGIC = b"LGRTRJ01"
|
|
115
|
+
_VERSION = 1
|
|
116
|
+
_HEADER = struct.Struct("<8sHI5dI8s")
|
|
117
|
+
_SAMPLE = struct.Struct("<5d")
|
|
118
|
+
_MOUNT_FIELD = 8
|
|
119
|
+
_MOUNTS = (None, "normal", "reverse")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def trajectory_dir() -> str:
|
|
123
|
+
"""Directory a trajectory is saved to when only a name is given.
|
|
124
|
+
|
|
125
|
+
``~/.litegrip/trajectories`` — next to the per-channel calibration files
|
|
126
|
+
(see :func:`~litegrip.default_calib_path`), because a trajectory belongs to
|
|
127
|
+
the machine, not to the working directory the program happened to start in.
|
|
128
|
+
``LITEGRIP_TRAJ_DIR`` overrides it, for tests and for a controller with a
|
|
129
|
+
read-only home.
|
|
130
|
+
"""
|
|
131
|
+
env = os.environ.get("LITEGRIP_TRAJ_DIR")
|
|
132
|
+
if env:
|
|
133
|
+
return env
|
|
134
|
+
return os.path.join(os.path.expanduser("~"), ".litegrip", "trajectories")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_path(path: str) -> str:
|
|
138
|
+
"""A bare name lands in :func:`trajectory_dir`; anything else is a path.
|
|
139
|
+
|
|
140
|
+
``"pick"`` → ``~/.litegrip/trajectories/pick.lgt``; ``"out/pick.lgt"`` and
|
|
141
|
+
``"/tmp/pick.lgt"`` are used as written.
|
|
142
|
+
"""
|
|
143
|
+
if os.sep in path or (os.altsep and os.altsep in path):
|
|
144
|
+
return path
|
|
145
|
+
name = path if path.endswith(".lgt") else path + ".lgt"
|
|
146
|
+
return os.path.join(trajectory_dir(), name)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── exceptions ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TrajectoryError(LiteGripError):
|
|
153
|
+
"""Base class for trajectory record/replay errors."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TrajectoryBusyError(TrajectoryError):
|
|
157
|
+
"""Raised when a recording or replay is started while one is already running."""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TrajectoryNotActiveError(TrajectoryError):
|
|
161
|
+
"""Raised when a stop is asked for but nothing is running."""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TrajectoryEmptyError(TrajectoryError):
|
|
165
|
+
"""Raised when a capture or a replay would deal with zero samples."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TrajectoryRecordingError(TrajectoryError):
|
|
169
|
+
"""Raised when the recording loop died, or a timed capture did not fill."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TrajectoryFormatError(TrajectoryError):
|
|
173
|
+
"""Raised when a byte stream is not a well-formed trajectory file."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── data ──────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class TrajectorySample:
|
|
181
|
+
"""One sample of a recorded motion.
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
t: Seconds since the recording started. A trimmed clock reading of the
|
|
185
|
+
*recording* machine — it means nothing after a reboot or on another
|
|
186
|
+
host, and replay only ever uses the differences between samples.
|
|
187
|
+
openness: Opening in ``[0, 1]`` (0 = closed, 1 = fully open). The
|
|
188
|
+
channel replay actually follows; see the module docstring.
|
|
189
|
+
position_rad: Raw motor angle at that instant — diagnostic. Only
|
|
190
|
+
meaningful against the calibration recorded alongside it.
|
|
191
|
+
velocity_rad_s: Motor velocity — diagnostic.
|
|
192
|
+
torque_nm: Motor torque — diagnostic. Also the closest thing to a
|
|
193
|
+
force record, but replay does not feed it forward.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
t: float
|
|
197
|
+
openness: float
|
|
198
|
+
position_rad: float
|
|
199
|
+
velocity_rad_s: float = 0.0
|
|
200
|
+
torque_nm: float = 0.0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class Trajectory:
|
|
205
|
+
"""A recorded motion: samples plus the calibration they were taken against.
|
|
206
|
+
|
|
207
|
+
The geometry fields describe the gripper that *recorded* the trajectory and
|
|
208
|
+
travel with it in the file, so a loaded trajectory still reports which unit
|
|
209
|
+
it came from and how its ``openness`` values were derived. Replay ignores
|
|
210
|
+
them in favour of the local gripper's own calibration.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
samples: List[TrajectorySample] = field(default_factory=list)
|
|
214
|
+
sample_hz: float = DEFAULT_RATE_HZ
|
|
215
|
+
created: float = field(default_factory=time.time)
|
|
216
|
+
can_id: int = 0x08
|
|
217
|
+
pos_closed_rad: float = 0.0
|
|
218
|
+
pos_open_rad: float = 0.0
|
|
219
|
+
rad_to_mm: float = 0.0
|
|
220
|
+
mount: Optional[str] = None
|
|
221
|
+
|
|
222
|
+
def __len__(self) -> int:
|
|
223
|
+
return len(self.samples)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def duration(self) -> float:
|
|
227
|
+
"""Seconds from the first sample to the last (0.0 for a short one).
|
|
228
|
+
|
|
229
|
+
The span, not the last sample's timestamp: a trajectory whose first
|
|
230
|
+
sample does not sit at ``t = 0`` still lasts only as long as its own
|
|
231
|
+
samples cover, and reporting the raw end stamp would make replay hold
|
|
232
|
+
its opening for the whole offset before starting to move.
|
|
233
|
+
"""
|
|
234
|
+
if not self.samples:
|
|
235
|
+
return 0.0
|
|
236
|
+
return float(self.samples[-1].t - self.samples[0].t)
|
|
237
|
+
|
|
238
|
+
def openness_at(self, t: float) -> float:
|
|
239
|
+
"""Opening at time *t*, linearly interpolated between samples.
|
|
240
|
+
|
|
241
|
+
Clamped at both ends: before the first sample and after the last one the
|
|
242
|
+
nearest sample's opening is returned. Signals that move are sampled far
|
|
243
|
+
faster than they move, so linear interpolation between neighbours is
|
|
244
|
+
well below the mechanical resolution — a smoother curve would be
|
|
245
|
+
inventing detail the recording does not contain.
|
|
246
|
+
"""
|
|
247
|
+
samples = self.samples
|
|
248
|
+
if not samples:
|
|
249
|
+
raise TrajectoryEmptyError("轨迹没有采样点")
|
|
250
|
+
if t <= samples[0].t:
|
|
251
|
+
return samples[0].openness
|
|
252
|
+
if t >= samples[-1].t:
|
|
253
|
+
return samples[-1].openness
|
|
254
|
+
lo, hi = 0, len(samples) - 1
|
|
255
|
+
while hi - lo > 1: # bisect on t
|
|
256
|
+
mid = (lo + hi) // 2
|
|
257
|
+
if samples[mid].t <= t:
|
|
258
|
+
lo = mid
|
|
259
|
+
else:
|
|
260
|
+
hi = mid
|
|
261
|
+
a, b = samples[lo], samples[hi]
|
|
262
|
+
span = b.t - a.t
|
|
263
|
+
if span <= 0.0:
|
|
264
|
+
return b.openness
|
|
265
|
+
return a.openness + (t - a.t) / span * (b.openness - a.openness)
|
|
266
|
+
|
|
267
|
+
# ── serialisation ─────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
def to_bytes(self) -> bytes:
|
|
270
|
+
"""Serialise to the binary format described in this module's header.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
TrajectoryError: The mount name does not fit its field.
|
|
274
|
+
"""
|
|
275
|
+
mount = (self.mount or "").encode("ascii")
|
|
276
|
+
if len(mount) > _MOUNT_FIELD:
|
|
277
|
+
raise TrajectoryError(
|
|
278
|
+
f"mount 名 {self.mount!r} 超过 {_MOUNT_FIELD} 字节")
|
|
279
|
+
parts = [_HEADER.pack(
|
|
280
|
+
_MAGIC, _VERSION, len(self.samples), float(self.sample_hz),
|
|
281
|
+
float(self.created), float(self.pos_closed_rad),
|
|
282
|
+
float(self.pos_open_rad), float(self.rad_to_mm), int(self.can_id),
|
|
283
|
+
mount.ljust(_MOUNT_FIELD, b"\x00"))]
|
|
284
|
+
for s in self.samples:
|
|
285
|
+
parts.append(_SAMPLE.pack(
|
|
286
|
+
float(s.t), float(s.openness), float(s.position_rad),
|
|
287
|
+
float(s.velocity_rad_s), float(s.torque_nm)))
|
|
288
|
+
return b"".join(parts)
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def from_bytes(cls, blob: bytes) -> "Trajectory":
|
|
292
|
+
"""Parse a trajectory file, or say precisely why it is not one.
|
|
293
|
+
|
|
294
|
+
Every check here exists to stop a corrupt file from becoming a
|
|
295
|
+
plausible-looking motion: the header's sample count has to match the
|
|
296
|
+
payload exactly (a truncated download, a half-written file and a file
|
|
297
|
+
with another file's tail appended are all caught by the same rule), and
|
|
298
|
+
the samples have to be finite, in range and ordered in time.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
TrajectoryFormatError: Magic, version, length, header fields or
|
|
302
|
+
sample values are not valid.
|
|
303
|
+
"""
|
|
304
|
+
blob = bytes(blob)
|
|
305
|
+
if len(blob) < _HEADER.size:
|
|
306
|
+
raise TrajectoryFormatError(
|
|
307
|
+
f"文件只有 {len(blob)}B, 连 {_HEADER.size}B 的头都不够")
|
|
308
|
+
(magic, version, n, hz, created, pos_closed, pos_open, rad_to_mm,
|
|
309
|
+
can_id, mount_raw) = _HEADER.unpack_from(blob, 0)
|
|
310
|
+
if magic != _MAGIC:
|
|
311
|
+
raise TrajectoryFormatError(
|
|
312
|
+
f"magic 不对: {magic!r} (期望 {_MAGIC!r}) —— 不是轨迹文件")
|
|
313
|
+
if version != _VERSION:
|
|
314
|
+
raise TrajectoryFormatError(
|
|
315
|
+
f"格式版本 {version} 不是本 SDK 能读的版本 {_VERSION}")
|
|
316
|
+
want = _HEADER.size + n * _SAMPLE.size
|
|
317
|
+
if len(blob) != want:
|
|
318
|
+
raise TrajectoryFormatError(
|
|
319
|
+
f"文件长度 {len(blob)}B 与头部声明的 {n} 拍 ({want}B) 不符 "
|
|
320
|
+
f"(差 {len(blob) - want:+d}B) —— 文件被截断或尾部多了数据, "
|
|
321
|
+
f"拒绝解析出半截轨迹")
|
|
322
|
+
if not (hz > 0.0):
|
|
323
|
+
raise TrajectoryFormatError(f"采样率非法: {hz}")
|
|
324
|
+
if not (rad_to_mm > 0.0):
|
|
325
|
+
raise TrajectoryFormatError(f"rad_to_mm 非法: {rad_to_mm}")
|
|
326
|
+
mount = mount_raw.split(b"\x00", 1)[0].decode("ascii", "replace")
|
|
327
|
+
if mount not in ("", "normal", "reverse"):
|
|
328
|
+
raise TrajectoryFormatError(f"装法名非法: {mount!r}")
|
|
329
|
+
if not (abs(pos_open - pos_closed) * rad_to_mm > 0.0):
|
|
330
|
+
raise TrajectoryFormatError(
|
|
331
|
+
f"行程为零 (closed={pos_closed}, open={pos_open})")
|
|
332
|
+
|
|
333
|
+
samples: List[TrajectorySample] = []
|
|
334
|
+
last_t = float("-inf")
|
|
335
|
+
for i in range(n):
|
|
336
|
+
values = _SAMPLE.unpack_from(blob, _HEADER.size + i * _SAMPLE.size)
|
|
337
|
+
t, openness, position_rad, velocity, torque = values
|
|
338
|
+
for name, value in zip(
|
|
339
|
+
("t", "openness", "position_rad", "velocity_rad_s",
|
|
340
|
+
"torque_nm"), values):
|
|
341
|
+
if value != value or value in (float("inf"), float("-inf")):
|
|
342
|
+
raise TrajectoryFormatError(
|
|
343
|
+
f"第 {i} 拍的 {name} 不是有限数: {value}")
|
|
344
|
+
if not 0.0 <= openness <= 1.0:
|
|
345
|
+
raise TrajectoryFormatError(
|
|
346
|
+
f"第 {i} 拍的 openness={openness} 不在 [0, 1] 内")
|
|
347
|
+
if t < last_t:
|
|
348
|
+
raise TrajectoryFormatError(
|
|
349
|
+
f"第 {i} 拍的 t={t} 比上一拍 {last_t} 还早 —— 时间未单调")
|
|
350
|
+
last_t = t
|
|
351
|
+
samples.append(TrajectorySample(
|
|
352
|
+
t=t, openness=openness, position_rad=position_rad,
|
|
353
|
+
velocity_rad_s=velocity, torque_nm=torque))
|
|
354
|
+
|
|
355
|
+
return cls(samples=samples, sample_hz=hz, created=created, can_id=can_id,
|
|
356
|
+
pos_closed_rad=pos_closed, pos_open_rad=pos_open,
|
|
357
|
+
rad_to_mm=rad_to_mm, mount=mount or None)
|
|
358
|
+
|
|
359
|
+
def save(self, path: str) -> str:
|
|
360
|
+
"""Write to *path*, creating the parent directory. Returns the path.
|
|
361
|
+
|
|
362
|
+
A bare name goes to :func:`trajectory_dir` (see :func:`resolve_path`).
|
|
363
|
+
"""
|
|
364
|
+
target = resolve_path(path)
|
|
365
|
+
parent = os.path.dirname(target)
|
|
366
|
+
if parent:
|
|
367
|
+
os.makedirs(parent, exist_ok=True)
|
|
368
|
+
with open(target, "wb") as f:
|
|
369
|
+
f.write(self.to_bytes())
|
|
370
|
+
log.info("trajectory saved: %s (%d samples)", target, len(self.samples))
|
|
371
|
+
return target
|
|
372
|
+
|
|
373
|
+
@classmethod
|
|
374
|
+
def load(cls, path: str) -> "Trajectory":
|
|
375
|
+
"""Read a trajectory written by :meth:`save`."""
|
|
376
|
+
with open(resolve_path(path), "rb") as f:
|
|
377
|
+
return cls.from_bytes(f.read())
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ── loop pacing ───────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class _Pacer:
|
|
384
|
+
"""Frame pacing and loop-rate measurement, shared by both loops.
|
|
385
|
+
|
|
386
|
+
``rest()`` sleeps whatever is left of a cycle's budget, so the loop holds
|
|
387
|
+
its rate without drifting when a cycle overruns. ``loop_hz`` is the
|
|
388
|
+
measured rate over the last second — the number to look at when a recording
|
|
389
|
+
sounds wrong, because a loop that cannot keep up drops samples rather than
|
|
390
|
+
stretching time.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
def __init__(self, dt: float, sleep_fn: Callable[[float], None],
|
|
394
|
+
monotonic_fn: Callable[[], float]) -> None:
|
|
395
|
+
self._dt = dt
|
|
396
|
+
self._sleep_fn = sleep_fn
|
|
397
|
+
self._monotonic_fn = monotonic_fn
|
|
398
|
+
self._loops = 0
|
|
399
|
+
self._hz_t0 = 0.0
|
|
400
|
+
self._hz_n0 = 0
|
|
401
|
+
self.loop_hz = 0.0
|
|
402
|
+
|
|
403
|
+
def rest(self, t0: float) -> None:
|
|
404
|
+
self._loops += 1
|
|
405
|
+
now = self._monotonic_fn()
|
|
406
|
+
if self._hz_t0 == 0.0:
|
|
407
|
+
self._hz_t0 = now
|
|
408
|
+
self._hz_n0 = self._loops
|
|
409
|
+
elif now - self._hz_t0 >= 1.0:
|
|
410
|
+
self.loop_hz = (self._loops - self._hz_n0) / (now - self._hz_t0)
|
|
411
|
+
self._hz_t0 = now
|
|
412
|
+
self._hz_n0 = self._loops
|
|
413
|
+
rest = self._dt - (self._monotonic_fn() - t0)
|
|
414
|
+
if rest > 0.0:
|
|
415
|
+
self._sleep_fn(rest)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _resolve_seams(
|
|
419
|
+
gripper: Any,
|
|
420
|
+
sleep_fn: Optional[Callable[[float], None]],
|
|
421
|
+
monotonic_fn: Optional[Callable[[], float]],
|
|
422
|
+
) -> Tuple[Callable[[float], None], Callable[[], float]]:
|
|
423
|
+
"""Fill in the timing seams from the gripper's :class:`MotionConfig`.
|
|
424
|
+
|
|
425
|
+
The motion engine already takes ``sleep_fn`` / ``monotonic_fn`` from there,
|
|
426
|
+
and the fixture every test builds its gripper with stubs them. Defaulting
|
|
427
|
+
to the same place is what lets a test drive a whole recording without
|
|
428
|
+
stubbing this module — and letting a caller pass them explicitly is what
|
|
429
|
+
keeps the classes usable on their own.
|
|
430
|
+
"""
|
|
431
|
+
cfg = getattr(gripper, "motion_config", None)
|
|
432
|
+
if sleep_fn is None:
|
|
433
|
+
sleep_fn = getattr(cfg, "sleep_fn", None) or time.sleep
|
|
434
|
+
if monotonic_fn is None:
|
|
435
|
+
monotonic_fn = getattr(cfg, "monotonic_fn", None) or time.monotonic
|
|
436
|
+
return sleep_fn, monotonic_fn
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ── recording ─────────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class TrajectoryRecorder:
|
|
443
|
+
"""Samples the gripper's state into a :class:`Trajectory`, on a thread.
|
|
444
|
+
|
|
445
|
+
With ``zero_gravity=True`` the loop streams ``q=0, kp=0, kd=0`` every cycle,
|
|
446
|
+
which leaves the motor energised but torque-free so the jaws can be pushed
|
|
447
|
+
by hand — that is the hand-teaching mode, and the zero-torque frame is
|
|
448
|
+
mandatory, not a convenience (see the module docstring). With
|
|
449
|
+
``zero_gravity=False`` the loop only *reads*, so the caller is free to drive
|
|
450
|
+
the gripper from another thread while it records: that is how a
|
|
451
|
+
programmatic ``grasp()`` or a move sequence gets captured.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
gripper: The ``LiteGrip`` to sample.
|
|
455
|
+
rate_hz: Samples per second. ``DEFAULT_RATE_HZ`` (100) unless the
|
|
456
|
+
motion is fast.
|
|
457
|
+
zero_gravity: Stream zero-torque frames so the jaws can be hand-driven.
|
|
458
|
+
max_samples: Stop by itself after this many samples. ``None`` records
|
|
459
|
+
until :meth:`stop`. Bounded recordings are what make a stuck clock
|
|
460
|
+
visible as a timeout instead of a running process.
|
|
461
|
+
sleep_fn, monotonic_fn: Timing seams for tests, matching
|
|
462
|
+
:class:`~litegrip.MotionConfig`'s names.
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
def __init__(
|
|
466
|
+
self,
|
|
467
|
+
gripper: Any,
|
|
468
|
+
rate_hz: float = DEFAULT_RATE_HZ,
|
|
469
|
+
zero_gravity: bool = True,
|
|
470
|
+
max_samples: Optional[int] = None,
|
|
471
|
+
sleep_fn: Optional[Callable[[float], None]] = None,
|
|
472
|
+
monotonic_fn: Optional[Callable[[], float]] = None,
|
|
473
|
+
) -> None:
|
|
474
|
+
if rate_hz <= 0.0:
|
|
475
|
+
raise ValueError(f"rate_hz must be > 0, got {rate_hz!r}")
|
|
476
|
+
self._g = gripper
|
|
477
|
+
self._rate_hz = float(rate_hz)
|
|
478
|
+
self._zero_gravity = bool(zero_gravity)
|
|
479
|
+
self._max_samples = None if max_samples is None else int(max_samples)
|
|
480
|
+
self._sleep_fn, self._monotonic_fn = _resolve_seams(
|
|
481
|
+
gripper, sleep_fn, monotonic_fn)
|
|
482
|
+
self._pacer = _Pacer(1.0 / self._rate_hz, self._sleep_fn,
|
|
483
|
+
self._monotonic_fn)
|
|
484
|
+
|
|
485
|
+
# Appended by the loop thread, read by the caller. No lock, for the
|
|
486
|
+
# same reason teleop's frame counter has none: list append and len are
|
|
487
|
+
# atomic under the GIL, and a torn sample is impossible once appended.
|
|
488
|
+
self._samples: List[TrajectorySample] = []
|
|
489
|
+
self._thread: Optional[threading.Thread] = None
|
|
490
|
+
self._running = False
|
|
491
|
+
self._error: Optional[BaseException] = None
|
|
492
|
+
self._t0 = 0.0
|
|
493
|
+
|
|
494
|
+
# ── lifecycle ─────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def is_recording(self) -> bool:
|
|
498
|
+
return self._running
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def sample_count(self) -> int:
|
|
502
|
+
return len(self._samples)
|
|
503
|
+
|
|
504
|
+
def start(self) -> None:
|
|
505
|
+
"""Start sampling. Raises :class:`TrajectoryBusyError` if already on."""
|
|
506
|
+
if self._running:
|
|
507
|
+
raise TrajectoryBusyError("trajectory recording is already running")
|
|
508
|
+
self._samples = []
|
|
509
|
+
self._error = None
|
|
510
|
+
self._pacer.loop_hz = 0.0
|
|
511
|
+
# Sample 0 is taken here, before the thread exists, with t = 0.0: the
|
|
512
|
+
# recording then starts at a commanded instant instead of whenever the
|
|
513
|
+
# scheduler happened to first run the loop.
|
|
514
|
+
self._t0 = self._monotonic_fn()
|
|
515
|
+
self._samples.append(self._sample_now(0.0))
|
|
516
|
+
self._running = True
|
|
517
|
+
self._thread = threading.Thread(
|
|
518
|
+
target=self._run, name="litegrip-trajectory-record", daemon=True)
|
|
519
|
+
self._thread.start()
|
|
520
|
+
log.info("recording started: %.0fHz zero_gravity=%s max_samples=%s",
|
|
521
|
+
self._rate_hz, self._zero_gravity, self._max_samples)
|
|
522
|
+
|
|
523
|
+
def _run(self) -> None:
|
|
524
|
+
try:
|
|
525
|
+
self._loop()
|
|
526
|
+
except BaseException as e: # noqa: BLE001 — surfaces through result()
|
|
527
|
+
self._error = e
|
|
528
|
+
log.warning("recording loop stopped: %s", e)
|
|
529
|
+
finally:
|
|
530
|
+
self._running = False
|
|
531
|
+
|
|
532
|
+
def _loop(self) -> None:
|
|
533
|
+
stall = 0
|
|
534
|
+
last = self._t0
|
|
535
|
+
while self._running:
|
|
536
|
+
t0 = self._monotonic_fn()
|
|
537
|
+
# A cycle whose clock did not move produced no sample: two rows with
|
|
538
|
+
# the same timestamp are not two measurements, and appending one
|
|
539
|
+
# anyway is how a stopped clock becomes a "successful" recording of
|
|
540
|
+
# zero length. So the clock has to move before anything is stored —
|
|
541
|
+
# and a clock that will not move is an error, not a slow capture.
|
|
542
|
+
if t0 - last <= _CLOCK_EPS:
|
|
543
|
+
stall += 1
|
|
544
|
+
if stall >= _STALL_CYCLES:
|
|
545
|
+
raise TrajectoryRecordingError(
|
|
546
|
+
f"采样时钟连续 {stall} 拍没有前进 (t={t0}) —— "
|
|
547
|
+
f"拒绝在停住的时钟上无限追加采样")
|
|
548
|
+
last = t0
|
|
549
|
+
self._pacer.rest(t0)
|
|
550
|
+
continue
|
|
551
|
+
stall = 0
|
|
552
|
+
last = t0
|
|
553
|
+
|
|
554
|
+
# Checked before storing, not after: start() already stored the
|
|
555
|
+
# reference sample, so a check after the append would return
|
|
556
|
+
# max_samples + 1 rows — and for a cap the reference sample alone
|
|
557
|
+
# already meets, it would depend on which thread got there first.
|
|
558
|
+
if (self._max_samples is not None
|
|
559
|
+
and len(self._samples) >= self._max_samples):
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
if self._zero_gravity:
|
|
563
|
+
self._g.send_mit_frame(q=0.0, kp=0.0, kd=0.0)
|
|
564
|
+
self._samples.append(self._sample_now(t0 - self._t0))
|
|
565
|
+
self._pacer.rest(t0)
|
|
566
|
+
|
|
567
|
+
def _sample_now(self, t: float) -> TrajectorySample:
|
|
568
|
+
state = self._g.get_state(wait=False)
|
|
569
|
+
return TrajectorySample(
|
|
570
|
+
t=float(t),
|
|
571
|
+
openness=rad_to_openness(state.position_rad, self._g.config),
|
|
572
|
+
position_rad=float(state.position_rad),
|
|
573
|
+
velocity_rad_s=float(state.velocity_rad_s),
|
|
574
|
+
torque_nm=float(state.torque_nm),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def wait_for(self, n_samples: int, timeout: float,
|
|
578
|
+
poll: float = 0.005) -> int:
|
|
579
|
+
"""Block until *n_samples* have been captured; return the count.
|
|
580
|
+
|
|
581
|
+
Polls the real clock, not the loop's ``monotonic_fn`` seam, so a test
|
|
582
|
+
that stubs the loop's clock still gets a bounded wait. A loop that died
|
|
583
|
+
raises its own error here immediately rather than after the timeout.
|
|
584
|
+
|
|
585
|
+
Raises:
|
|
586
|
+
TrajectoryRecordingError: The loop died, or *timeout* elapsed with
|
|
587
|
+
fewer samples — the message names the count, so a short capture
|
|
588
|
+
is visible rather than silently accepted.
|
|
589
|
+
"""
|
|
590
|
+
n = int(n_samples)
|
|
591
|
+
deadline = time.monotonic() + float(timeout)
|
|
592
|
+
while True:
|
|
593
|
+
if self._error is not None:
|
|
594
|
+
raise self._recording_error(len(self._samples))
|
|
595
|
+
got = len(self._samples)
|
|
596
|
+
if got >= n:
|
|
597
|
+
return got
|
|
598
|
+
if not self._running:
|
|
599
|
+
raise TrajectoryRecordingError(
|
|
600
|
+
f"录制提前结束: 只录到 {got}/{n} 拍, 循环已停止")
|
|
601
|
+
if time.monotonic() >= deadline:
|
|
602
|
+
raise TrajectoryRecordingError(
|
|
603
|
+
f"录制未在 {float(timeout):.1f}s 内录满 {n} 拍 "
|
|
604
|
+
f"(只录到 {got} 拍) —— 可调大 duration_s 或调小 rate_hz")
|
|
605
|
+
time.sleep(poll)
|
|
606
|
+
|
|
607
|
+
def stop(self, timeout: float = 2.0) -> None:
|
|
608
|
+
"""Stop the loop and leave the gripper holding its position.
|
|
609
|
+
|
|
610
|
+
The hold happens even when the loop died on a CAN error: a failed
|
|
611
|
+
recording must not leave the jaws slack.
|
|
612
|
+
"""
|
|
613
|
+
self._running = False
|
|
614
|
+
thread = self._thread
|
|
615
|
+
if thread is not None and thread.is_alive():
|
|
616
|
+
thread.join(timeout=timeout)
|
|
617
|
+
self._thread = None
|
|
618
|
+
if self._zero_gravity:
|
|
619
|
+
try:
|
|
620
|
+
self._g.exit_zero_gravity()
|
|
621
|
+
except Exception as e: # noqa: BLE001
|
|
622
|
+
log.debug("exit_zero_gravity on stop failed: %s", e)
|
|
623
|
+
log.info("recording stopped: %d samples", len(self._samples))
|
|
624
|
+
|
|
625
|
+
# ── results ───────────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
def _recording_error(self, got: int) -> TrajectoryRecordingError:
|
|
628
|
+
"""Wrap whatever killed the loop, without double-wrapping our own error."""
|
|
629
|
+
if isinstance(self._error, TrajectoryRecordingError):
|
|
630
|
+
return self._error
|
|
631
|
+
return TrajectoryRecordingError(
|
|
632
|
+
f"录制失败, 已录到的 {got} 拍不予返回: {self._error}")
|
|
633
|
+
|
|
634
|
+
def result(self, allow_empty: bool = False) -> Trajectory:
|
|
635
|
+
"""The captured trajectory.
|
|
636
|
+
|
|
637
|
+
Refuses to hand back a capture that did not work: a dead loop raises
|
|
638
|
+
:class:`TrajectoryRecordingError` (carrying the original error and the
|
|
639
|
+
sample count) rather than returning the partial capture as if it were a
|
|
640
|
+
whole recording.
|
|
641
|
+
|
|
642
|
+
:class:`TrajectoryEmptyError` is for the one way to get nothing at all —
|
|
643
|
+
:meth:`result` on a recorder that was never started. A started capture
|
|
644
|
+
always holds at least the reference sample :meth:`start` takes.
|
|
645
|
+
|
|
646
|
+
Raises:
|
|
647
|
+
TrajectoryRecordingError: The sampling loop died.
|
|
648
|
+
TrajectoryEmptyError: Nothing was ever captured.
|
|
649
|
+
"""
|
|
650
|
+
if self._error is not None:
|
|
651
|
+
raise self._recording_error(len(self._samples))
|
|
652
|
+
if not self._samples and not allow_empty:
|
|
653
|
+
raise TrajectoryEmptyError(
|
|
654
|
+
"录制没有采到任何样本 (0 拍) —— 起停之间没有留出采样时间")
|
|
655
|
+
cfg = self._g.config
|
|
656
|
+
return Trajectory(
|
|
657
|
+
samples=list(self._samples),
|
|
658
|
+
sample_hz=self._rate_hz,
|
|
659
|
+
created=time.time(),
|
|
660
|
+
can_id=int(self._g.can_id),
|
|
661
|
+
pos_closed_rad=float(cfg.pos_closed_rad),
|
|
662
|
+
pos_open_rad=float(cfg.pos_open_rad),
|
|
663
|
+
rad_to_mm=float(cfg.rad_to_mm),
|
|
664
|
+
mount=cfg.mount,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
def status(self) -> dict:
|
|
668
|
+
"""A snapshot of the recording session, for logging and diagnostics."""
|
|
669
|
+
return {
|
|
670
|
+
"active": self._running,
|
|
671
|
+
"kind": "record",
|
|
672
|
+
"samples": len(self._samples),
|
|
673
|
+
"rate_hz": round(self._rate_hz, 1),
|
|
674
|
+
"zero_gravity": self._zero_gravity,
|
|
675
|
+
"loop_hz": round(self._pacer.loop_hz, 1),
|
|
676
|
+
"error": None if self._error is None else str(self._error),
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ── replay ────────────────────────────────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
class TrajectoryPlayer:
|
|
684
|
+
"""Streams a :class:`Trajectory` back to the gripper, on a thread.
|
|
685
|
+
|
|
686
|
+
The target at each cycle is interpolated from the trajectory by **wall
|
|
687
|
+
clock**: ``u = (now - t0) * speed``, then ``trajectory.openness_at(u)``.
|
|
688
|
+
Advancing an index once per cycle instead would tie playback speed to the
|
|
689
|
+
loop rate and accumulate drift, so a cycle that overruns would make every
|
|
690
|
+
later sample late and a 2 s recording would take longer and longer to play.
|
|
691
|
+
Here a slow cycle skips ahead, and the motion stays the length it was
|
|
692
|
+
taught.
|
|
693
|
+
|
|
694
|
+
The commanded angle is ``openness_to_rad(openness, local_config)``, so the
|
|
695
|
+
trajectory plays on a gripper with a different mount or calibration. Only
|
|
696
|
+
position is commanded: the recorded velocity and torque are never sent as
|
|
697
|
+
``dq``/``tau`` feed-forward, because both are tied to the sign convention of
|
|
698
|
+
the unit that recorded them.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
gripper: The ``LiteGrip`` to drive.
|
|
702
|
+
trajectory: What to play. Rejected up front if it has no samples.
|
|
703
|
+
speed: Multiplier on the recorded timing. ``0.5`` is half speed.
|
|
704
|
+
kp, kd: Gains for the replay frames. ``None`` uses the gripper's
|
|
705
|
+
configured gains.
|
|
706
|
+
loop: Restart from the beginning instead of stopping at the end. A
|
|
707
|
+
trajectory with a single sample is a pose with no length to
|
|
708
|
+
restart, so looping it keeps holding that opening.
|
|
709
|
+
align: Move to the trajectory's first opening (one ``goto_rad``) before
|
|
710
|
+
following. Without it the first frame steps to sample 0 from
|
|
711
|
+
wherever the jaws happen to be, which is a torque spike.
|
|
712
|
+
rate_hz: Frame rate. ``None`` uses
|
|
713
|
+
:attr:`~litegrip.MotionConfig.frame_interval` (200 Hz by default).
|
|
714
|
+
sleep_fn, monotonic_fn: Timing seams for tests.
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
def __init__(
|
|
718
|
+
self,
|
|
719
|
+
gripper: Any,
|
|
720
|
+
trajectory: Trajectory,
|
|
721
|
+
speed: float = 1.0,
|
|
722
|
+
kp: Optional[float] = None,
|
|
723
|
+
kd: Optional[float] = None,
|
|
724
|
+
loop: bool = False,
|
|
725
|
+
align: bool = True,
|
|
726
|
+
rate_hz: Optional[float] = None,
|
|
727
|
+
sleep_fn: Optional[Callable[[float], None]] = None,
|
|
728
|
+
monotonic_fn: Optional[Callable[[], float]] = None,
|
|
729
|
+
) -> None:
|
|
730
|
+
if speed <= 0.0:
|
|
731
|
+
raise ValueError(f"speed must be > 0, got {speed!r}")
|
|
732
|
+
if len(trajectory) == 0:
|
|
733
|
+
raise TrajectoryEmptyError(
|
|
734
|
+
"轨迹里没有任何采样点, 没有可回放的动作")
|
|
735
|
+
self._g = gripper
|
|
736
|
+
self._traj = trajectory
|
|
737
|
+
self._speed = float(speed)
|
|
738
|
+
self._kp = float(kp) if kp is not None else float(gripper.config.kp)
|
|
739
|
+
self._kd = float(kd) if kd is not None else float(gripper.config.kd)
|
|
740
|
+
self._loop = bool(loop)
|
|
741
|
+
self._align = bool(align)
|
|
742
|
+
# Where this trajectory's own clock starts. Zero for anything this
|
|
743
|
+
# SDK recorded; a hand-built or foreign one may not be, and
|
|
744
|
+
# openness_at() indexes by the absolute stamp, so the phase is kept
|
|
745
|
+
# separately from the elapsed time the pacing works in.
|
|
746
|
+
self._origin = float(trajectory.samples[0].t)
|
|
747
|
+
if rate_hz is None:
|
|
748
|
+
interval = float(gripper.motion_config.frame_interval)
|
|
749
|
+
rate_hz = 1.0 / interval if interval > 0.0 else 200.0
|
|
750
|
+
self._rate_hz = float(rate_hz)
|
|
751
|
+
self._dt = 1.0 / self._rate_hz
|
|
752
|
+
self._sleep_fn, self._monotonic_fn = _resolve_seams(
|
|
753
|
+
gripper, sleep_fn, monotonic_fn)
|
|
754
|
+
self._pacer = _Pacer(self._dt, self._sleep_fn, self._monotonic_fn)
|
|
755
|
+
|
|
756
|
+
self._thread: Optional[threading.Thread] = None
|
|
757
|
+
self._running = False
|
|
758
|
+
self._error: Optional[BaseException] = None
|
|
759
|
+
self._frames = 0
|
|
760
|
+
self._last_openness = trajectory.samples[0].openness
|
|
761
|
+
self._completed = False
|
|
762
|
+
|
|
763
|
+
# ── lifecycle ─────────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
@property
|
|
766
|
+
def is_playing(self) -> bool:
|
|
767
|
+
return self._running
|
|
768
|
+
|
|
769
|
+
@property
|
|
770
|
+
def is_finished(self) -> bool:
|
|
771
|
+
"""True once the trajectory has played through to its end."""
|
|
772
|
+
return self._completed
|
|
773
|
+
|
|
774
|
+
def start(self) -> None:
|
|
775
|
+
"""Start replaying. Raises :class:`TrajectoryBusyError` if already on."""
|
|
776
|
+
if self._running:
|
|
777
|
+
raise TrajectoryBusyError("trajectory replay is already running")
|
|
778
|
+
self._running = True
|
|
779
|
+
self._thread = threading.Thread(
|
|
780
|
+
target=self._run, name="litegrip-trajectory-play", daemon=True)
|
|
781
|
+
self._thread.start()
|
|
782
|
+
log.info("replay started: %d samples, %.2fs, speed=%.2f loop=%s",
|
|
783
|
+
len(self._traj), self._traj.duration, self._speed, self._loop)
|
|
784
|
+
|
|
785
|
+
def _run(self) -> None:
|
|
786
|
+
try:
|
|
787
|
+
if self._align:
|
|
788
|
+
self._align_to_start()
|
|
789
|
+
self._loop_frames()
|
|
790
|
+
except BaseException as e: # noqa: BLE001 — surfaces through status()
|
|
791
|
+
self._error = e
|
|
792
|
+
log.warning("replay loop stopped: %s", e)
|
|
793
|
+
finally:
|
|
794
|
+
self._running = False
|
|
795
|
+
|
|
796
|
+
def _align_to_start(self) -> None:
|
|
797
|
+
"""Walk to sample 0 at a bounded speed, so following starts in place.
|
|
798
|
+
|
|
799
|
+
This used to be one ``goto_rad(..., duration=1.0)``. At the time
|
|
800
|
+
``duration`` read like a ramp but was only a deadline: the CAN layer sent
|
|
801
|
+
``q = q_target`` from the first frame and held it, so that frame demanded
|
|
802
|
+
``kp`` times the whole error. That is the same full-torque step #22
|
|
803
|
+
reports for the teleop follower — at the shipped ``kp`` of 100 Nm/rad any
|
|
804
|
+
error past ~0.1 rad saturates the DM4310.
|
|
805
|
+
|
|
806
|
+
``goto_rad`` runs that bounded ramp itself now, but this loop stays: the
|
|
807
|
+
alignment has to be interruptible every frame (``self._running``) and has
|
|
808
|
+
to keep time on the replay's own pacer, neither of which a call into the
|
|
809
|
+
action engine can give it.
|
|
810
|
+
|
|
811
|
+
The target is walked in at ``motion_config.speed_mm_s`` with that same
|
|
812
|
+
speed fed forward as ``dq``, and each frame is capped to
|
|
813
|
+
``motion_config.max_lead_mm`` of lead. Torque is
|
|
814
|
+
``kp * (q_cmd - q_measured)``, so capping the lead caps the commanded
|
|
815
|
+
torque by construction — the rule ``open``/``close`` already travel
|
|
816
|
+
under.
|
|
817
|
+
"""
|
|
818
|
+
gcfg = self._g.config
|
|
819
|
+
mcfg = self._g.motion_config
|
|
820
|
+
target = openness_to_rad(self._traj.samples[0].openness, gcfg)
|
|
821
|
+
log.info("replay aligning to first sample: openness=%.3f -> %.3f rad",
|
|
822
|
+
self._traj.samples[0].openness, target)
|
|
823
|
+
try:
|
|
824
|
+
start = self._g.get_state(wait=False).position_rad
|
|
825
|
+
except Exception as e: # noqa: BLE001
|
|
826
|
+
log.warning("replay align state read failed: %s", e)
|
|
827
|
+
return
|
|
828
|
+
rad_to_mm = float(getattr(gcfg, "rad_to_mm", 0.0) or 0.0)
|
|
829
|
+
speed_mm_s = float(getattr(mcfg, "speed_mm_s", 0.0) or 0.0)
|
|
830
|
+
dist_rad = target - start
|
|
831
|
+
if rad_to_mm <= 0.0 or speed_mm_s <= 0.0 or abs(dist_rad) < 1e-9:
|
|
832
|
+
# Nothing to schedule against: the jaws are already at the target,
|
|
833
|
+
# or the config cannot give us a speed. One frame hands over.
|
|
834
|
+
self._send_rad(target)
|
|
835
|
+
return
|
|
836
|
+
speed_rad_s = speed_mm_s / rad_to_mm
|
|
837
|
+
sign = 1.0 if dist_rad >= 0.0 else -1.0
|
|
838
|
+
cap_rad = float(getattr(mcfg, "max_lead_mm", 0.0) or 0.0) / rad_to_mm
|
|
839
|
+
steps = max(1, int(round(abs(dist_rad) / speed_rad_s / self._dt)))
|
|
840
|
+
log.info("replay align: %+.4f -> %+.4f rad at %.1f mm/s "
|
|
841
|
+
"(%d frames, cap %.4f rad)",
|
|
842
|
+
start, target, speed_mm_s, steps, cap_rad)
|
|
843
|
+
for i in range(1, steps + 1):
|
|
844
|
+
if not self._running:
|
|
845
|
+
return
|
|
846
|
+
cycle_start = self._monotonic_fn()
|
|
847
|
+
q_sched = start + dist_rad * (i / steps)
|
|
848
|
+
pos = self._g.get_state(wait=False).position_rad
|
|
849
|
+
lead = q_sched - pos
|
|
850
|
+
if cap_rad > 0.0:
|
|
851
|
+
if lead > cap_rad:
|
|
852
|
+
q_sched = pos + cap_rad
|
|
853
|
+
elif lead < -cap_rad:
|
|
854
|
+
q_sched = pos - cap_rad
|
|
855
|
+
self._send_rad(q_sched, dq=sign * speed_rad_s)
|
|
856
|
+
self._pacer.rest(cycle_start)
|
|
857
|
+
|
|
858
|
+
def _loop_frames(self) -> None:
|
|
859
|
+
duration = self._traj.duration
|
|
860
|
+
t0 = self._monotonic_fn()
|
|
861
|
+
last = t0
|
|
862
|
+
stall = 0
|
|
863
|
+
while self._running:
|
|
864
|
+
cycle_start = self._monotonic_fn()
|
|
865
|
+
# The recorder's rule, for the same reason: trajectory time is
|
|
866
|
+
# read off this clock, so a clock that will not move means the
|
|
867
|
+
# replay can never reach its end — and a loop that keeps sending
|
|
868
|
+
# frames it cannot advance past is flooding the bus, not playing.
|
|
869
|
+
if cycle_start - last <= _CLOCK_EPS:
|
|
870
|
+
stall += 1
|
|
871
|
+
if stall >= _STALL_CYCLES:
|
|
872
|
+
raise TrajectoryError(
|
|
873
|
+
f"回放时钟连续 {stall} 拍没有前进 (t={cycle_start}) —— "
|
|
874
|
+
f"回放推不动, 拒绝空转刷帧")
|
|
875
|
+
else:
|
|
876
|
+
stall = 0
|
|
877
|
+
last = cycle_start
|
|
878
|
+
t0 = self._emit_cycle(cycle_start, t0, duration)
|
|
879
|
+
if t0 is None:
|
|
880
|
+
return
|
|
881
|
+
self._pacer.rest(cycle_start)
|
|
882
|
+
|
|
883
|
+
def _emit_cycle(self, now: float, t0: float,
|
|
884
|
+
duration: float) -> Optional[float]:
|
|
885
|
+
"""Emit one frame; return the (possibly rewound) epoch, or None to stop."""
|
|
886
|
+
if duration <= 0.0:
|
|
887
|
+
# A one-sample trajectory is a pose, not a path: there is no time
|
|
888
|
+
# to advance along. Looping it means holding that pose, which is
|
|
889
|
+
# the only reading that keeps `loop=True` meaning "keeps going
|
|
890
|
+
# until play_stop" — a pose is exactly what a hold is for.
|
|
891
|
+
if self._loop:
|
|
892
|
+
self._emit(self._traj.samples[0].openness)
|
|
893
|
+
return t0
|
|
894
|
+
self._emit(self._traj.samples[-1].openness)
|
|
895
|
+
self._completed = True
|
|
896
|
+
log.info("replay finished: %d frames", self._frames)
|
|
897
|
+
return None
|
|
898
|
+
|
|
899
|
+
# Elapsed seconds along the trajectory, from the wall clock — never an
|
|
900
|
+
# index stepped once per cycle, which would tie the speed to the loop
|
|
901
|
+
# rate and make a recorded 2 s path take longer every time.
|
|
902
|
+
elapsed = (now - t0) * self._speed
|
|
903
|
+
if elapsed >= duration:
|
|
904
|
+
if self._loop:
|
|
905
|
+
# Rewind by whole loops rather than resetting to `now`: a cycle
|
|
906
|
+
# that overran keeps its phase instead of shifting the loop.
|
|
907
|
+
elapsed %= duration
|
|
908
|
+
t0 = now - elapsed / self._speed
|
|
909
|
+
else:
|
|
910
|
+
self._emit(self._traj.samples[-1].openness)
|
|
911
|
+
self._completed = True
|
|
912
|
+
log.info("replay finished: %d frames", self._frames)
|
|
913
|
+
return None
|
|
914
|
+
self._emit(self._traj.openness_at(self._origin + elapsed))
|
|
915
|
+
return t0
|
|
916
|
+
|
|
917
|
+
def _emit(self, openness: float) -> None:
|
|
918
|
+
self._send_rad(openness_to_rad(openness, self._g.config))
|
|
919
|
+
self._last_openness = openness
|
|
920
|
+
|
|
921
|
+
def _send_rad(self, q: float, dq: float = 0.0) -> None:
|
|
922
|
+
"""Send one MIT frame at ``q`` (radians), counting it.
|
|
923
|
+
|
|
924
|
+
Shared by the replay loop (``dq = 0``) and the align ramp, which feeds
|
|
925
|
+
its travel speed forward as ``dq``. A failed send aborts: dropping
|
|
926
|
+
frames silently would leave the jaws somewhere the status does not show.
|
|
927
|
+
|
|
928
|
+
The abort keeps the type it always had — this is a replay that stopped,
|
|
929
|
+
not a transport error — but it now carries the send's own reason in the
|
|
930
|
+
message instead of asking "not connected or not enabled?" and leaving
|
|
931
|
+
the answer in a log the caller may never read.
|
|
932
|
+
"""
|
|
933
|
+
if not self._g.send_mit_frame(q=q, kp=self._kp, kd=self._kd, dq=dq):
|
|
934
|
+
error = getattr(self._g, "last_send_error", None)
|
|
935
|
+
reason = "原因未记录" if error is None else repr(error)
|
|
936
|
+
raise TrajectoryError(
|
|
937
|
+
f"MIT 帧下发失败({reason})—— 回放中止, 不静默丢帧") from error
|
|
938
|
+
self._frames += 1
|
|
939
|
+
|
|
940
|
+
def stop(self, timeout: float = 2.0) -> None:
|
|
941
|
+
"""Stop replaying and leave the gripper holding its last target.
|
|
942
|
+
|
|
943
|
+
One MIT frame is sent at the current position under the configured
|
|
944
|
+
gains. That holds the jaws for as long as frames keep arriving — the
|
|
945
|
+
motor self-locks a comm-loss fault about 100 ms after they stop — so
|
|
946
|
+
call the next action promptly, or keep the player running with
|
|
947
|
+
``loop=True`` if the hold has to last.
|
|
948
|
+
"""
|
|
949
|
+
self._running = False
|
|
950
|
+
thread = self._thread
|
|
951
|
+
if thread is not None and thread.is_alive():
|
|
952
|
+
thread.join(timeout=timeout)
|
|
953
|
+
self._thread = None
|
|
954
|
+
try:
|
|
955
|
+
self._g._hold_position()
|
|
956
|
+
except Exception as e: # noqa: BLE001
|
|
957
|
+
log.debug("hold on replay stop failed: %s", e)
|
|
958
|
+
log.info("replay stopped: %d frames", self._frames)
|
|
959
|
+
|
|
960
|
+
def wait(self, timeout: float) -> bool:
|
|
961
|
+
"""Block until the replay finishes. True if it did, False on timeout.
|
|
962
|
+
|
|
963
|
+
A wall-clock playback of a stalling clock would otherwise spin forever,
|
|
964
|
+
so the blocking wrapper gives it a deadline and this is how it asks.
|
|
965
|
+
"""
|
|
966
|
+
thread = self._thread
|
|
967
|
+
if thread is None:
|
|
968
|
+
return True
|
|
969
|
+
thread.join(timeout)
|
|
970
|
+
return not thread.is_alive()
|
|
971
|
+
|
|
972
|
+
def status(self) -> dict:
|
|
973
|
+
"""A snapshot of the replay session, for logging and diagnostics."""
|
|
974
|
+
return {
|
|
975
|
+
"active": self._running,
|
|
976
|
+
"kind": "play",
|
|
977
|
+
"samples": len(self._traj),
|
|
978
|
+
"frames": self._frames,
|
|
979
|
+
"speed": round(self._speed, 3),
|
|
980
|
+
"loop": self._loop,
|
|
981
|
+
"completed": self._completed,
|
|
982
|
+
"openness": round(self._last_openness, 4),
|
|
983
|
+
"loop_hz": round(self._pacer.loop_hz, 1),
|
|
984
|
+
"error": None if self._error is None else str(self._error),
|
|
985
|
+
}
|