bulletlab 0.1.0__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 ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ BulletLab – A fast, extensible robotics experimentation framework built on PyBullet.
3
+
4
+ Developed by Ranasurya Ghosh (https://github.com/NuclearVenom/BulletLab)
5
+
6
+ BulletLab provides a high-level Python API over PyBullet, making robotics
7
+ experimentation significantly easier by exposing robots as structured Python
8
+ objects rather than raw physics engine primitives.
9
+
10
+ Quick Start::
11
+
12
+ from bulletlab import Simulation, Robot
13
+
14
+ sim = Simulation()
15
+ sim.start()
16
+
17
+ robot = Robot.load("robot.urdf", sim=sim)
18
+ robot.joints["motor"].velocity = 15
19
+ robot.links["wheel"].mass = 2.5
20
+
21
+ while True:
22
+ sim.step()
23
+ """
24
+
25
+ from bulletlab.core.simulation import Simulation
26
+ from bulletlab.core.world import World
27
+ from bulletlab.robot.robot import Robot
28
+ from bulletlab.robot.joint import Joint
29
+ from bulletlab.robot.link import Link
30
+ from bulletlab.telemetry.manager import TelemetryManager
31
+ from bulletlab.logging.logger import DataLogger
32
+ from bulletlab.plotting.live_plot import LivePlot
33
+
34
+ __version__ = "0.1.0"
35
+ __author__ = "Ranasurya Ghosh"
36
+ __url__ = "https://github.com/NuclearVenom/BulletLab"
37
+ __license__ = "MIT"
38
+
39
+ __all__ = [
40
+ "Simulation",
41
+ "World",
42
+ "Robot",
43
+ "Joint",
44
+ "Link",
45
+ "TelemetryManager",
46
+ "DataLogger",
47
+ "LivePlot",
48
+ ]
@@ -0,0 +1,10 @@
1
+ """
2
+ BulletLab core subpackage.
3
+
4
+ Provides the Simulation and World classes — the foundation of every BulletLab session.
5
+ """
6
+
7
+ from bulletlab.core.simulation import Simulation
8
+ from bulletlab.core.world import World
9
+
10
+ __all__ = ["Simulation", "World"]
@@ -0,0 +1,377 @@
1
+ """
2
+ Simulation – the central controller for a BulletLab physics session.
3
+
4
+ The Simulation class manages the PyBullet physics server connection, controls
5
+ the simulation time step, gravity, stepping, pausing, and tracks all robots
6
+ loaded into the scene.
7
+
8
+ Example::
9
+
10
+ from bulletlab import Simulation
11
+
12
+ sim = Simulation()
13
+ sim.start() # connects to PyBullet GUI
14
+ sim.gravity = (0, 0, -9.81)
15
+ sim.timestep = 1.0 / 240.0
16
+
17
+ while True:
18
+ sim.step()
19
+
20
+ sim.stop()
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ from typing import TYPE_CHECKING, Optional
27
+
28
+ import pybullet as p
29
+ import pybullet_data
30
+
31
+ if TYPE_CHECKING:
32
+ from bulletlab.robot.robot import Robot
33
+
34
+
35
+ class Simulation:
36
+ """Central controller for a BulletLab physics simulation session.
37
+
38
+ Wraps the PyBullet physics server and provides a high-level interface
39
+ for connecting, stepping, pausing, resetting, and configuring the
40
+ simulation environment.
41
+
42
+ Args:
43
+ mode: PyBullet connection mode. Use ``"gui"`` for an interactive
44
+ window or ``"direct"`` for headless (testing/RL) mode.
45
+ gravity: Initial gravity vector as ``(gx, gy, gz)``.
46
+ Defaults to ``(0, 0, -9.81)``.
47
+ timestep: Physics timestep in seconds. Defaults to ``1/240``.
48
+ real_time: If ``True``, enable real-time simulation in GUI mode.
49
+
50
+ Example::
51
+
52
+ sim = Simulation(mode="gui")
53
+ sim.start()
54
+ sim.gravity = (0, 0, -9.81)
55
+
56
+ for _ in range(1000):
57
+ sim.step()
58
+
59
+ sim.stop()
60
+ """
61
+
62
+ GUI = p.GUI
63
+ DIRECT = p.DIRECT
64
+
65
+ def __init__(
66
+ self,
67
+ mode: str = "gui",
68
+ gravity: tuple[float, float, float] = (0.0, 0.0, -9.81),
69
+ timestep: float = 1.0 / 240.0,
70
+ real_time: bool = False,
71
+ hide_gui: bool = True,
72
+ ) -> None:
73
+ self._mode_str = mode.lower()
74
+ self._mode = p.GUI if self._mode_str == "gui" else p.DIRECT
75
+ self._gravity = gravity
76
+ self._timestep = timestep
77
+ self._real_time = real_time
78
+ self._hide_gui = hide_gui
79
+ self._client_id: int = -1
80
+ self._paused: bool = False
81
+ self._step_count: int = 0
82
+ self._robots: list["Robot"] = []
83
+ self._connected: bool = False
84
+
85
+ # ------------------------------------------------------------------
86
+ # Connection lifecycle
87
+ # ------------------------------------------------------------------
88
+
89
+ def start(self) -> "Simulation":
90
+ """Connect to the PyBullet server and configure the environment.
91
+
92
+ Returns:
93
+ self, for method chaining.
94
+
95
+ Example::
96
+
97
+ sim = Simulation().start()
98
+ """
99
+ if self._connected:
100
+ return self
101
+
102
+ self._client_id = p.connect(self._mode)
103
+ p.setAdditionalSearchPath(pybullet_data.getDataPath(), physicsClientId=self._client_id)
104
+ p.setGravity(*self._gravity, physicsClientId=self._client_id)
105
+ p.setTimeStep(self._timestep, physicsClientId=self._client_id)
106
+
107
+ if self._mode == p.GUI:
108
+ if self._hide_gui:
109
+ # Remove all PyBullet built-in sidebar panels, sliders,
110
+ # and debug widgets. BulletLab provides its own ImGui UI.
111
+ p.configureDebugVisualizer(
112
+ p.COV_ENABLE_GUI, 0, physicsClientId=self._client_id
113
+ )
114
+ if self._real_time:
115
+ p.setRealTimeSimulation(1, physicsClientId=self._client_id)
116
+
117
+ self._connected = True
118
+ return self
119
+
120
+
121
+ def stop(self) -> None:
122
+ """Disconnect from the PyBullet server.
123
+
124
+ Example::
125
+
126
+ sim.stop()
127
+ """
128
+ if self._connected:
129
+ try:
130
+ p.disconnect(physicsClientId=self._client_id)
131
+ except Exception:
132
+ pass
133
+ self._connected = False
134
+ self._client_id = -1
135
+
136
+ # Alias for stop()
137
+ disconnect = stop
138
+
139
+ def reset(self) -> None:
140
+ """Reset the simulation to a clean state.
141
+
142
+ Removes all objects from the world, resets the step counter,
143
+ and reloads the data search path. Robot references in
144
+ ``self.robots`` are cleared.
145
+
146
+ Example::
147
+
148
+ sim.reset()
149
+ """
150
+ if not self._connected:
151
+ return
152
+ p.resetSimulation(physicsClientId=self._client_id)
153
+ p.setAdditionalSearchPath(pybullet_data.getDataPath(), physicsClientId=self._client_id)
154
+ p.setGravity(*self._gravity, physicsClientId=self._client_id)
155
+ p.setTimeStep(self._timestep, physicsClientId=self._client_id)
156
+ self._robots.clear()
157
+ self._step_count = 0
158
+ self._paused = False
159
+
160
+ # ------------------------------------------------------------------
161
+ # Stepping
162
+ # ------------------------------------------------------------------
163
+
164
+ def step(self) -> None:
165
+ """Advance the simulation by one timestep.
166
+
167
+ Does nothing if the simulation is paused or not connected.
168
+
169
+ Example::
170
+
171
+ for _ in range(1000):
172
+ sim.step()
173
+ """
174
+ if not self._connected or self._paused:
175
+ return
176
+ p.stepSimulation(physicsClientId=self._client_id)
177
+ self._step_count += 1
178
+
179
+ def pause(self) -> None:
180
+ """Pause the simulation. Calls to :meth:`step` are no-ops while paused.
181
+
182
+ Example::
183
+
184
+ sim.pause()
185
+ """
186
+ self._paused = True
187
+
188
+ def resume(self) -> None:
189
+ """Resume a paused simulation.
190
+
191
+ Example::
192
+
193
+ sim.resume()
194
+ """
195
+ self._paused = False
196
+
197
+ # ------------------------------------------------------------------
198
+ # Properties
199
+ # ------------------------------------------------------------------
200
+
201
+ @property
202
+ def gravity(self) -> tuple[float, float, float]:
203
+ """Gravity vector ``(gx, gy, gz)`` in m/s².
204
+
205
+ Example::
206
+
207
+ sim.gravity = (0, 0, -9.81) # Earth gravity
208
+ sim.gravity = (0, 0, -1.62) # Moon gravity
209
+ """
210
+ return self._gravity
211
+
212
+ @gravity.setter
213
+ def gravity(self, value: tuple[float, float, float]) -> None:
214
+ self._gravity = tuple(float(v) for v in value) # type: ignore[assignment]
215
+ if self._connected:
216
+ p.setGravity(*self._gravity, physicsClientId=self._client_id)
217
+
218
+ @property
219
+ def timestep(self) -> float:
220
+ """Physics timestep in seconds.
221
+
222
+ Example::
223
+
224
+ sim.timestep = 1.0 / 480.0 # higher resolution
225
+ """
226
+ return self._timestep
227
+
228
+ @timestep.setter
229
+ def timestep(self, value: float) -> None:
230
+ self._timestep = float(value)
231
+ if self._connected:
232
+ p.setTimeStep(self._timestep, physicsClientId=self._client_id)
233
+
234
+ @property
235
+ def is_paused(self) -> bool:
236
+ """``True`` if the simulation is currently paused."""
237
+ return self._paused
238
+
239
+ @property
240
+ def is_connected(self) -> bool:
241
+ """``True`` if connected to a PyBullet physics server."""
242
+ return self._connected
243
+
244
+ @property
245
+ def client_id(self) -> int:
246
+ """The PyBullet physics server client ID.
247
+
248
+ This is an internal identifier. Most users should not need it.
249
+ """
250
+ return self._client_id
251
+
252
+ @property
253
+ def step_count(self) -> int:
254
+ """Total number of simulation steps taken since last reset."""
255
+ return self._step_count
256
+
257
+ @property
258
+ def elapsed_time(self) -> float:
259
+ """Simulated time elapsed in seconds since last reset."""
260
+ return self._step_count * self._timestep
261
+
262
+ @property
263
+ def robots(self) -> list["Robot"]:
264
+ """List of all robots currently registered in this simulation."""
265
+ return list(self._robots)
266
+
267
+ # ------------------------------------------------------------------
268
+ # Robot management
269
+ # ------------------------------------------------------------------
270
+
271
+ def add_robot(self, robot: "Robot") -> None:
272
+ """Register a robot with this simulation.
273
+
274
+ This is called automatically by :meth:`Robot.load` when ``sim`` is
275
+ provided, so you rarely need to call this directly.
276
+
277
+ Args:
278
+ robot: A :class:`~bulletlab.robot.robot.Robot` instance.
279
+
280
+ Example::
281
+
282
+ sim.add_robot(robot)
283
+ """
284
+ if robot not in self._robots:
285
+ self._robots.append(robot)
286
+
287
+ def remove_robot(self, robot: "Robot") -> None:
288
+ """Unregister a robot from this simulation.
289
+
290
+ Args:
291
+ robot: The robot to remove.
292
+ """
293
+ if robot in self._robots:
294
+ self._robots.remove(robot)
295
+
296
+ # ------------------------------------------------------------------
297
+ # Utilities
298
+ # ------------------------------------------------------------------
299
+
300
+ def add_debug_text(
301
+ self,
302
+ text: str,
303
+ position: tuple[float, float, float],
304
+ color: tuple[float, float, float] = (1.0, 1.0, 1.0),
305
+ size: float = 1.5,
306
+ ) -> int:
307
+ """Add a text label to the PyBullet debug visualizer.
308
+
309
+ Args:
310
+ text: Text to display.
311
+ position: 3D world position ``(x, y, z)``.
312
+ color: RGB color normalized to [0, 1].
313
+ size: Text size multiplier.
314
+
315
+ Returns:
316
+ Debug item ID (can be used to remove later).
317
+ """
318
+ if not self._connected:
319
+ return -1
320
+ return p.addUserDebugText(
321
+ text,
322
+ list(position),
323
+ textColorRGB=list(color),
324
+ textSize=size,
325
+ physicsClientId=self._client_id,
326
+ )
327
+
328
+ def remove_debug_item(self, item_id: int) -> None:
329
+ """Remove a debug visualizer item by ID.
330
+
331
+ Args:
332
+ item_id: ID returned by :meth:`add_debug_text` or similar.
333
+ """
334
+ if self._connected:
335
+ p.removeUserDebugItem(item_id, physicsClientId=self._client_id)
336
+
337
+ def set_camera(
338
+ self,
339
+ distance: float = 3.0,
340
+ yaw: float = 50.0,
341
+ pitch: float = -35.0,
342
+ target: tuple[float, float, float] = (0.0, 0.0, 0.0),
343
+ ) -> None:
344
+ """Set the PyBullet debug camera position.
345
+
346
+ Args:
347
+ distance: Camera distance from target.
348
+ yaw: Camera yaw angle in degrees.
349
+ pitch: Camera pitch angle in degrees.
350
+ target: Camera target position ``(x, y, z)``.
351
+ """
352
+ if self._connected and self._mode == p.GUI:
353
+ p.resetDebugVisualizerCamera(
354
+ cameraDistance=distance,
355
+ cameraYaw=yaw,
356
+ cameraPitch=pitch,
357
+ cameraTargetPosition=list(target),
358
+ physicsClientId=self._client_id,
359
+ )
360
+
361
+ # ------------------------------------------------------------------
362
+ # Context manager support
363
+ # ------------------------------------------------------------------
364
+
365
+ def __enter__(self) -> "Simulation":
366
+ self.start()
367
+ return self
368
+
369
+ def __exit__(self, *_: object) -> None:
370
+ self.stop()
371
+
372
+ def __repr__(self) -> str:
373
+ status = "connected" if self._connected else "disconnected"
374
+ return (
375
+ f"Simulation(mode={self._mode_str!r}, {status}, "
376
+ f"step={self._step_count}, t={self.elapsed_time:.3f}s)"
377
+ )
@@ -0,0 +1,173 @@
1
+ """
2
+ World – helper for populating a BulletLab simulation with environment objects.
3
+
4
+ The World class provides convenient methods for loading static environment
5
+ geometry (ground planes, terrain, obstacles) into the simulation.
6
+
7
+ Example::
8
+
9
+ from bulletlab import Simulation
10
+ from bulletlab.core.world import World
11
+
12
+ sim = Simulation().start()
13
+ world = World(sim)
14
+ world.load_plane()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ import pybullet as p
23
+ import pybullet_data
24
+
25
+ from bulletlab.core.simulation import Simulation
26
+
27
+
28
+ class World:
29
+ """Helper for populating the simulation environment with static geometry.
30
+
31
+ Args:
32
+ sim: The :class:`~bulletlab.core.simulation.Simulation` instance to use.
33
+
34
+ Example::
35
+
36
+ sim = Simulation().start()
37
+ world = World(sim)
38
+ world.load_plane()
39
+ world.load_urdf("box.urdf", position=(2, 0, 0.5))
40
+ """
41
+
42
+ def __init__(self, sim: Simulation) -> None:
43
+ self._sim = sim
44
+ self._bodies: list[int] = []
45
+
46
+ # ------------------------------------------------------------------
47
+ # Loaders
48
+ # ------------------------------------------------------------------
49
+
50
+ def load_plane(
51
+ self,
52
+ texture_scale: float = 1.0,
53
+ ) -> int:
54
+ """Load the standard flat ground plane.
55
+
56
+ Uses the ``plane.urdf`` asset bundled with pybullet_data.
57
+
58
+ Args:
59
+ texture_scale: Not used currently; reserved for future texture
60
+ scaling support.
61
+
62
+ Returns:
63
+ PyBullet body ID of the plane.
64
+
65
+ Example::
66
+
67
+ world.load_plane()
68
+ """
69
+ body_id = p.loadURDF(
70
+ "plane.urdf",
71
+ physicsClientId=self._sim.client_id,
72
+ )
73
+ self._bodies.append(body_id)
74
+ return body_id
75
+
76
+ def load_urdf(
77
+ self,
78
+ path: str | Path,
79
+ position: tuple[float, float, float] = (0.0, 0.0, 0.0),
80
+ orientation: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0),
81
+ fixed: bool = True,
82
+ scale: float = 1.0,
83
+ ) -> int:
84
+ """Load an arbitrary URDF as a static or dynamic object.
85
+
86
+ Args:
87
+ path: Path to the URDF file. Can be an absolute path or a
88
+ filename resolvable from the pybullet_data search path.
89
+ position: Initial position ``(x, y, z)`` in meters.
90
+ orientation: Initial orientation as a quaternion ``(x, y, z, w)``.
91
+ fixed: If ``True``, the base link is fixed to the world frame.
92
+ scale: Global scale factor for the loaded model.
93
+
94
+ Returns:
95
+ PyBullet body ID.
96
+
97
+ Example::
98
+
99
+ box_id = world.load_urdf("cube_small.urdf", position=(1, 0, 0.5))
100
+ """
101
+ flags = p.URDF_USE_INERTIA_FROM_FILE
102
+ body_id = p.loadURDF(
103
+ str(path),
104
+ basePosition=list(position),
105
+ baseOrientation=list(orientation),
106
+ useFixedBase=fixed,
107
+ globalScaling=scale,
108
+ flags=flags,
109
+ physicsClientId=self._sim.client_id,
110
+ )
111
+ self._bodies.append(body_id)
112
+ return body_id
113
+
114
+ def load_sdf(
115
+ self,
116
+ path: str | Path,
117
+ ) -> list[int]:
118
+ """Load an SDF file and return all resulting body IDs.
119
+
120
+ Args:
121
+ path: Path to the SDF file.
122
+
123
+ Returns:
124
+ List of PyBullet body IDs created.
125
+ """
126
+ ids = p.loadSDF(str(path), physicsClientId=self._sim.client_id)
127
+ self._bodies.extend(ids)
128
+ return list(ids)
129
+
130
+ # ------------------------------------------------------------------
131
+ # Convenience factories
132
+ # ------------------------------------------------------------------
133
+
134
+ def set_gravity(self, gx: float = 0.0, gy: float = 0.0, gz: float = -9.81) -> None:
135
+ """Set simulation gravity.
136
+
137
+ Convenience shorthand for :attr:`Simulation.gravity`.
138
+
139
+ Args:
140
+ gx: X component in m/s².
141
+ gy: Y component in m/s².
142
+ gz: Z component in m/s².
143
+
144
+ Example::
145
+
146
+ world.set_gravity(gz=-1.62) # Moon
147
+ """
148
+ self._sim.gravity = (gx, gy, gz)
149
+
150
+ # ------------------------------------------------------------------
151
+ # Cleanup
152
+ # ------------------------------------------------------------------
153
+
154
+ def clear(self) -> None:
155
+ """Remove all objects loaded by this World instance.
156
+
157
+ Does not reset the entire simulation — only removes objects
158
+ that this :class:`World` instance created.
159
+ """
160
+ for body_id in self._bodies:
161
+ try:
162
+ p.removeBody(body_id, physicsClientId=self._sim.client_id)
163
+ except Exception:
164
+ pass
165
+ self._bodies.clear()
166
+
167
+ @property
168
+ def body_ids(self) -> list[int]:
169
+ """List of all PyBullet body IDs managed by this World."""
170
+ return list(self._bodies)
171
+
172
+ def __repr__(self) -> str:
173
+ return f"World(bodies={len(self._bodies)})"
@@ -0,0 +1,9 @@
1
+ """
2
+ BulletLab logging subpackage.
3
+
4
+ Provides DataLogger with CSV and JSON backends for recording experiment data.
5
+ """
6
+
7
+ from bulletlab.logging.logger import DataLogger
8
+
9
+ __all__ = ["DataLogger"]
@@ -0,0 +1,54 @@
1
+ """
2
+ CSV writer backend for DataLogger.
3
+
4
+ Internal module — used by :class:`~bulletlab.logging.logger.DataLogger`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ class CsvWriter:
15
+ """Writes tabular log data to a CSV file.
16
+
17
+ Args:
18
+ filepath: Output file path.
19
+ fieldnames: Column names.
20
+
21
+ Example::
22
+
23
+ writer = CsvWriter("run1.csv", ["t", "speed", "roll"])
24
+ writer.write_row({"t": 0.0, "speed": 1.5, "roll": 0.01})
25
+ writer.close()
26
+ """
27
+
28
+ def __init__(self, filepath: str | Path, fieldnames: list[str]) -> None:
29
+ self._filepath = Path(filepath)
30
+ self._fieldnames = fieldnames
31
+ self._file = open(self._filepath, "w", newline="", encoding="utf-8")
32
+ self._writer = csv.DictWriter(self._file, fieldnames=self._fieldnames)
33
+ self._writer.writeheader()
34
+
35
+ def write_row(self, row: dict[str, Any]) -> None:
36
+ """Write a single row to the CSV file.
37
+
38
+ Args:
39
+ row: Dictionary mapping column names to values.
40
+ """
41
+ self._writer.writerow(row)
42
+
43
+ def flush(self) -> None:
44
+ """Flush the write buffer to disk."""
45
+ self._file.flush()
46
+
47
+ def close(self) -> None:
48
+ """Close the file handle."""
49
+ if not self._file.closed:
50
+ self._file.flush()
51
+ self._file.close()
52
+
53
+ def __del__(self) -> None:
54
+ self.close()