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.
@@ -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})"