BulletLab 0.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.
- bulletlab/__init__.py +48 -0
- bulletlab/core/__init__.py +10 -0
- bulletlab/core/simulation.py +377 -0
- bulletlab/core/world.py +173 -0
- bulletlab/logging/__init__.py +9 -0
- bulletlab/logging/csv_writer.py +54 -0
- bulletlab/logging/json_writer.py +70 -0
- bulletlab/logging/logger.py +258 -0
- bulletlab/plotting/__init__.py +9 -0
- bulletlab/plotting/live_plot.py +364 -0
- bulletlab/robot/__init__.py +12 -0
- bulletlab/robot/joint.py +402 -0
- bulletlab/robot/link.py +401 -0
- bulletlab/robot/robot.py +533 -0
- bulletlab/telemetry/__init__.py +10 -0
- bulletlab/telemetry/channel.py +124 -0
- bulletlab/telemetry/manager.py +227 -0
- bulletlab/ui/__init__.py +31 -0
- bulletlab/ui/app.py +543 -0
- bulletlab/ui/panels/__init__.py +16 -0
- bulletlab/ui/panels/console.py +189 -0
- bulletlab/ui/panels/explorer.py +144 -0
- bulletlab/ui/panels/plots.py +143 -0
- bulletlab/ui/panels/properties.py +265 -0
- bulletlab/ui/panels/telemetry.py +105 -0
- bulletlab/ui/widgets.py +348 -0
- bulletlab/utils/__init__.py +26 -0
- bulletlab/utils/math_utils.py +183 -0
- bulletlab/utils/timer.py +107 -0
- bulletlab/utils/urdf_utils.py +114 -0
- bulletlab-0.1.2.dist-info/METADATA +284 -0
- bulletlab-0.1.2.dist-info/RECORD +35 -0
- bulletlab-0.1.2.dist-info/WHEEL +5 -0
- bulletlab-0.1.2.dist-info/licenses/LICENSE +21 -0
- bulletlab-0.1.2.dist-info/top_level.txt +1 -0
bulletlab/robot/robot.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Robot – the primary interface to a simulated robot in BulletLab.
|
|
3
|
+
|
|
4
|
+
The Robot class loads a URDF or MJCF model into PyBullet and exposes all
|
|
5
|
+
its joints and links as named Python objects. You never need to work with
|
|
6
|
+
raw PyBullet body IDs or joint indices.
|
|
7
|
+
|
|
8
|
+
Example::
|
|
9
|
+
|
|
10
|
+
from bulletlab import Simulation, Robot
|
|
11
|
+
|
|
12
|
+
sim = Simulation().start()
|
|
13
|
+
robot = Robot.load("kuka_iiwa/model.urdf", sim=sim)
|
|
14
|
+
|
|
15
|
+
# Access joints and links by name
|
|
16
|
+
robot.joints["iiwa_joint_1"].set_position(1.0)
|
|
17
|
+
robot.links["iiwa_link_0"].mass = 5.0
|
|
18
|
+
|
|
19
|
+
# State inspection
|
|
20
|
+
print(robot.base_position)
|
|
21
|
+
print(robot.roll, robot.pitch, robot.yaw)
|
|
22
|
+
|
|
23
|
+
# RL-style interface
|
|
24
|
+
state = robot.get_state()
|
|
25
|
+
robot.apply_action(action)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import TYPE_CHECKING, Optional
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pybullet as p
|
|
36
|
+
|
|
37
|
+
from bulletlab.robot.joint import Joint, JointType
|
|
38
|
+
from bulletlab.robot.link import Link
|
|
39
|
+
from bulletlab.utils.math_utils import quaternion_to_euler
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from bulletlab.core.simulation import Simulation
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Robot:
|
|
46
|
+
"""A simulated robot loaded from a URDF or MJCF file.
|
|
47
|
+
|
|
48
|
+
Provides structured access to all joints and links by name, along with
|
|
49
|
+
base-link state properties, RL-compatible state/action interfaces,
|
|
50
|
+
and reset functionality.
|
|
51
|
+
|
|
52
|
+
Do not instantiate directly — use the :meth:`load` class method.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
body_id: PyBullet body ID.
|
|
56
|
+
sim: The parent :class:`~bulletlab.core.simulation.Simulation`.
|
|
57
|
+
name: Human-readable robot name.
|
|
58
|
+
initial_position: Initial base position.
|
|
59
|
+
initial_orientation: Initial base orientation (quaternion).
|
|
60
|
+
|
|
61
|
+
Example::
|
|
62
|
+
|
|
63
|
+
robot = Robot.load("car.urdf", sim=sim, position=(0, 0, 0.5))
|
|
64
|
+
robot.joints["steering"].set_position(0.3)
|
|
65
|
+
robot.links["chassis"].mass = 10.0
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
body_id: int,
|
|
71
|
+
sim: "Simulation",
|
|
72
|
+
name: str = "Robot",
|
|
73
|
+
initial_position: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
|
74
|
+
initial_orientation: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0),
|
|
75
|
+
) -> None:
|
|
76
|
+
self._body_id = body_id
|
|
77
|
+
self._sim = sim
|
|
78
|
+
self._name = name
|
|
79
|
+
self._initial_position = initial_position
|
|
80
|
+
self._initial_orientation = initial_orientation
|
|
81
|
+
self._joints: dict[str, Joint] = {}
|
|
82
|
+
self._links: dict[str, Link] = {}
|
|
83
|
+
self._joint_indices: list[int] = [] # controllable joint indices
|
|
84
|
+
|
|
85
|
+
self._discover_joints_and_links()
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Factory / classmethod
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def load(
|
|
93
|
+
cls,
|
|
94
|
+
path: str | Path,
|
|
95
|
+
sim: "Simulation",
|
|
96
|
+
position: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
|
97
|
+
orientation: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0),
|
|
98
|
+
name: str | None = None,
|
|
99
|
+
fixed_base: bool = False,
|
|
100
|
+
scale: float = 1.0,
|
|
101
|
+
flags: int = 0,
|
|
102
|
+
) -> "Robot":
|
|
103
|
+
"""Load a robot from a URDF or MJCF file.
|
|
104
|
+
|
|
105
|
+
Automatically discovers all joints and links and exposes them by name.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
path: Path to the URDF/MJCF file. Can be an absolute path or a
|
|
109
|
+
filename resolvable from the pybullet_data search path.
|
|
110
|
+
sim: The :class:`~bulletlab.core.simulation.Simulation` instance.
|
|
111
|
+
position: Initial base position ``(x, y, z)`` in meters.
|
|
112
|
+
orientation: Initial base orientation as a quaternion ``(x, y, z, w)``.
|
|
113
|
+
name: Human-readable robot name. Defaults to the filename stem.
|
|
114
|
+
fixed_base: If ``True``, the robot's base is fixed to the world.
|
|
115
|
+
scale: Global scale factor for the loaded model.
|
|
116
|
+
flags: Additional PyBullet load flags.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A new :class:`Robot` instance.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
FileNotFoundError: If the URDF/MJCF file cannot be found.
|
|
123
|
+
RuntimeError: If PyBullet fails to load the model.
|
|
124
|
+
|
|
125
|
+
Example::
|
|
126
|
+
|
|
127
|
+
robot = Robot.load("kuka_iiwa/model.urdf", sim=sim, position=(0, 0, 0))
|
|
128
|
+
"""
|
|
129
|
+
path_str = str(path)
|
|
130
|
+
robot_name = name or Path(path_str).stem
|
|
131
|
+
|
|
132
|
+
path_obj = Path(path_str)
|
|
133
|
+
ext = path_obj.suffix.lower()
|
|
134
|
+
|
|
135
|
+
if ext in (".urdf",):
|
|
136
|
+
body_id = p.loadURDF(
|
|
137
|
+
path_str,
|
|
138
|
+
basePosition=list(position),
|
|
139
|
+
baseOrientation=list(orientation),
|
|
140
|
+
useFixedBase=fixed_base,
|
|
141
|
+
globalScaling=scale,
|
|
142
|
+
flags=flags,
|
|
143
|
+
physicsClientId=sim.client_id,
|
|
144
|
+
)
|
|
145
|
+
elif ext in (".xml", ".mjcf"):
|
|
146
|
+
# MJCF: position/orientation not directly supported at load time
|
|
147
|
+
body_ids = p.loadMJCF(
|
|
148
|
+
path_str,
|
|
149
|
+
physicsClientId=sim.client_id,
|
|
150
|
+
)
|
|
151
|
+
body_id = body_ids[0] if isinstance(body_ids, (list, tuple)) else body_ids
|
|
152
|
+
else:
|
|
153
|
+
# Try URDF by default
|
|
154
|
+
body_id = p.loadURDF(
|
|
155
|
+
path_str,
|
|
156
|
+
basePosition=list(position),
|
|
157
|
+
baseOrientation=list(orientation),
|
|
158
|
+
useFixedBase=fixed_base,
|
|
159
|
+
globalScaling=scale,
|
|
160
|
+
flags=flags,
|
|
161
|
+
physicsClientId=sim.client_id,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
robot = cls(
|
|
165
|
+
body_id=body_id,
|
|
166
|
+
sim=sim,
|
|
167
|
+
name=robot_name,
|
|
168
|
+
initial_position=position,
|
|
169
|
+
initial_orientation=orientation,
|
|
170
|
+
)
|
|
171
|
+
sim.add_robot(robot)
|
|
172
|
+
return robot
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
# Discovery
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _discover_joints_and_links(self) -> None:
|
|
179
|
+
"""Scan PyBullet body and populate joints and links dictionaries."""
|
|
180
|
+
num_joints = p.getNumJoints(self._body_id, physicsClientId=self._sim.client_id)
|
|
181
|
+
|
|
182
|
+
# Base link (index -1)
|
|
183
|
+
base_name = p.getBodyInfo(self._body_id, physicsClientId=self._sim.client_id)
|
|
184
|
+
base_link_name = base_name[0].decode("utf-8") if isinstance(base_name[0], bytes) else str(base_name[0])
|
|
185
|
+
self._links[base_link_name] = Link(
|
|
186
|
+
name=base_link_name,
|
|
187
|
+
index=-1,
|
|
188
|
+
body_id=self._body_id,
|
|
189
|
+
sim=self._sim,
|
|
190
|
+
)
|
|
191
|
+
# Also expose as "base"
|
|
192
|
+
self._links["base"] = self._links[base_link_name]
|
|
193
|
+
|
|
194
|
+
for i in range(num_joints):
|
|
195
|
+
info = p.getJointInfo(self._body_id, i, physicsClientId=self._sim.client_id)
|
|
196
|
+
joint_name_raw = info[1]
|
|
197
|
+
link_name_raw = info[12]
|
|
198
|
+
|
|
199
|
+
joint_name = joint_name_raw.decode("utf-8") if isinstance(joint_name_raw, bytes) else str(joint_name_raw)
|
|
200
|
+
link_name = link_name_raw.decode("utf-8") if isinstance(link_name_raw, bytes) else str(link_name_raw)
|
|
201
|
+
joint_type = info[2]
|
|
202
|
+
|
|
203
|
+
# Build Joint object
|
|
204
|
+
joint = Joint(
|
|
205
|
+
name=joint_name,
|
|
206
|
+
index=i,
|
|
207
|
+
body_id=self._body_id,
|
|
208
|
+
sim=self._sim,
|
|
209
|
+
)
|
|
210
|
+
self._joints[joint_name] = joint
|
|
211
|
+
|
|
212
|
+
# Build Link object for this joint's child link
|
|
213
|
+
link = Link(
|
|
214
|
+
name=link_name,
|
|
215
|
+
index=i,
|
|
216
|
+
body_id=self._body_id,
|
|
217
|
+
sim=self._sim,
|
|
218
|
+
)
|
|
219
|
+
self._links[link_name] = link
|
|
220
|
+
|
|
221
|
+
# Track controllable joints (non-fixed)
|
|
222
|
+
if joint_type != p.JOINT_FIXED:
|
|
223
|
+
self._joint_indices.append(i)
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Joints and Links access
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def joints(self) -> dict[str, Joint]:
|
|
231
|
+
"""Dictionary of all joints indexed by name.
|
|
232
|
+
|
|
233
|
+
Example::
|
|
234
|
+
|
|
235
|
+
robot.joints["wheel_left"].velocity = 10
|
|
236
|
+
"""
|
|
237
|
+
return self._joints
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def links(self) -> dict[str, Link]:
|
|
241
|
+
"""Dictionary of all links indexed by name.
|
|
242
|
+
|
|
243
|
+
Example::
|
|
244
|
+
|
|
245
|
+
robot.links["chassis"].mass = 5.0
|
|
246
|
+
"""
|
|
247
|
+
return self._links
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def controllable_joints(self) -> list[Joint]:
|
|
251
|
+
"""List of all non-fixed joints (those that can be actuated)."""
|
|
252
|
+
return [j for j in self._joints.values() if not j.is_fixed]
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------------
|
|
255
|
+
# Base state
|
|
256
|
+
# ------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def base_position(self) -> tuple[float, float, float]:
|
|
260
|
+
"""World-frame base position ``(x, y, z)`` in meters.
|
|
261
|
+
|
|
262
|
+
Example::
|
|
263
|
+
|
|
264
|
+
x, y, z = robot.base_position
|
|
265
|
+
"""
|
|
266
|
+
pos, _ = p.getBasePositionAndOrientation(
|
|
267
|
+
self._body_id,
|
|
268
|
+
physicsClientId=self._sim.client_id,
|
|
269
|
+
)
|
|
270
|
+
return tuple(float(v) for v in pos) # type: ignore[return-value]
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def base_orientation(self) -> tuple[float, float, float, float]:
|
|
274
|
+
"""World-frame base orientation as quaternion ``(x, y, z, w)``.
|
|
275
|
+
|
|
276
|
+
Example::
|
|
277
|
+
|
|
278
|
+
q = robot.base_orientation
|
|
279
|
+
"""
|
|
280
|
+
_, orn = p.getBasePositionAndOrientation(
|
|
281
|
+
self._body_id,
|
|
282
|
+
physicsClientId=self._sim.client_id,
|
|
283
|
+
)
|
|
284
|
+
return tuple(float(v) for v in orn) # type: ignore[return-value]
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def base_velocity(self) -> tuple[float, float, float]:
|
|
288
|
+
"""World-frame linear velocity ``(vx, vy, vz)`` in m/s.
|
|
289
|
+
|
|
290
|
+
Example::
|
|
291
|
+
|
|
292
|
+
speed = robot.base_velocity[0]
|
|
293
|
+
"""
|
|
294
|
+
vel, _ = p.getBaseVelocity(
|
|
295
|
+
self._body_id,
|
|
296
|
+
physicsClientId=self._sim.client_id,
|
|
297
|
+
)
|
|
298
|
+
return tuple(float(v) for v in vel) # type: ignore[return-value]
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def base_angular_velocity(self) -> tuple[float, float, float]:
|
|
302
|
+
"""World-frame angular velocity ``(wx, wy, wz)`` in rad/s.
|
|
303
|
+
|
|
304
|
+
Example::
|
|
305
|
+
|
|
306
|
+
wx, wy, wz = robot.base_angular_velocity
|
|
307
|
+
"""
|
|
308
|
+
_, avel = p.getBaseVelocity(
|
|
309
|
+
self._body_id,
|
|
310
|
+
physicsClientId=self._sim.client_id,
|
|
311
|
+
)
|
|
312
|
+
return tuple(float(v) for v in avel) # type: ignore[return-value]
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def roll(self) -> float:
|
|
316
|
+
"""Base roll angle in radians (rotation around X axis).
|
|
317
|
+
|
|
318
|
+
Example::
|
|
319
|
+
|
|
320
|
+
print(f"Roll: {math.degrees(robot.roll):.1f}°")
|
|
321
|
+
"""
|
|
322
|
+
return quaternion_to_euler(self.base_orientation)[0]
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def pitch(self) -> float:
|
|
326
|
+
"""Base pitch angle in radians (rotation around Y axis)."""
|
|
327
|
+
return quaternion_to_euler(self.base_orientation)[1]
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def yaw(self) -> float:
|
|
331
|
+
"""Base yaw angle in radians (rotation around Z axis)."""
|
|
332
|
+
return quaternion_to_euler(self.base_orientation)[2]
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def speed(self) -> float:
|
|
336
|
+
"""Scalar speed (magnitude of base linear velocity) in m/s.
|
|
337
|
+
|
|
338
|
+
Example::
|
|
339
|
+
|
|
340
|
+
print(f"Speed: {robot.speed:.2f} m/s")
|
|
341
|
+
"""
|
|
342
|
+
v = self.base_velocity
|
|
343
|
+
return math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
# Reset
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def reset(
|
|
350
|
+
self,
|
|
351
|
+
position: tuple[float, float, float] | None = None,
|
|
352
|
+
orientation: tuple[float, float, float, float] | None = None,
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Reset the robot to its initial (or specified) pose.
|
|
355
|
+
|
|
356
|
+
Also resets all joint positions and velocities to zero.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
position: Target base position. Defaults to initial load position.
|
|
360
|
+
orientation: Target base orientation. Defaults to initial load orientation.
|
|
361
|
+
|
|
362
|
+
Example::
|
|
363
|
+
|
|
364
|
+
robot.reset()
|
|
365
|
+
robot.reset(position=(0, 0, 1), orientation=(0, 0, 0, 1))
|
|
366
|
+
"""
|
|
367
|
+
pos = position if position is not None else self._initial_position
|
|
368
|
+
orn = orientation if orientation is not None else self._initial_orientation
|
|
369
|
+
|
|
370
|
+
p.resetBasePositionAndOrientation(
|
|
371
|
+
self._body_id,
|
|
372
|
+
list(pos),
|
|
373
|
+
list(orn),
|
|
374
|
+
physicsClientId=self._sim.client_id,
|
|
375
|
+
)
|
|
376
|
+
p.resetBaseVelocity(
|
|
377
|
+
self._body_id,
|
|
378
|
+
[0, 0, 0],
|
|
379
|
+
[0, 0, 0],
|
|
380
|
+
physicsClientId=self._sim.client_id,
|
|
381
|
+
)
|
|
382
|
+
for joint in self._joints.values():
|
|
383
|
+
if not joint.is_fixed:
|
|
384
|
+
joint.reset(pos=0.0, vel=0.0)
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------
|
|
387
|
+
# RL interface
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def get_state(self) -> np.ndarray:
|
|
391
|
+
"""Return the full observable state as a flat NumPy array.
|
|
392
|
+
|
|
393
|
+
State vector layout::
|
|
394
|
+
|
|
395
|
+
[base_x, base_y, base_z, # base position (3)
|
|
396
|
+
base_qx, base_qy, base_qz, base_qw, # base orientation quaternion (4)
|
|
397
|
+
base_vx, base_vy, base_vz, # base linear velocity (3)
|
|
398
|
+
base_wx, base_wy, base_wz, # base angular velocity (3)
|
|
399
|
+
joint_pos_0, ..., # controllable joint positions (N)
|
|
400
|
+
joint_vel_0, ...] # controllable joint velocities (N)
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
1D NumPy float64 array of shape ``(13 + 2*N,)`` where N is the
|
|
404
|
+
number of controllable joints.
|
|
405
|
+
|
|
406
|
+
Example::
|
|
407
|
+
|
|
408
|
+
state = robot.get_state()
|
|
409
|
+
action = my_policy(state)
|
|
410
|
+
"""
|
|
411
|
+
pos = list(self.base_position)
|
|
412
|
+
orn = list(self.base_orientation)
|
|
413
|
+
vel = list(self.base_velocity)
|
|
414
|
+
avel = list(self.base_angular_velocity)
|
|
415
|
+
|
|
416
|
+
joint_positions = []
|
|
417
|
+
joint_velocities = []
|
|
418
|
+
for idx in self._joint_indices:
|
|
419
|
+
js = p.getJointState(self._body_id, idx, physicsClientId=self._sim.client_id)
|
|
420
|
+
joint_positions.append(float(js[0]))
|
|
421
|
+
joint_velocities.append(float(js[1]))
|
|
422
|
+
|
|
423
|
+
return np.array(
|
|
424
|
+
pos + orn + vel + avel + joint_positions + joint_velocities,
|
|
425
|
+
dtype=np.float64,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def apply_action(self, action: np.ndarray) -> None:
|
|
429
|
+
"""Apply a NumPy action array to all controllable joints.
|
|
430
|
+
|
|
431
|
+
The action array must have the same length as the number of
|
|
432
|
+
controllable joints. Each value is interpreted as a target velocity.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
action: 1D NumPy array of target velocities for each controllable joint.
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
ValueError: If ``action`` length doesn't match controllable joint count.
|
|
439
|
+
|
|
440
|
+
Example::
|
|
441
|
+
|
|
442
|
+
action = np.array([10.0, 10.0, -5.0])
|
|
443
|
+
robot.apply_action(action)
|
|
444
|
+
"""
|
|
445
|
+
if len(action) != len(self._joint_indices):
|
|
446
|
+
raise ValueError(
|
|
447
|
+
f"Action length {len(action)} does not match "
|
|
448
|
+
f"controllable joint count {len(self._joint_indices)}"
|
|
449
|
+
)
|
|
450
|
+
for idx, act_val in zip(self._joint_indices, action):
|
|
451
|
+
p.setJointMotorControl2(
|
|
452
|
+
self._body_id,
|
|
453
|
+
idx,
|
|
454
|
+
controlMode=p.VELOCITY_CONTROL,
|
|
455
|
+
targetVelocity=float(act_val),
|
|
456
|
+
force=100.0,
|
|
457
|
+
physicsClientId=self._sim.client_id,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def apply_torques(self, torques: np.ndarray) -> None:
|
|
461
|
+
"""Apply torque control to all controllable joints.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
torques: 1D NumPy array of torques for each controllable joint (N·m).
|
|
465
|
+
|
|
466
|
+
Raises:
|
|
467
|
+
ValueError: If ``torques`` length doesn't match controllable joint count.
|
|
468
|
+
|
|
469
|
+
Example::
|
|
470
|
+
|
|
471
|
+
robot.apply_torques(np.array([5.0, -5.0, 0.0]))
|
|
472
|
+
"""
|
|
473
|
+
if len(torques) != len(self._joint_indices):
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"Torques length {len(torques)} does not match "
|
|
476
|
+
f"controllable joint count {len(self._joint_indices)}"
|
|
477
|
+
)
|
|
478
|
+
# Disable velocity motors first
|
|
479
|
+
for idx in self._joint_indices:
|
|
480
|
+
p.setJointMotorControl2(
|
|
481
|
+
self._body_id,
|
|
482
|
+
idx,
|
|
483
|
+
controlMode=p.VELOCITY_CONTROL,
|
|
484
|
+
targetVelocity=0,
|
|
485
|
+
force=0,
|
|
486
|
+
physicsClientId=self._sim.client_id,
|
|
487
|
+
)
|
|
488
|
+
for idx, torque_val in zip(self._joint_indices, torques):
|
|
489
|
+
p.setJointMotorControl2(
|
|
490
|
+
self._body_id,
|
|
491
|
+
idx,
|
|
492
|
+
controlMode=p.TORQUE_CONTROL,
|
|
493
|
+
force=float(torque_val),
|
|
494
|
+
physicsClientId=self._sim.client_id,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# ------------------------------------------------------------------
|
|
498
|
+
# Identity
|
|
499
|
+
# ------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def name(self) -> str:
|
|
503
|
+
"""Human-readable robot name."""
|
|
504
|
+
return self._name
|
|
505
|
+
|
|
506
|
+
@name.setter
|
|
507
|
+
def name(self, value: str) -> None:
|
|
508
|
+
self._name = str(value)
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def body_id(self) -> int:
|
|
512
|
+
"""PyBullet body ID (internal identifier). Prefer using named properties."""
|
|
513
|
+
return self._body_id
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def num_joints(self) -> int:
|
|
517
|
+
"""Total number of joints (including fixed joints)."""
|
|
518
|
+
return len(self._joints)
|
|
519
|
+
|
|
520
|
+
@property
|
|
521
|
+
def num_controllable_joints(self) -> int:
|
|
522
|
+
"""Number of non-fixed, actuatable joints."""
|
|
523
|
+
return len(self._joint_indices)
|
|
524
|
+
|
|
525
|
+
# ------------------------------------------------------------------
|
|
526
|
+
# Repr
|
|
527
|
+
# ------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
def __repr__(self) -> str:
|
|
530
|
+
return (
|
|
531
|
+
f"Robot({self._name!r}, body_id={self._body_id}, "
|
|
532
|
+
f"joints={len(self._joints)}, links={len(self._links)})"
|
|
533
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BulletLab telemetry subpackage.
|
|
3
|
+
|
|
4
|
+
Provides TelemetryManager and TelemetryChannel for monitoring live robot state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from bulletlab.telemetry.manager import TelemetryManager
|
|
8
|
+
from bulletlab.telemetry.channel import TelemetryChannel
|
|
9
|
+
|
|
10
|
+
__all__ = ["TelemetryManager", "TelemetryChannel"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TelemetryChannel – a single monitored data channel.
|
|
3
|
+
|
|
4
|
+
A channel wraps a callable (lambda, method, or property function) and
|
|
5
|
+
accumulates a rolling history of (timestamp, value) pairs.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
channel = TelemetryChannel("Speed", lambda: robot.speed)
|
|
10
|
+
channel.poll(t=0.1)
|
|
11
|
+
print(channel.latest) # most recent value
|
|
12
|
+
print(channel.history) # deque of (t, value)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from collections import deque
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TelemetryChannel:
|
|
23
|
+
"""A single named data stream with a rolling history buffer.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
name: Human-readable channel name.
|
|
27
|
+
source: Callable that returns the current value when called.
|
|
28
|
+
history_len: Maximum number of samples to retain in history.
|
|
29
|
+
Defaults to 1000.
|
|
30
|
+
unit: Optional unit string (e.g. ``"m/s"``, ``"rad"``).
|
|
31
|
+
|
|
32
|
+
Example::
|
|
33
|
+
|
|
34
|
+
ch = TelemetryChannel("Roll", lambda: robot.roll, unit="rad")
|
|
35
|
+
ch.poll(0.0)
|
|
36
|
+
print(ch.latest)
|
|
37
|
+
print(ch.history[-1]) # (timestamp, value)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
name: str,
|
|
43
|
+
source: Callable[[], Any],
|
|
44
|
+
history_len: int = 1000,
|
|
45
|
+
unit: str = "",
|
|
46
|
+
) -> None:
|
|
47
|
+
self._name = name
|
|
48
|
+
self._source = source
|
|
49
|
+
self._history: deque[tuple[float, Any]] = deque(maxlen=history_len)
|
|
50
|
+
self._unit = unit
|
|
51
|
+
self._latest: Any = None
|
|
52
|
+
self._last_poll_time: float = 0.0
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Polling
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def poll(self, t: float | None = None) -> Any:
|
|
59
|
+
"""Sample the source and store the result in history.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
t: Timestamp for this sample. If ``None``, uses
|
|
63
|
+
:func:`time.monotonic`.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The sampled value.
|
|
67
|
+
|
|
68
|
+
Example::
|
|
69
|
+
|
|
70
|
+
value = channel.poll(t=sim.elapsed_time)
|
|
71
|
+
"""
|
|
72
|
+
timestamp = t if t is not None else time.monotonic()
|
|
73
|
+
try:
|
|
74
|
+
value = self._source()
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
value = float("nan")
|
|
77
|
+
|
|
78
|
+
self._latest = value
|
|
79
|
+
self._last_poll_time = timestamp
|
|
80
|
+
self._history.append((timestamp, value))
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Properties
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def name(self) -> str:
|
|
89
|
+
"""Channel name."""
|
|
90
|
+
return self._name
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def unit(self) -> str:
|
|
94
|
+
"""Measurement unit string (e.g. ``"m/s"``)."""
|
|
95
|
+
return self._unit
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def latest(self) -> Any:
|
|
99
|
+
"""Most recently polled value, or ``None`` if never polled."""
|
|
100
|
+
return self._latest
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def history(self) -> deque[tuple[float, Any]]:
|
|
104
|
+
"""Rolling history as a deque of ``(timestamp, value)`` pairs."""
|
|
105
|
+
return self._history
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def timestamps(self) -> list[float]:
|
|
109
|
+
"""List of all stored timestamps."""
|
|
110
|
+
return [t for t, _ in self._history]
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def values(self) -> list[Any]:
|
|
114
|
+
"""List of all stored values."""
|
|
115
|
+
return [v for _, v in self._history]
|
|
116
|
+
|
|
117
|
+
def clear(self) -> None:
|
|
118
|
+
"""Clear the history buffer."""
|
|
119
|
+
self._history.clear()
|
|
120
|
+
self._latest = None
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
unit_str = f" {self._unit}" if self._unit else ""
|
|
124
|
+
return f"TelemetryChannel({self._name!r}, latest={self._latest}{unit_str})"
|