mujoco-toolbox 0.1.8__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,131 @@
1
+ from numpy import cos, pi, random, sin
2
+
3
+
4
+ def _apply_control(model, data, value, joint=None, axis=None, delay=0):
5
+ """Common helper function for controller logic to reduce redundancy.
6
+
7
+ Args:
8
+ model: MuJoCo model.
9
+ data: MuJoCo data.
10
+ value: Control value to apply.
11
+ joint (list[int], optional): Joints to apply control to.
12
+ axis (int, optional): Axis to apply control to.
13
+ delay (float, optional): Delay before applying control.
14
+ """
15
+ # Don't apply control until after delay
16
+ if data.time < delay:
17
+ return
18
+
19
+ # Determine targets if not explicitly provided
20
+ if joint is None and axis is None:
21
+ joint = range(model.nu)
22
+
23
+ # Apply control to joints or specific axis
24
+ if joint is not None and model.nu > 0:
25
+ for j in joint:
26
+ data.ctrl[j] = value
27
+ elif axis is not None:
28
+ data.qpos[axis] = value
29
+
30
+
31
+ def stepController(model, data, **kwargs):
32
+ """A step controller for the simulation.
33
+
34
+ Args:
35
+ amplitude (float): The amplitude of the step signal (default=1).
36
+ joint (list[int]): The joints to apply the step signal to (default=all).
37
+ axis (int): The axis to apply the step signal to (default=None).
38
+ delay (float): The delay before applying the step signal (default=0).
39
+
40
+ Returns:
41
+ None
42
+ """
43
+ amplitude = kwargs.get("amplitude", 1)
44
+ joint = kwargs.get("joint", None)
45
+ axis = kwargs.get("axis", None)
46
+ delay = kwargs.get("delay", 0)
47
+
48
+ if delay < 0:
49
+ raise ValueError("Delay must be non-negative.")
50
+ if joint is not None and axis is not None:
51
+ raise ValueError("Cannot specify both 'joint' and 'axis'.")
52
+
53
+ _apply_control(model, data, amplitude, joint=joint, axis=axis, delay=delay)
54
+
55
+
56
+ def sineController(model, data, **kwargs):
57
+ """A simple sine wave controller for the simulation.
58
+
59
+ Args:
60
+ amplitude (float): The amplitude of the sine wave (default=1).
61
+ frequency (float): The frequency of the sine wave (default=1).
62
+ phase (float): The phase shift of the sine wave (default=0).
63
+ joint (list[int]): The joint to apply the sine wave to (default=all).
64
+ delay (float): The delay before applying the sine wave (default=0).
65
+
66
+ Returns:
67
+ None
68
+ """
69
+ amplitude = kwargs.get("amplitude", 1)
70
+ frequency = kwargs.get("frequency", 1)
71
+ phase = kwargs.get("phase", 0)
72
+ joint = kwargs.get("joint", None)
73
+ delay = kwargs.get("delay", 0)
74
+
75
+ if delay < 0:
76
+ raise ValueError("Delay must be non-negative.")
77
+
78
+ value = amplitude * sin(2 * pi * frequency * data.time + phase)
79
+ _apply_control(model, data, value, joint=joint, delay=delay)
80
+
81
+
82
+ def cosineController(model, data, **kwargs):
83
+ """A simple cosine wave controller for the simulation.
84
+
85
+ Args:
86
+ amplitude (float): The amplitude of the cosine wave (default=1).
87
+ frequency (float): The frequency of the cosine wave (default=1).
88
+ phase (float): The phase shift of the cosine wave (default=0).
89
+ joint (list[int]): The joint to apply the cosine wave to (default=all).
90
+ delay (float): The delay before applying the cosine wave (default=0).
91
+
92
+ Returns:
93
+ None
94
+ """
95
+ amplitude = kwargs.get("amplitude", 1)
96
+ frequency = kwargs.get("frequency", 1)
97
+ phase = kwargs.get("phase", 0)
98
+ joint = kwargs.get("joint", None)
99
+ delay = kwargs.get("delay", 0)
100
+
101
+ if delay < 0:
102
+ raise ValueError("Delay must be non-negative.")
103
+
104
+ value = amplitude * cos(2 * pi * frequency * data.time + phase)
105
+ _apply_control(model, data, value, joint=joint, delay=delay)
106
+
107
+
108
+ def randomController(model, data, **kwargs):
109
+ """A random controller for the simulation.
110
+
111
+ Args:
112
+ amplitude (float): The maximum amplitude of the random signal (default=1).
113
+ joint (list[int]): The joints to apply the random signal to (default=all).
114
+ axis (int): The axis to apply the random signal to (default=None).
115
+ delay (float): The delay before applying the random signal (default=0).
116
+
117
+ Returns:
118
+ None
119
+ """
120
+ amplitude = kwargs.get("amplitude", 1)
121
+ joint = kwargs.get("joint", None)
122
+ axis = kwargs.get("axis", None)
123
+ delay = kwargs.get("delay", 0)
124
+
125
+ if delay < 0:
126
+ raise ValueError("Delay must be non-negative.")
127
+ if joint is not None and axis is not None:
128
+ raise ValueError("Cannot specify both 'joint' and 'axis'.")
129
+
130
+ value = amplitude * random.rand()
131
+ _apply_control(model, data, value, joint=joint, axis=axis, delay=delay)
@@ -0,0 +1,53 @@
1
+ from functools import wraps
2
+ from sys import platform
3
+ from time import time
4
+
5
+
6
+ def timer(func):
7
+ @wraps(func)
8
+ def time_wrapper(*args, **kwargs):
9
+ start_time = time()
10
+ result = func(*args, **kwargs)
11
+ end_time = time()
12
+ total_time = end_time - start_time
13
+
14
+ from . import VERBOSITY
15
+
16
+ if VERBOSITY:
17
+ print(f"Function '{func.__name__}' took {total_time:.4f} seconds")
18
+ return result
19
+
20
+ return time_wrapper
21
+
22
+
23
+ def print_warning(*args) -> None:
24
+ """
25
+ Prints the first argument as an orange warning message and prints the other arguments in regular text, all on the same line.
26
+
27
+ Args:
28
+ *args: First argument is printed as an orange warning, other arguments are printed normally.
29
+ """
30
+ if args:
31
+ # Make sure the first argument is printed with the 'WARNING:' prefix and in orange
32
+ message = f"WARNING: {args[0]}"
33
+
34
+ # Check if we're on Windows or a Unix-like system for color support
35
+ if platform.startswith("win"):
36
+ try:
37
+ from colorama import Fore, Style, init
38
+
39
+ init() # Initialize colorama
40
+ print(
41
+ f"{Fore.YELLOW}{message}{Style.RESET_ALL}", end=" "
42
+ ) # Yellow color for warning
43
+ print(
44
+ *args[1:], end=""
45
+ ) # No newline, print all args separated by spaces
46
+ except ImportError:
47
+ # If colorama is not available, print without color
48
+ print(message, end=" ")
49
+ print(*args[1:], end="")
50
+ else:
51
+ # ANSI escape code for orange (for Unix-like systems)
52
+ print(f"\033[38;5;214m{message}\033[0m", end=" ") # Orange ANSI escape code
53
+ print(*args[1:], end="") # No newline, print all args separated by spaces
@@ -0,0 +1,741 @@
1
+ import os
2
+ import sys
3
+ import xml.etree.ElementTree as ET
4
+ from collections import defaultdict
5
+ from functools import lru_cache
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ import matplotlib.pyplot as plt
9
+ import mediapy as media
10
+ import mujoco
11
+ import numpy as np
12
+ import trimesh
13
+ import yaml
14
+ from screeninfo import get_monitors
15
+ from tqdm import tqdm as barTerminal
16
+ from tqdm.notebook import tqdm as barNotebook
17
+ from screeninfo import get_monitors
18
+ import trimesh
19
+ import os
20
+ import sys
21
+ from dataclasses import dataclass, field
22
+ from typing import Dict, List, Tuple, Union, Optional, Callable, Any, TypeAlias
23
+
24
+ from .Utils import print_warning, timer
25
+
26
+ assert sys.version_info >= (3, 10), "This code requires Python 3.10.0 or later."
27
+ assert mujoco.__version__ >= "2.0.0", "This code requires MuJoCo 2.0.0 or later."
28
+
29
+ mjModel: TypeAlias = mujoco.MjModel
30
+ mjData: TypeAlias = mujoco.MjData
31
+
32
+
33
+ class Wrapper(object):
34
+ """A class to handle MuJoCo simulations and data capture."""
35
+
36
+ def __init__(self, xml:str, duration:int=10, fps:int=30, resolution:Tuple[int,int]=(400,300), initialConditions:Dict[str, List]={}, controller:Optional[Callable[[mjModel, mjData, Any], None]]=None, *args, **kwargs):
37
+ self._load_model(xml, **kwargs)
38
+
39
+ self.duration = duration
40
+ self.fps = fps
41
+ self.resolution = resolution # recursively sets width and height
42
+ self.init_conditions = initialConditions
43
+ self.controller = controller
44
+
45
+ # Predefined simulation parameters but can be overridden
46
+ # TODO: Currently Causing Bugs when occluded from XML Code
47
+ self.ts = kwargs.get("ts", self._model.opt.timestep)
48
+ self.gravity = kwargs.get("gravity", self._model.opt.gravity)
49
+
50
+ self._data = mujoco.MjData(self._model)
51
+
52
+ # Auto-Populate the names of bodies, joints, and actuators
53
+ self._body_names = [self._model.body(i).name for i in range(self._model.nbody)]
54
+ self._geom_names = [self._model.geom(i).name for i in range(self._model.ngeom)]
55
+ self._joint_names = [self._model.joint(i).name for i in range(self._model.njnt)]
56
+ self._actuator_names = [
57
+ self._model.actuator(i).name for i in range(self._model.nu)
58
+ ]
59
+
60
+ def _load_model(self, xml: str, **kwargs: Any) -> None:
61
+ """Load a MuJoCo model from an XML file or a string."""
62
+ try:
63
+ # Convert the XML path to an absolute path
64
+ xml_path = os.path.abspath(xml)
65
+
66
+ # Check if the path exists
67
+ if os.path.exists(xml_path):
68
+ # Extract and validate file extension
69
+ extension = os.path.splitext(xml_path)[1].lower()[1:]
70
+
71
+ if extension == "xml":
72
+ self._load_xml_file(xml_path)
73
+ elif extension == "urdf":
74
+ self._load_urdf_file(xml_path, **kwargs)
75
+ else:
76
+ raise ValueError(
77
+ f"Unsupported file extension: '{extension}'. Please provide an XML or URDF file."
78
+ )
79
+ else:
80
+ # If the file doesn't exist, assume it's a string and attempt to load it as XML
81
+ if "<mujoco>" in xml or "<robot>" in xml:
82
+ self._load_xml_string(xml)
83
+ else:
84
+ raise FileNotFoundError(
85
+ f"Model file not found: {xml_path}. Ensure the file path is correct and accessible."
86
+ )
87
+
88
+ except FileNotFoundError as e:
89
+ raise FileNotFoundError(f"Failed to load the MuJoCo model: {e}") from e
90
+
91
+ except ValueError as e:
92
+ raise ValueError(
93
+ f"Invalid value encountered while loading the model: {e}"
94
+ ) from e
95
+
96
+ except Exception as e:
97
+ raise Exception(
98
+ f"Unexpected error while loading the MuJoCo model: {e}"
99
+ ) from e
100
+
101
+ def _load_xml_file(self, xml: str, **kwargs) -> None:
102
+ """Load a MuJoCo model from an XML file."""
103
+ self._model = mujoco.MjModel.from_xml_path(xml)
104
+ with open(xml, "r") as f:
105
+ self.xml = f.read()
106
+ template = kwargs.get('template', None)
107
+ if template:
108
+ try:
109
+ self.xml = self.xml.format(**template)
110
+ except KeyError as e:
111
+ raise ValueError(f"Template key error. Ensure the template keys match the placeholders in the XML.") from e
112
+ except Exception as e:
113
+ raise ValueError(f"Error formatting XML with template") from e
114
+ self._model = mujoco.MjModel.from_xml_string(self.xml)
115
+
116
+ def _load_urdf_file(self, urdf_path: str, **kwargs: Any) -> None:
117
+ """Process and load a URDF file for use with MuJoCo."""
118
+
119
+ def convert_dae_to_stl(meshdir: str) -> None:
120
+ """Convert all DAE files in a directory (including subdirectories) to STL."""
121
+ if not os.path.exists(meshdir):
122
+ raise FileNotFoundError(f"Directory not found: {meshdir}")
123
+ for filename in os.listdir(meshdir):
124
+ if filename.lower().endswith(".dae"):
125
+ dae_path = os.path.join(meshdir, filename)
126
+ stl_path = os.path.splitext(dae_path)[0] + ".stl"
127
+ try:
128
+ trimesh.load_mesh(dae_path).export(stl_path)
129
+ print(f"Converted: {os.path.basename(dae_path)} -> {os.path.basename(stl_path)}")
130
+ except Exception as e:
131
+ raise ValueError(f"Error converting {filename}") from e
132
+
133
+ try:
134
+ from . import VERBOSITY
135
+ robot = ET.parse(urdf_path).getroot()
136
+ mujoco_tag = ET.Element("mujoco")
137
+
138
+ # Get main meshdir (parent directory for meshes)
139
+ meshdir = kwargs.get("meshdir", "meshes/") # Default as tuple
140
+ if not os.path.isabs(meshdir):
141
+ urdf_dir = os.path.dirname(os.path.abspath(urdf_path))
142
+ meshdir = os.path.join(urdf_dir, meshdir)
143
+
144
+ # Ensure meshdir exists
145
+ if not os.path.exists(meshdir):
146
+ raise FileNotFoundError(f"Mesh directory not found: {meshdir}")
147
+
148
+ # If no explicit subdirs are provided, auto-detect subdirectories with STL files
149
+ subdirs = kwargs.get('meshdir_sub', None)
150
+ if not subdirs:
151
+ subdirs = [os.path.relpath(root, meshdir) for root, _, files in os.walk(meshdir) if any(f.endswith('.stl') for f in files)]
152
+ if VERBOSITY:
153
+ print(f"Auto-detected subdirectories: {subdirs}")
154
+
155
+ # Convert relative subdir paths to absolute based on meshdir
156
+ full_meshdirs = [
157
+ os.path.join(meshdir, subdir) if not os.path.isabs(subdir) else subdir
158
+ for subdir in subdirs
159
+ ]
160
+
161
+ # Add <compiler> tag with the main meshdir (required for MuJoCo)
162
+ compiler_attrs = {
163
+ "meshdir": meshdir, # Use the main meshdir
164
+ "balanceinertia": kwargs.get("balanceinertia", "true"),
165
+ "discardvisual": "false",
166
+ }
167
+ ET.SubElement(mujoco_tag, "compiler", **compiler_attrs)
168
+
169
+ # Create <asset> tag (required for MuJoCo)
170
+ asset_tag = ET.SubElement(mujoco_tag, "asset")
171
+
172
+
173
+ # Process all valid mesh directories
174
+ for full_meshdir in full_meshdirs:
175
+ if os.path.exists(full_meshdir):
176
+ convert_dae_to_stl(full_meshdir) # Convert DAE to STL in the origin directory
177
+
178
+ # Walk through all files in the directory
179
+ for root, _, files in os.walk(full_meshdir):
180
+ for filename in files:
181
+ if filename.lower().endswith('.stl'):
182
+ relative_dir = os.path.relpath(root, meshdir) # Extract subdir name
183
+ mesh_name = f"{os.path.splitext(filename)[0]}_{relative_dir.replace(os.sep, '_')}" # Format: base_visual
184
+ mesh_file_relative = os.path.join(relative_dir, filename)
185
+ ET.SubElement(asset_tag, "mesh", name=mesh_name, file=mesh_file_relative)
186
+
187
+ robot.insert(0, mujoco_tag)
188
+ self.xml = ET.tostring(robot, encoding="unicode").replace('.dae', '.stl')
189
+ self._model = mujoco.MjModel.from_xml_string(self.xml)
190
+ except Exception as e:
191
+ raise ValueError(f"Failed to process URDF file: {e}")
192
+
193
+ def _load_xml_string(self, xml: str) -> None:
194
+ """Load a MuJoCo model from an XML string."""
195
+ try:
196
+ self._model = mujoco.MjModel.from_xml_string(xml)
197
+ self.xml = xml
198
+ except Exception as e:
199
+ raise ValueError(f"Failed to load the MuJoCo model from XML string: {e}") from e
200
+
201
+ def __str__(self):
202
+ return self._model.__str__()
203
+
204
+ def __repr__(self):
205
+ return (
206
+ f"{self.__class__.__name__}(\n"
207
+ f" Duration: {self.duration} [fps={self.fps}, ts={self.ts:.0e}]\n"
208
+ f" Gravity: {self.gravity},\n"
209
+ f" Resolution: {self._width}x{self._height}\n"
210
+ f" Bodies ({self.model.nbody}): {', '.join(self._body_names[:5])}{' ...' if len(self._body_names) > 5 else ''}\n"
211
+ f" Joints ({self.model.njnt}): {', '.join(self._joint_names[:5])}{' ...' if len(self._joint_names) > 5 else ''}\n"
212
+ f" Actuators ({self.model.nu}): {', '.join(self._actuator_names[:5])}{' ...' if len(self._actuator_names) > 5 else ''}\n"
213
+ f" Controller: {self.controller.__name__ if self.controller else None}\n" # Returns str name of the function
214
+ f")"
215
+ )
216
+
217
+ def __enter__(self):
218
+ return self
219
+
220
+ def __exit__(self, exc_type, exc_value, traceback):
221
+ mujoco.set_mjcb_control(None)
222
+ return
223
+
224
+ @property
225
+ def model(self) -> mujoco.MjModel:
226
+ """Read-only property to access the MjModel object."""
227
+ return self._model
228
+
229
+ @property
230
+ def data(self) -> mujoco.MjData:
231
+ """Read-only property to access the MjData single-step object."""
232
+ return self._data
233
+
234
+ @property
235
+ def captured_data(self) -> Dict[str, np.ndarray]:
236
+ """Read-only property to access the entire captured simulation data."""
237
+ if self._captured_data is None:
238
+ raise ValueError("No simulation data captured yet.")
239
+ return self._captured_data.unwrap()
240
+
241
+ @captured_data.deleter
242
+ def captured_data(self):
243
+ self._captured_data = None
244
+
245
+ @property
246
+ def frames(self) -> List[np.ndarray]:
247
+ """Read-only property to access the captured frames."""
248
+ if self._frames is None:
249
+ raise ValueError("No frames captured yet.")
250
+ return self._frames
251
+
252
+ @frames.deleter
253
+ def frames(self):
254
+ self._frames.clear()
255
+ import gc
256
+
257
+ gc.collect()
258
+
259
+ @property
260
+ def duration(self) -> float:
261
+ return self._duration
262
+
263
+ @duration.setter
264
+ def duration(self, value):
265
+ if value < 0:
266
+ raise ValueError("Duration must be greater than zero.")
267
+ self._duration = value
268
+
269
+ @property
270
+ def fps(self) -> float:
271
+ return self._fps
272
+
273
+ @fps.setter
274
+ def fps(self, value):
275
+ if value < 0:
276
+ raise ValueError("FPS must be greater than zero.")
277
+ self._fps = value
278
+
279
+ @property
280
+ def resolution(self) -> Tuple[int, int]:
281
+ return self._resolution
282
+
283
+ @resolution.setter
284
+ def resolution(self, values):
285
+ if len(values) != 2:
286
+ raise ValueError("Resolution must be a tuple of width and height.")
287
+ if values[0] < 1 or values[1] < 1:
288
+ raise ValueError("Resolution must be at least 1x1 pixels.")
289
+
290
+ try:
291
+ monitor = get_monitors()[0]
292
+ screen_width, screen_height = monitor.width, monitor.height
293
+ except Exception as e:
294
+ screen_width, screen_height = 1920, 1080
295
+ print_warning(
296
+ f"Failed to get screen resolution: {e}. Defaulting to 1920x1080."
297
+ )
298
+
299
+ if values[0] > screen_width or values[1] > screen_height:
300
+ raise ValueError("Resolution must be less than the screen resolution.")
301
+
302
+ self._resolution = tuple(int(value) for value in values)
303
+ self._width, self._height = self._resolution
304
+ # Match changes to the model's visual settings
305
+ self._model.vis.Global.offwidth = self._width
306
+ self._model.vis.Global.offheight = self._height
307
+
308
+ @property
309
+ def init_conditions(self):
310
+ return self._initcond
311
+
312
+ @init_conditions.setter
313
+ def init_conditions(self, values):
314
+ if not isinstance(values, dict):
315
+ raise ValueError("Initial conditions must be a dictionary.")
316
+ invalid_keys = [
317
+ key for key in values.keys() if not hasattr(mujoco.MjData(self._model), key)
318
+ ]
319
+ if invalid_keys:
320
+ valid_keys = SimulationData._get_public_keys(self._data)
321
+ print(f"Valid initial condition attributes: {', '.join(valid_keys)}")
322
+ raise ValueError(
323
+ f"Invalid initial condition attributes: {', '.join(invalid_keys)}"
324
+ )
325
+ self._initcond = values
326
+
327
+ @property
328
+ def controller(self) -> Optional[Callable[[mjModel, mjData, Any], None]]:
329
+ """Controller Function"""
330
+ return self._controller
331
+
332
+ @controller.setter
333
+ def controller(self, func: Callable[[mjModel, mjData, Any], None]) -> None:
334
+ if func is not None and not callable(func):
335
+ raise ValueError("Controller must be a callable function.")
336
+ self._controller = func
337
+
338
+ @property
339
+ def ts(self) -> float:
340
+ return self._model.opt.timestep
341
+
342
+ @ts.setter
343
+ def ts(self, value) -> None:
344
+ if value <= 0:
345
+ raise ValueError("Timestep must be greater than 0.")
346
+ self._model.opt.timestep = value
347
+
348
+ @property
349
+ def data_rate(self) -> int:
350
+ if self._dr is None:
351
+ raise ValueError(
352
+ f"Use '{self.runSim.__name__}' first in order to access this value."
353
+ )
354
+ return self._dr
355
+
356
+ @data_rate.setter
357
+ def data_rate(self, value) -> None:
358
+ if value.is_numeric() and not isinstance(value, int):
359
+ value = round(value)
360
+ print_warning(f"Data rate must be an integer. Rounding to the nearest integer ({value}).")
361
+ if value <= 0:
362
+ raise ValueError("Data rate must be greater than 0.")
363
+ # TODO: Check the math on this validation
364
+ # max_ = int(self._duration / self.ts)
365
+ # if value > max_:
366
+ # print_warning(f"Data rate exceeds the simulation steps. Setting to the maximum possible value ({max_}).")
367
+ # value = max_
368
+ self._dr = value
369
+
370
+ @property
371
+ def gravity(self):
372
+ return self._model.opt.gravity
373
+
374
+ @gravity.setter
375
+ def gravity(self, values):
376
+ if len(values) != 3:
377
+ raise ValueError("Gravity must be a 3D vector.")
378
+ self._model.opt.gravity = values
379
+
380
+ def _setInitialConditions(self):
381
+ for key, value in self._initcond.items():
382
+ if hasattr(self._data, key):
383
+ setattr(self._data, key, value)
384
+ else:
385
+ print_warning(f"'{key}' is not a valid attribute of MjData.")
386
+
387
+ def _resetSimulation(self):
388
+ mujoco.mj_resetData(self._model, self._data)
389
+ self._setInitialConditions()
390
+ self._frames = []
391
+ self._captured_data = SimulationData()
392
+
393
+ @timer
394
+ def runSim(self, render=False, camera=None, data_rate=100, multi_thread=False):
395
+ """Run the simulation with optional rendering and controlled data capture.
396
+
397
+ Args:
398
+ render (bool): If True, renders the simulation.
399
+ camera (str): The camera view to render from, defaults to None.
400
+ data_rate (int): How often to capture data, expressed as frames per second.
401
+
402
+ Returns:
403
+ self: The current Wrapper object for method chaining.
404
+ """
405
+ try:
406
+ mujoco.set_mjcb_control(self._controller) if self._controller else None
407
+ self._resetSimulation()
408
+ total_steps = int(self._duration / self.ts)
409
+
410
+ # Cache frequently used functions and objects for performance
411
+ mj_step = mujoco.mj_step
412
+ m = self._model
413
+ d = self._data
414
+
415
+ self._dr = data_rate
416
+ capture_rate = data_rate * self.ts
417
+ capture_interval = max(1, int(1.0 / capture_rate))
418
+ render_interval = max(1, int(1.0 / (self._fps * self.ts)))
419
+
420
+ import __main__ as main
421
+
422
+ if hasattr(main, "__file__"):
423
+ PBar = barTerminal
424
+ else:
425
+ PBar = barNotebook
426
+
427
+ if multi_thread:
428
+ # num_threads = os.cpu_count()
429
+ # TODO: Implement multi-threading
430
+ print(
431
+ "Multi-threading not yet implemented. Running simulation in single-thread mode."
432
+ )
433
+
434
+ with (
435
+ PBar(
436
+ total=total_steps, desc="Simulation", unit=" step", leave=False
437
+ ) as pbar,
438
+ mujoco.Renderer(self._model, self._height, self._width) as renderer,
439
+ ):
440
+ step = 0
441
+ while d.time < self._duration:
442
+ mj_step(m, d)
443
+
444
+ # Capture data at the specified rate
445
+ if step % capture_interval == 0:
446
+ self._captured_data.capture(d)
447
+
448
+ if render and step % render_interval == 0:
449
+ (
450
+ renderer.update_scene(d)
451
+ if camera is None
452
+ else renderer.update_scene(d, camera)
453
+ )
454
+ self._frames.append(renderer.render().copy())
455
+
456
+ pbar.update(1)
457
+ step += 1
458
+
459
+ # if verbose:
460
+ # for warning in mujoco.mjtWarning:
461
+ # if d.warning[warning].number > 0:
462
+ # print_warning(f"{warning.name} - {d.warning[warning].number} occurrences")
463
+ # else:
464
+ # if any(d.warning[warning].number > 0 for warning in mujoco.mjtWarning):
465
+ # print_warning("Please check MUJOCO_LOG.txt for more details.")
466
+
467
+ except Exception as e:
468
+ print(f"Simulation error: {e}")
469
+ finally:
470
+ mujoco.set_mjcb_control(None)
471
+
472
+ return self
473
+
474
+ def renderFrame(self, t=0, frame=0, title=None) -> Optional[str]:
475
+ """Render a specific frame as an image.
476
+
477
+ Args:
478
+ t (float): Time in seconds for which the frame should be rendered.
479
+ frame (int): The frame index to render.
480
+ title (str): The title of the rendered frame.
481
+
482
+ Returns:
483
+ None
484
+ """
485
+ if not self._frames:
486
+ raise ValueError("No frames captured to render.")
487
+
488
+ if t < 0 or frame < 0:
489
+ raise ValueError(
490
+ "Time and frame index must be greater than or equal to zero."
491
+ )
492
+
493
+ if frame and t:
494
+ raise ValueError("Can only specify singular time or frame parameter")
495
+
496
+ try:
497
+ if t > 0:
498
+ frame = self.t2f(t) # Convert time to frame index
499
+ else:
500
+ frame = int(frame)
501
+
502
+ plt.imshow(self._frames[frame])
503
+ plt.axis("off")
504
+ plt.title(title or f"Frame {frame}", loc="center")
505
+ plt.show()
506
+
507
+ except IndexError as e:
508
+ raise ValueError(f"Invalid frame index: {e}")
509
+ except TypeError as e:
510
+ raise ValueError(f"Invalid type for time or frame: {e}")
511
+ except Exception as e:
512
+ raise RuntimeError(f"Unexpected error while rendering frame: {e}")
513
+
514
+ def renderMedia(self, codec="gif", title=None, save=False) -> media:
515
+ """Render the simulation as a video or GIF, with an option to save to a file.
516
+
517
+ Args:
518
+ codec (str): The media format to use ("gif" or "mp4").
519
+ title (str): The filename or window title for the media.
520
+ save (bool): Whether to save the media to a file.
521
+ """
522
+ if not self._frames:
523
+ raise ValueError("No frames captured to create media.")
524
+ # Display the media in a window
525
+ if not save:
526
+ media.show_video(
527
+ self._frames,
528
+ fps=self._fps,
529
+ width=self._width,
530
+ height=self._height,
531
+ codec=codec,
532
+ title=title,
533
+ )
534
+ return
535
+
536
+ if not title.endswith(f".{codec}"):
537
+ title += f".{codec}"
538
+
539
+ # Save the frames to the specified file
540
+ available_codecs = ["gif", "mp4", "h264", "hevc", "vp9"]
541
+ if codec in available_codecs:
542
+ media.write_video(
543
+ title if not None else "render",
544
+ self._frames,
545
+ fps=self._fps,
546
+ codec=codec,
547
+ )
548
+ else:
549
+ raise ValueError(
550
+ f"Unsupported codec '{codec}'. Supported codecs are {', '.join(available_codecs)}"
551
+ )
552
+
553
+ path = os.path.abspath(title)
554
+ print(f"Media saved to {path}")
555
+ return path
556
+
557
+ @lru_cache(maxsize=100)
558
+ def t2f(self, t: float) -> int:
559
+ """Convert time to frame index."""
560
+ return min(
561
+ int(t * self._fps), int(self._duration * self._fps) - 1
562
+ ) # Subtract 1 to convert to 0-based index
563
+
564
+ @lru_cache(maxsize=100)
565
+ def f2t(self, frame: int) -> float:
566
+ """Convert frame index to time."""
567
+ return frame / self._fps
568
+
569
+ def getBodyData(self, body_name: str, data_name: str = None) -> np.ndarray:
570
+ """Get the data for a specific body in the simulation.
571
+
572
+ Args:
573
+ body_name (str): The name of the body to retrieve data for.
574
+ data_name (str): The name of the data to retrieve.
575
+
576
+ Returns:
577
+ np.ndarray: The data for the specified body.
578
+ """
579
+ if body_name not in self._body_names:
580
+ raise ValueError(f"Body '{body_name}' not found in the model.")
581
+ else:
582
+ body_id = mujoco.mj_name2id(
583
+ self._model, mujoco.mjtObj.mjOBJ_BODY, body_name
584
+ )
585
+
586
+ if data_name is None:
587
+ return self._captured_data.unwrap()[body_id]
588
+ if data_name not in self._captured_data.unwrap():
589
+ raise ValueError(f"Data '{data_name}' not found for body '{body_name}'.")
590
+ return self._captured_data.unwrap()[body_id][data_name]
591
+
592
+ def getID(self, id: int) -> str:
593
+ """Get the name of a body given its ID.
594
+
595
+ Args:
596
+ id (int): The ID of the body.
597
+
598
+ Returns:
599
+ str: The name of the body.
600
+ """
601
+ if id < 0 or id >= self._model.nbody:
602
+ raise ValueError(f"Invalid body ID: {id}")
603
+ return mujoco.mj_id2name(self._model, mujoco.mjtObj.mjOBJ_BODY, id)
604
+
605
+ def saveYAML(self, name="Model"):
606
+ """Save simulation data to a YAML file.
607
+
608
+ Args:
609
+ name (str): The filename for the YAML file.
610
+
611
+ Returns:
612
+ None
613
+ """
614
+ if not name.endswith(".yml"):
615
+ name += ".yml"
616
+
617
+ try:
618
+ # Convert simData's NumPy arrays or lists to a YAML-friendly format
619
+ serialized_data = {
620
+ k: (v.tolist() if isinstance(v, np.ndarray) else v)
621
+ for k, v in self.captured_data.items()
622
+ }
623
+
624
+ with open(name, "w") as f:
625
+ yaml.dump(serialized_data, f, default_flow_style=False)
626
+
627
+ print(f"Simulation data saved to {name}")
628
+ except Exception as e:
629
+ print(f"Failed to save data to YAML: {e}")
630
+
631
+
632
+ class SimulationData(object):
633
+ """A class to store and manage simulation data."""
634
+
635
+ __slots__ = ['_d']
636
+
637
+ def __init__(self):
638
+ self._d = defaultdict(list)
639
+
640
+ def capture(self, mj_data: mujoco.MjData):
641
+ """Capture data from MjData object, storing specified or all public simulation data."""
642
+ from . import CAPTURE_PARAMETERS, VERBOSITY
643
+ keys = self._get_public_keys(mj_data) if CAPTURE_PARAMETERS == "all" else CAPTURE_PARAMETERS
644
+
645
+ for key in keys:
646
+ try:
647
+ value = getattr(mj_data, key)
648
+ # Check if value is a numpy array and copy if needed
649
+ if isinstance(value, np.ndarray):
650
+ self._d[key].append(value.copy())
651
+ elif np.isscalar(value):
652
+ self._d[key].append(value)
653
+ elif hasattr(value, "copy") and callable(value.copy):
654
+ # Copy if it has a copy method (e.g., MuJoCo's MjArray)
655
+ self._d[key].append(value.copy())
656
+ else:
657
+ self._d[key].append(value)
658
+ except AttributeError:
659
+ print_warning(f"Key '{key}' not found in MjData. Skipping.") if VERBOSITY else None
660
+ except Exception as e:
661
+ print(f"An error occurred while capturing '{key}': {e}") if VERBOSITY else None
662
+
663
+ def unwrap(self) -> Dict[str, np.ndarray]:
664
+ """Unwrap the captured simulation data into a structured format with NumPy arrays."""
665
+ unwrapped_data = {}
666
+
667
+ for key, value_list in self._d.items():
668
+ if not value_list: # Skip empty lists
669
+ unwrapped_data[key] = np.array([])
670
+ continue
671
+
672
+ try:
673
+ # Check if all items in the list have the same shape for array data
674
+ if isinstance(value_list[0], np.ndarray):
675
+ # Check if arrays have consistent shapes
676
+ shapes = [arr.shape for arr in value_list]
677
+ if all(shape == shapes[0] for shape in shapes):
678
+ unwrapped_data[key] = np.stack(value_list)
679
+ else:
680
+ # For arrays with different shapes, keep as a list
681
+ unwrapped_data[key] = value_list
682
+ else:
683
+ # Convert to a NumPy array if it's a list of scalars
684
+ unwrapped_data[key] = np.array(value_list)
685
+ except ValueError as e:
686
+ print(f"ValueError while unwrapping key '{key}': {e}. Keeping as list.")
687
+ unwrapped_data[key] = value_list # Store as a list if conversion fails
688
+ except Exception as e:
689
+ print(f"Error processing key '{key}': {e}")
690
+ unwrapped_data[key] = value_list
691
+
692
+ return unwrapped_data
693
+
694
+ @property
695
+ def shape(self):
696
+ """TODO: Implement a method to return the shape of the captured data."""
697
+ if not self._d:
698
+ return None
699
+
700
+ self.unwrap() # Ensure data is unwrapped before checking shape
701
+
702
+ for key, value in self._d.items():
703
+ try:
704
+ if isinstance(value, np.ndarray):
705
+ print(f"{key}: {value.shape}")
706
+ elif isinstance(value, list):
707
+ print(f"{key}: {len(value)}")
708
+ elif isinstance(value, dict):
709
+ print(f"{key}: {len(value)}")
710
+ else: # Handle any other types
711
+ print(f"{key}: {type(value)}")
712
+
713
+ except Exception:
714
+ pass
715
+
716
+ def __del__(self):
717
+ self._d.clear()
718
+ import gc
719
+
720
+ gc.collect()
721
+
722
+ def __len__(self):
723
+ if not self._d:
724
+ return 0
725
+ # Return the length of one of the data lists (assuming all have same length)
726
+ return len(next(iter(self._d.values())))
727
+
728
+ def __str__(self):
729
+ return f"{self.__class__.__name__}({len(self)} Step(s) Captured)"
730
+
731
+ def __repr__(self):
732
+ return self.__str__()
733
+
734
+ @staticmethod
735
+ def _get_public_keys(obj):
736
+ """Get all public keys of an object."""
737
+ return [
738
+ name
739
+ for name in dir(obj)
740
+ if not name.startswith("_") and not callable(getattr(obj, name))
741
+ ]
@@ -0,0 +1,67 @@
1
+ """
2
+ Mujoco Toolbox
3
+ ==============
4
+ A toolbox for working with MuJoCo simulations. This package provides various utilities and controllers to facilitate
5
+ the simulation process.
6
+
7
+ Modules:
8
+ --------
9
+ - Wrapper: Contains the Wrapper class for interfacing with MuJoCo.
10
+ - Controller: Includes sineController, cosineController, and randomController for controlling simulations.
11
+
12
+ Constants:
13
+ ----------
14
+ - CAPTURE_PARAMETERS: List of MjData fields to capture during simulation.
15
+
16
+ Attributes:
17
+ -----------
18
+ - __version__ (str): The current version of the package.
19
+ - __author__ (str): The author of the package.
20
+ - __all__ (list): List of public objects of the module, as interpreted by `from module import *`.
21
+
22
+ Notes:
23
+ ------
24
+ This package is still under development. Report any issues to https://github.com/MGross21/mujoco-toolbox/issues.
25
+ """
26
+
27
+ from .Controller import (cosineController, randomController, sineController,
28
+ stepController)
29
+ from .Utils import print_warning
30
+ from .Wrapper import Wrapper
31
+
32
+ __version__ = "0.1.8"
33
+ __author__ = "Michael Gross"
34
+ __github_repo__ = "mujoco-toolbox"
35
+ __license__ = "MIT"
36
+ __status__ = "Development"
37
+
38
+ # `from mujoco_toolbox import *` will import these objects
39
+ __all__ = [
40
+ "Wrapper",
41
+ "sineController",
42
+ "cosineController",
43
+ "randomController",
44
+ "stepController",
45
+ "timer",
46
+ ]
47
+
48
+ CAPTURE_PARAMETERS = [
49
+ "time",
50
+ "qpos",
51
+ "qvel",
52
+ "act",
53
+ "qacc",
54
+ "xpos",
55
+ "xquat",
56
+ "xmat",
57
+ "ctrl",
58
+ "sensordata",
59
+ ] # MjData default fields to capture during simulation
60
+
61
+ VERBOSITY = False
62
+
63
+ if True:
64
+ print_warning(
65
+ f"{__package__} ({__version__}) is still under development.",
66
+ f"Report any issues to https://github.com/MGross21/{__github_repo__}/issues\n",
67
+ )
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Michael Gross
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.3
2
+ Name: mujoco-toolbox
3
+ Version: 0.1.8
4
+ Summary: A modern package to handle MuJoCo environments
5
+ License: MIT
6
+ Keywords: MuJoCo,robotics,simulation,toolbox,wrapper
7
+ Author: MGross21
8
+ Author-email: MGross21@TODO.com
9
+ Requires-Python: >=3.10
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Provides-Extra: dev
16
+ Requires-Dist: ffmpeg
17
+ Requires-Dist: ipywidgets
18
+ Requires-Dist: matplotlib
19
+ Requires-Dist: mediapy
20
+ Requires-Dist: mujoco
21
+ Requires-Dist: mypy ; extra == "dev"
22
+ Requires-Dist: numpy
23
+ Requires-Dist: pytest ; extra == "dev"
24
+ Requires-Dist: pyyaml
25
+ Requires-Dist: ruff ; extra == "dev"
26
+ Requires-Dist: scipy
27
+ Requires-Dist: screeninfo
28
+ Requires-Dist: tqdm
29
+ Requires-Dist: trimesh
30
+ Project-URL: Documentation, https://github.com/MGross21/mujoco-toolbox
31
+ Project-URL: Homepage, https://github.com/MGross21/mujoco-toolbox
32
+ Project-URL: Repository, https://github.com/MGross21/mujoco-toolbox
33
+ Project-URL: bug-tracker, https://github.com/MGross21/mujoco-toolbox/issues
34
+ Description-Content-Type: text/markdown
35
+
36
+ ![Build](https://github.com/MGross21/mujoco-toolbox/actions/workflows/ci.yml/badge.svg)
37
+ ![Python](https://img.shields.io/badge/python-3.10%20|%203.11%20|%203.12%20|%203.13-blue)
38
+ ![License](https://img.shields.io/github/license/MGross21/mujoco-toolbox)
39
+ [![PyPI](https://github.com/MGross21/mujoco-toolbox/actions/workflows/publish.yml/badge.svg)](https://github.com/MGross21/mujoco-toolbox/actions/workflows/publish.yml)
40
+
41
+ # Mujoco Toolbox
42
+
43
+ Streamlines the MuJoCo Physics Simulator
44
+
45
+ ## PyPI Package
46
+
47
+ ```bash
48
+ pip install -U mujoco-toolbox
49
+ ```
50
+
51
+ ## For Local Install
52
+
53
+ *Navigate to the Directory that you wish to place the package*
54
+
55
+ <details>
56
+ <summary><b>JUPYTER NOTEBOOK</b></summary>
57
+ </br>
58
+ *Place Commands Inside Cell*
59
+
60
+ One-Time Install:
61
+
62
+ ```python
63
+ !git clone https://github.com/MGross21/mujoco-toolbox
64
+ !pip install -U ./mujoco-toolbox
65
+ ```
66
+
67
+ Check for Updates:
68
+
69
+ ```python
70
+ !git pull origin main
71
+ ```
72
+
73
+ Install/Check for Updates:
74
+
75
+ ```python
76
+ folder = "mujoco-toolbox"
77
+ repo_url = f"https://github.com/MGross21/{folder}"
78
+
79
+ !if [ -d "{folder}" ]; then cd {folder} && git pull && cd ..; else git clone {repo_url}; fi
80
+ !pip install -U ./{folder}
81
+ ```
82
+
83
+ </details>
84
+ </br>
85
+
86
+ <details>
87
+ <summary><b>BASH WINDOW</b></summary>
88
+ </br>
89
+ One-Time Install:
90
+
91
+ ```bash
92
+ git clone https://github.com/MGross21/mujoco-toolbox
93
+ pip install -U ./mujoco-toolbox
94
+ ```
95
+
96
+ Check for Updates:
97
+ ```bash
98
+ git pull origin main
99
+ ```
100
+
101
+ Install/Check for Updates:
102
+
103
+ ```bash
104
+ REPO_DIR="mujoco-toolbox"
105
+ REPO_URL="https://github.com/MGross21/mujoco-toolbox"
106
+
107
+ if [ -d "$REPO_DIR" ]; then
108
+ cd "$REPO_DIR" && git fetch origin && git pull origin main
109
+ else
110
+ git clone $REPO_URL
111
+ cd "$REPO_DIR"
112
+ fi
113
+
114
+ pip install -U ./mujoco-toolbox
115
+ ```
116
+
117
+ </details>
118
+
119
+ ## Extra Packages
120
+
121
+ <details>
122
+ <summary><b>FFMPEG</b></summary>
123
+
124
+ </br>
125
+
126
+ *Required for [mediapy](https://google.github.io/mediapy/mediapy.html) dependency*
127
+
128
+ **Windows**
129
+
130
+ ```bash
131
+ winget install ffmpeg
132
+ ffmpeg -version
133
+ ```
134
+
135
+ **Linux**
136
+
137
+ ```bash
138
+ sudo apt update && sudo apt install ffmpeg
139
+ ffmpeg -version
140
+ ```
141
+
142
+ **MacOS**
143
+
144
+ *Using Homebrew*
145
+
146
+ ```bash
147
+ brew install ffmpeg
148
+ ffmpeg -version
149
+ ```
150
+
151
+ *Using MacPorts*
152
+
153
+ ```bash
154
+ sudo port install ffmpeg
155
+ ffmpeg -version
156
+ ```
157
+
158
+ </details>
159
+
160
+ ## To Run in Scripts
161
+
162
+ ```python
163
+ import mujoco_toolbox as mjtb
164
+ from mujoco_toolbox import Wrapper
165
+
166
+ # Optional Global Variables
167
+ mjtb.VERBOSITY = True # Additional Performance and Debugging Print Statements (Default=False)
168
+ mjtb.CAPTURE_PARAMETERS = "all" # MjData Simulation Parameters (Default = ['time', 'qpos', 'qvel', 'act', 'qacc', 'xpos', 'xquat', 'xmat', 'ctrl', 'sensordata'])
169
+
170
+ # Example Code
171
+ Wrapper("path/to/your/xml").runSim(render=True).renderMedia()
172
+ ```
173
+
174
+ ## Pre-Made Controllers
175
+
176
+ ```python
177
+ import mujoco_toolbox as mjtb
178
+
179
+ mjtb.sineController(m,d,**kwargs)
180
+ mjtb.cosineController(m,d,**kwargs)
181
+ mjtb.stepController(m,d,**kwargs)
182
+ mjtb.randomController(m,d,**kwargs)
183
+
184
+ # Wrapper can use custom controllers as well!
185
+ ```
186
+
@@ -0,0 +1,8 @@
1
+ mujoco_toolbox/__init__.py,sha256=5F4JYO_gNSHdvBeu3F8cIr5V_3SeCKmtoMFZOcNswAY,1818
2
+ mujoco_toolbox/Controller.py,sha256=-q4qt43IWw0wz7TT4FnL6omLX-W7166rAdltTWSOflw,4659
3
+ mujoco_toolbox/Utils.py,sha256=eDGJ973ItipwA-qgrWpKTEJke1-Xx4oc3asCXQp25kc,1900
4
+ mujoco_toolbox/Wrapper.py,sha256=UmSq9vzaI96C2gBXcbshMNviS4y3_a7he168WRpQOwc,29050
5
+ mujoco_toolbox-0.1.8.dist-info/LICENSE,sha256=Fh6x3ZPSwg6wLoA0jW1U0U4cM5tAFm2hi1JMypVc1V8,1089
6
+ mujoco_toolbox-0.1.8.dist-info/METADATA,sha256=pyNDcZtroQGNrOr_-v8gtUryVg37LIy8HQgAs1yDsM8,4071
7
+ mujoco_toolbox-0.1.8.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
8
+ mujoco_toolbox-0.1.8.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any