mujoco-toolbox 0.3.4__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,893 @@
1
+ import os
2
+ import sys
3
+ import threading
4
+ import time
5
+ import xml.etree.ElementTree as ET
6
+ from collections import defaultdict
7
+ from collections.abc import Callable
8
+ from functools import lru_cache
9
+ from typing import Any, TypeAlias
10
+
11
+ import mediapy as media
12
+ import mujoco
13
+ import mujoco.viewer
14
+ import numpy as np
15
+ import trimesh
16
+ import yaml
17
+
18
+ from .utils import _print_warning
19
+
20
+ assert sys.version_info >= (3, 10), "This code requires Python 3.10.0 or later."
21
+ assert mujoco.__version__ >= "2.0.0", "This code requires MuJoCo 2.0.0 or later."
22
+
23
+ mjModel: TypeAlias = mujoco.MjModel
24
+ mjData: TypeAlias = mujoco.MjData
25
+
26
+
27
+ class Wrapper:
28
+ """A class to handle MuJoCo simulations."""
29
+
30
+ def __init__(self,
31
+ xml:str,
32
+ duration:int=10,
33
+ data_rate:int=100,
34
+ fps:int=30,
35
+ resolution:tuple[int,int]=(400,300),
36
+ initial_conditions:dict[str, list] | None=None,
37
+ controller:Callable[[mjModel, mjData, Any], None] | None=None,
38
+ *args,
39
+ **kwargs) -> None:
40
+ # xml = "<mujoco></mujoco>" if xml.strip() == "<mujoco/>" else xml
41
+ self._load_model(xml, **kwargs)
42
+
43
+ self.duration = duration
44
+ self.fps = fps
45
+ self.data_rate = data_rate
46
+ self.resolution = resolution # recursively sets width and height
47
+ self.controller = controller
48
+
49
+ # Predefined simulation parameters but can be overridden
50
+ # TODO: Currently Causing Bugs when occluded from XML Code
51
+ self.ts = kwargs.get("ts", self._model.opt.timestep)
52
+ self.gravity = kwargs.get("gravity", self._model.opt.gravity)
53
+
54
+ self._data = mujoco.MjData(self._model)
55
+ self.init_conditions = initial_conditions or {} # MUST BE AFTER DATA INITIALIZATION
56
+
57
+ # Auto-Populate the names of bodies, joints, and actuators
58
+ self._body_names = [self._model.body(i).name for i in range(self._model.nbody)]
59
+ self._geom_names = [self._model.geom(i).name for i in range(self._model.ngeom)]
60
+ self._joint_names = [self._model.joint(i).name for i in range(self._model.njnt)]
61
+ self._actuator_names = [
62
+ self._model.actuator(i).name for i in range(self._model.nu)
63
+ ]
64
+
65
+ def _load_model(self, xml: str, **kwargs: Any) -> None:
66
+ """Load a MuJoCo model from an XML file or a string."""
67
+ try:
68
+ # Check if the path exists
69
+ if os.path.exists(xml):
70
+ # Convert the XML path to an absolute path
71
+ xml_path = os.path.abspath(xml)
72
+ # Extract and validate file extension
73
+ extension = os.path.splitext(xml_path)[1].lower()[1:]
74
+
75
+ if extension == "xml":
76
+ self._load_xml_file(xml_path)
77
+ elif extension == "urdf":
78
+ self._load_urdf_file(xml_path, **kwargs)
79
+ else:
80
+ msg = f"Unsupported file extension: '{extension}'. Please provide an XML or URDF file."
81
+ raise ValueError(msg)
82
+ else:
83
+ # If the file doesn't exist, assume it's a string and attempt to load it as XML
84
+ try:
85
+ ET.fromstring(xml) # Try parsing the string as XML
86
+ self._load_xml_string(xml)
87
+ except ET.ParseError:
88
+ msg = f"Model not able to be loaded from the provided XML string: {xml}"
89
+ raise ValueError(msg)
90
+ except FileNotFoundError as e:
91
+ msg = f"Failed to load the MuJoCo model: {e}"
92
+ raise FileNotFoundError(msg) from e
93
+ except ValueError as e:
94
+ msg = f"Invalid value encountered while loading the model: {e}"
95
+ raise ValueError(msg) from e
96
+ except Exception as e:
97
+ msg = f"Unexpected error while loading the MuJoCo model: {e}"
98
+ raise Exception(msg)
99
+
100
+ def _load_xml_file(self, xml: str, **kwargs) -> None:
101
+ """Load a MuJoCo model from an XML file."""
102
+ self._model = mujoco.MjModel.from_xml_path(xml)
103
+ with open(xml) as f:
104
+ self.xml = f.read()
105
+ template = kwargs.get("template")
106
+ if template:
107
+ try:
108
+ self.xml = self.xml.format(**template)
109
+ except KeyError:
110
+ msg = "Template key error. Ensure the template keys match the placeholders in the XML."
111
+ raise ValueError(msg)
112
+ except Exception:
113
+ msg = "Error formatting XML with template"
114
+ raise ValueError(msg)
115
+ self._model = mujoco.MjModel.from_xml_string(self.xml)
116
+
117
+ def _load_urdf_file(self, urdf_path: str, **kwargs: Any) -> None:
118
+ """Process and load a URDF file for use with MuJoCo."""
119
+ # BUG: Generate actuators from joints in URDF so the controller can actually work
120
+
121
+ def convert_dae_to_stl(meshdir: str) -> None:
122
+ """Convert all DAE files in a directory (including subdirectories) to STL."""
123
+ if not os.path.exists(meshdir):
124
+ msg = f"Directory not found: {meshdir}"
125
+ raise FileNotFoundError(msg)
126
+ for filename in os.listdir(meshdir):
127
+ if filename.lower().endswith(".dae"):
128
+ dae_path = os.path.join(meshdir, filename)
129
+ stl_path = os.path.splitext(dae_path)[0] + ".stl"
130
+ try:
131
+ trimesh.load_mesh(dae_path).export(stl_path)
132
+ # logger.info(f"Converted: {os.path.basename(dae_path)} -> {os.path.basename(stl_path)}")
133
+ except Exception:
134
+ msg = f"Error converting {filename}"
135
+ raise ValueError(msg)
136
+
137
+ try:
138
+ robot = ET.parse(urdf_path).getroot()
139
+ mujoco_tag = ET.Element("mujoco")
140
+
141
+ # Get main meshdir (parent directory for meshes)
142
+ meshdir = kwargs.get("meshdir", "meshes/") # Default as tuple
143
+ if not os.path.isabs(meshdir):
144
+ urdf_dir = os.path.dirname(os.path.abspath(urdf_path))
145
+ meshdir = os.path.join(urdf_dir, meshdir)
146
+
147
+ # Ensure meshdir exists
148
+ if not os.path.exists(meshdir):
149
+ msg = f"Mesh directory not found: {meshdir}"
150
+ raise FileNotFoundError(msg)
151
+
152
+ # If no explicit subdirs are provided, auto-detect subdirectories with STL files
153
+ subdirs = kwargs.get("meshdir_sub")
154
+ if not subdirs:
155
+ subdirs = [os.path.relpath(root, meshdir) for root, _, files in os.walk(meshdir) if any(f.endswith(".stl") for f in files)]
156
+ # logger.info(f"Auto-detected subdirectories: {subdirs}")
157
+
158
+ # Convert relative subdir paths to absolute based on meshdir
159
+ full_meshdirs = [
160
+ os.path.join(meshdir, subdir) if not os.path.isabs(subdir) else subdir
161
+ for subdir in subdirs
162
+ ]
163
+
164
+ # Add <compiler> tag with the main meshdir (required for MuJoCo)
165
+ compiler_attrs = {
166
+ "meshdir": meshdir, # Use the main meshdir
167
+ "balanceinertia": kwargs.get("balanceinertia", "true"),
168
+ "discardvisual": "false",
169
+ }
170
+ ET.SubElement(mujoco_tag, "compiler", **compiler_attrs)
171
+
172
+ # Create <asset> tag (required for MuJoCo)
173
+ asset_tag = ET.SubElement(mujoco_tag, "asset")
174
+
175
+
176
+ # Process all valid mesh directories
177
+ for full_meshdir in full_meshdirs:
178
+ if os.path.exists(full_meshdir):
179
+ convert_dae_to_stl(full_meshdir) # Convert DAE to STL in the origin directory
180
+
181
+ # Walk through all files in the directory
182
+ for root, _, files in os.walk(full_meshdir):
183
+ for filename in files:
184
+ if filename.lower().endswith(".stl"):
185
+ relative_dir = os.path.relpath(root, meshdir) # Extract subdir name
186
+ mesh_name = f"{os.path.splitext(filename)[0]}_{relative_dir.replace(os.sep, '_')}" # Format: base_visual
187
+ mesh_file_relative = os.path.join(relative_dir, filename)
188
+ ET.SubElement(asset_tag, "mesh", name=mesh_name, file=mesh_file_relative)
189
+
190
+ robot.insert(0, mujoco_tag)
191
+ self.xml = ET.tostring(robot, encoding="unicode").replace(".dae", ".stl")
192
+ self._model = mujoco.MjModel.from_xml_string(self.xml)
193
+ except Exception as e:
194
+ msg = f"Failed to process URDF file: {e}"
195
+ raise ValueError(msg)
196
+
197
+ def _load_xml_string(self, xml: str) -> None:
198
+ """Load a MuJoCo model from an XML string."""
199
+ try:
200
+ self._model = mujoco.MjModel.from_xml_string(xml)
201
+ self.xml = xml
202
+ except Exception as e:
203
+ msg = f"Failed to load the MuJoCo model from XML string: {e}"
204
+ raise ValueError(msg)
205
+
206
+ def reload(self) -> "Wrapper":
207
+ """Reload the model and data objects."""
208
+ # TODO: Move all model loading to Builder Method
209
+ self._model = mujoco.MjModel.from_xml_string(self.xml)
210
+ self._data = mujoco.MjData(self._model)
211
+ return self
212
+
213
+ def __str__(self) -> str:
214
+ return self._model.__str__()
215
+
216
+ def __repr__(self) -> str:
217
+ return (
218
+ f"{self.__class__.__name__}(\n"
219
+ f" Duration: {self.duration}s [fps={self.fps}, ts={self.ts:.0e}]\n"
220
+ f" Gravity: {self.gravity},\n"
221
+ f" Resolution: {self._width}W x {self._height}H\n"
222
+ f" Bodies ({self.model.nbody}): {', '.join(self._body_names[:5])}"
223
+ f"{' ...' if len(self._body_names) > 5 else ''}\n"
224
+ f" Joints ({self.model.njnt}): {', '.join(self._joint_names[:5])}"
225
+ f"{' ...' if len(self._joint_names) > 5 else ''}\n"
226
+ f" Actuators ({self.model.nu}): {', '.join(self._actuator_names[:5])}"
227
+ f"{' ...' if len(self._actuator_names) > 5 else ''}\n"
228
+ f" Controller: {self.controller.__name__ if self.controller else None}\n"
229
+ f")"
230
+ )
231
+
232
+ def __enter__(self):
233
+ return self
234
+
235
+ def __exit__(self, exc_type, exc_value, traceback):
236
+ mujoco.set_mjcb_control(None)
237
+ for thread in threading.enumerate():
238
+ if thread is not threading.main_thread():
239
+ thread.join()
240
+
241
+ @property
242
+ def model(self) -> mjModel:
243
+ """Read-only property to access the MjModel object."""
244
+ return self._model
245
+
246
+ @property
247
+ def data(self) -> mjData:
248
+ """Read-only property to access the MjData single-step object. Use `captured_data` to access the entire simulation data."""
249
+ return self._data
250
+
251
+ @property
252
+ def captured_data(self) -> dict[str, np.ndarray]:
253
+ """Read-only property to access the entire captured simulation data."""
254
+ if self._captured_data is None:
255
+ msg = "No simulation data captured yet."
256
+ raise ValueError(msg)
257
+ return self._captured_data.unwrap()
258
+
259
+ @captured_data.deleter
260
+ def captured_data(self) -> None:
261
+ self._captured_data = None
262
+
263
+ @property
264
+ def frames(self) -> list[np.ndarray]:
265
+ """Read-only property to access the captured frames."""
266
+ if self._frames is None:
267
+ msg = "No frames captured yet."
268
+ raise ValueError(msg)
269
+ return self._frames
270
+
271
+ @frames.deleter
272
+ def frames(self) -> None:
273
+ # BUG: This is not working as intended
274
+ self._frames.clear()
275
+ import gc
276
+ gc.collect()
277
+
278
+ @property
279
+ def duration(self) -> float:
280
+ """Duration of the simulation in seconds."""
281
+ return self._duration
282
+
283
+ @duration.setter
284
+ def duration(self, value) -> None:
285
+ if value < 0:
286
+ msg = "Duration must be greater than zero."
287
+ raise ValueError(msg)
288
+ self._duration = value
289
+
290
+ @property
291
+ def fps(self) -> float:
292
+ """Frames per second."""
293
+ return self._fps
294
+
295
+ @fps.setter
296
+ def fps(self, value) -> None:
297
+ if value < 0:
298
+ msg = "FPS must be greater than zero."
299
+ raise ValueError(msg)
300
+ self._fps = value
301
+
302
+ @property
303
+ def resolution(self) -> tuple[int, int]:
304
+ """Resolution of the simulation in pixels."""
305
+ return self._resolution
306
+
307
+ @resolution.setter
308
+ def resolution(self, values) -> None:
309
+ if len(values) != 2:
310
+ msg = "Resolution must be a tuple of width and height."
311
+ raise ValueError(msg)
312
+ if values[0] < 1 or values[1] < 1:
313
+ msg = "Resolution must be at least 1x1 pixels."
314
+ raise ValueError(msg)
315
+
316
+ from . import COMPUTER
317
+ screen_width, screen_height = COMPUTER.RESOLUTION
318
+
319
+ if values[0] > screen_width or values[1] > screen_height:
320
+ msg = "Resolution must be less than the screen resolution."
321
+ raise ValueError(msg)
322
+
323
+ self._resolution = tuple(int(value) for value in values)
324
+ self._width, self._height = self._resolution
325
+ # Match changes to the model's visual settings
326
+ self._model.vis.Global.offwidth = self._width
327
+ self._model.vis.Global.offheight = self._height
328
+
329
+ @property
330
+ def initial_conditions(self):
331
+ """Initial conditions for the simulation."""
332
+ return self._initcond
333
+
334
+ @initial_conditions.setter
335
+ def initial_conditions(self, values) -> None:
336
+ if not isinstance(values, dict):
337
+ msg = "Initial conditions must be a dictionary."
338
+ raise ValueError(msg)
339
+
340
+ invalid_keys = [key for key in values if not hasattr(mujoco.MjData(self._model), key)]
341
+ if invalid_keys:
342
+ _SimulationData._get_public_keys(self._data)
343
+ msg = f"Invalid initial condition attributes: {', '.join(invalid_keys)}"
344
+ raise ValueError(msg)
345
+
346
+ self._initcond = values
347
+
348
+ # Apply initial conditions directly when set
349
+ for key, value in values.items():
350
+ if hasattr(self._data, key):
351
+ setattr(self._data, key, value)
352
+ else:
353
+ _print_warning(f"'{key}' is not a valid attribute of MjData.")
354
+
355
+ @property
356
+ def controller(self) -> Callable[[mjModel, mjData, Any], None] | None:
357
+ """Controller Function."""
358
+ return self._controller
359
+
360
+ @controller.setter
361
+ def controller(self, func: Callable[[mjModel, mjData, Any], None]) -> None:
362
+ if func is not None and not callable(func):
363
+ msg = "Controller must be a callable function."
364
+ raise ValueError(msg)
365
+ self._controller = func
366
+
367
+ @property
368
+ def ts(self) -> float:
369
+ """Timestep of the simulation in seconds."""
370
+ return self._model.opt.timestep
371
+
372
+ @ts.setter
373
+ def ts(self, value) -> None:
374
+ if value <= 0:
375
+ msg = "Timestep must be greater than 0."
376
+ raise ValueError(msg)
377
+ self._model.opt.timestep = value
378
+
379
+ @property
380
+ def data_rate(self) -> int:
381
+ """Data rate of the simulation in frames per second."""
382
+ try:
383
+ return self._dr
384
+ except AttributeError:
385
+ _print_warning(f"Use '{self.run.__name__}' first in order to access this value.")
386
+ return None
387
+
388
+ @data_rate.setter
389
+ def data_rate(self, value: int) -> None:
390
+ if isinstance(value, float) and not isinstance(value, int):
391
+ value = round(value)
392
+ _print_warning(f"Data rate must be an integer. Rounding to the nearest integer ({value}).")
393
+ if value <= 0:
394
+ msg = "Data rate must be greater than 0."
395
+ raise ValueError(msg)
396
+ # TODO: Check the math on this validation
397
+ # max_ = int(self._duration / self.ts)
398
+ # if value > max_:
399
+ # print_warning(f"Data rate exceeds the simulation steps. Setting to the maximum possible value ({max_}).")
400
+ # value = max_
401
+ self._dr = value
402
+
403
+ @property
404
+ def gravity(self):
405
+ """Gravity vector of the simulation."""
406
+ return self._model.opt.gravity
407
+
408
+ @gravity.setter
409
+ def gravity(self, values: list | tuple | np.ndarray) -> None:
410
+ if not isinstance(values, (list, tuple, np.ndarray)) or len(values) != 3:
411
+ msg = "Gravity must be a 3D vector."
412
+ raise ValueError(msg)
413
+ self._model.opt.gravity = np.array(values)
414
+
415
+ def run(self,
416
+ render: bool = False,
417
+ camera: str | None = None,
418
+ interactive: bool = False,
419
+ multi_thread: bool = False) -> "Wrapper":
420
+ """Run the simulation with optional rendering and controlled data capture.
421
+
422
+ Args:
423
+ render (bool): If True, renders the simulation.
424
+ camera (str): The camera view to render from, defaults to None.
425
+ data_rate (int): How often to capture data, expressed as frames per second.
426
+ interactive (bool): If True, opens an interactive viewer window. (not implemented yet)
427
+ multi_thread (bool): If True, runs the simulation in multi-threaded mode. (not implemented yet)
428
+
429
+ Returns:
430
+ self: The current Wrapper object for method chaining.
431
+
432
+ """
433
+ # TODO: Integrate interactive mujoco.viewer into this method
434
+ # Eventually rename this to run() and point to sub-methods for different modes
435
+ try:
436
+ mujoco.set_mjcb_control(self._controller) if self._controller else None
437
+ mujoco.mj_resetData(self._model, self._data)
438
+
439
+ # self._frames = [None] * (self.duration * self.fps + 1)
440
+ frames = []
441
+ sim_data = _SimulationData()
442
+ total_steps = int(self._duration / self.ts)
443
+
444
+ # Cache frequently used functions and objects for performance
445
+ mj_step = mujoco.mj_step
446
+ m = self._model
447
+ d = self._data
448
+ dur = self._duration
449
+
450
+ capture_rate = self.data_rate * self.ts
451
+ capture_interval = max(1, int(1.0 / capture_rate))
452
+ render_interval = max(1, int(1.0 / (self._fps * self.ts)))
453
+
454
+ # TODO: Pre-allocate frame length
455
+ # frame_count = 0
456
+
457
+ if multi_thread:
458
+ # num_threads = COMPUTER.CPU_COUNT
459
+ # TODO: Implement multi-threading
460
+ pass
461
+
462
+ from . import COMPUTER, MAX_GEOM_SCALAR, PROGRESS_BAR
463
+ from .utils import _EmptyContextManager
464
+
465
+ if PROGRESS_BAR and render:
466
+ if COMPUTER.IDE == "jupyter":
467
+ from tqdm.notebook import tqdm as ProgressBar
468
+ else:
469
+ from tqdm import tqdm as ProgressBar
470
+ else:
471
+ ProgressBar = _EmptyContextManager
472
+
473
+ # PEP Convention - Prevent Naming Conflict
474
+ _ProgressBar = ProgressBar(total=total_steps, desc="Simulation", unit=" step", leave=False)
475
+ _Renderer = mujoco.Renderer(self.model, self._height, self._width, self._geom_names.__len__() * MAX_GEOM_SCALAR)
476
+
477
+ with _ProgressBar as pbar, _Renderer as renderer:
478
+ step = 0
479
+ while d.time < dur:
480
+ mj_step(m, d)
481
+
482
+ # Capture data at the specified rate
483
+ if step % capture_interval == 0:
484
+ sim_data.capture(d)
485
+
486
+ if render and step % render_interval == 0:
487
+ if camera is None:
488
+ renderer.update_scene(d)
489
+ else:
490
+ renderer.update_scene(d, camera)
491
+ # self._frames[frame_count] = renderer.render().copy() # BUG: simulation attempts to write more frames than allocated
492
+ # frame_count += 1
493
+ frames.append(renderer.render().copy())
494
+
495
+ pbar.update(1) if PROGRESS_BAR else None
496
+ step += 1
497
+
498
+ # if verbose:
499
+ # for warning in mujoco.mjtWarning:
500
+ # if d.warning[warning].number > 0:
501
+ # print_warning(f"{warning.name} - {d.warning[warning].number} occurrences")
502
+ # else:
503
+ # if any(d.warning[warning].number > 0 for warning in mujoco.mjtWarning):
504
+ # print_warning("Please check MUJOCO_LOG.txt for more details.")
505
+
506
+ except Exception as e:
507
+ msg = "An error occurred while running the simulation."
508
+ raise RuntimeError(msg) from e
509
+ finally:
510
+ mujoco.set_mjcb_control(None)
511
+ # Expose local variables
512
+ self._captured_data = sim_data
513
+ self._frames = frames
514
+
515
+ return self
516
+
517
+ def _window(self, show_menu: bool =True) -> None:
518
+ """Open a window to display the simulation in real time."""
519
+ try:
520
+ m = self._model
521
+ d = self._data
522
+
523
+ def key_callback(key) -> bool:
524
+ return key in (27, ord("q")) # 27 = ESC key, 'q' to quit
525
+
526
+ # NOTE: launch_passive may blocking despite docstring saying otherwise
527
+ _Viewer = mujoco.viewer.launch_passive(m, d,
528
+ show_left_ui=show_menu,
529
+ show_right_ui=show_menu,
530
+ key_callback=key_callback)
531
+ with _Viewer as viewer:
532
+ viewer.sync()
533
+ start_time = time.time()
534
+
535
+ try:
536
+ while viewer.is_running():
537
+ current_time = time.time()
538
+ dt = current_time - start_time # Time difference between frames
539
+
540
+ mujoco.mj_step(m, d) # Advance simulation by one step
541
+ viewer.sync() # Sync the viewer
542
+
543
+ start_time = current_time # Reset start_time for the next frame
544
+ time.sleep(max(0, 1.0 / self.fps - dt)) # Adjust sleep to match self.fps
545
+
546
+ except KeyboardInterrupt:
547
+ viewer.close()
548
+ except Exception as e:
549
+ msg = "An error occurred while running the simulation."
550
+ raise RuntimeError(msg) from e
551
+ finally:
552
+ mujoco.set_mjcb_control(None)
553
+
554
+ def liveView(self, show_menu: bool = True) -> None:
555
+ """Open a window to display the simulation in real time."""
556
+ # Run the window in a separate thread
557
+ gui = threading.Thread(target=self._window, kwargs={"show_menu": show_menu})
558
+ gui.start()
559
+
560
+ def _validate_and_extract_frames(
561
+ self,
562
+ frame_idx: int | tuple[int, int] | None = None,
563
+ time_idx: float | tuple[float, float] | None = None,
564
+ ) -> list[Any]:
565
+ """Validate and extract frames based on frame or time indices.
566
+
567
+ Args:
568
+ frame_idx (int or tuple, optional): Single frame index or (start, stop) frame indices.
569
+ time_idx (float or tuple, optional): Single time or (start, end) times in seconds.
570
+
571
+ Returns:
572
+ List of frames
573
+
574
+ Raises:
575
+ ValueError: For invalid input parameters.
576
+
577
+ """
578
+ if not self._frames:
579
+ msg = "No frames captured to render."
580
+ raise ValueError(msg)
581
+
582
+ # Validate input parameters
583
+ if frame_idx is not None and time_idx is not None:
584
+ msg = "Can only specify either frame_idx or time_idx, not both."
585
+ raise ValueError(msg)
586
+
587
+ # If both are None, use all frames
588
+ if frame_idx is None and time_idx is None:
589
+ return self._frames
590
+
591
+ # Handle time index conversion
592
+ if time_idx is not None:
593
+ if isinstance(time_idx, (int, float)):
594
+ frame_idx = self.t2f(time_idx)
595
+ elif isinstance(time_idx, tuple):
596
+ frame_idx = (self.t2f(time_idx[0]), self.t2f(time_idx[1]))
597
+ else:
598
+ msg = "time_idx must be a number or a tuple of numbers."
599
+ raise ValueError(msg)
600
+
601
+ # Convert single index to tuple range
602
+ if isinstance(frame_idx, (int, float)):
603
+ frame_idx = (frame_idx, frame_idx + 1)
604
+
605
+ # Validate frame indices
606
+ start, stop = frame_idx
607
+ max_frames = len(self._frames)
608
+
609
+ if start < 0:
610
+ msg = f"Start index must be non-negative. Got {start}."
611
+ raise ValueError(msg)
612
+ if stop > max_frames:
613
+ msg = f"Stop index must not exceed total frames ({max_frames}). Got {stop}."
614
+ raise ValueError(msg)
615
+ if start >= stop:
616
+ msg = f"Start index ({start}) must be less than stop index ({stop})."
617
+ raise ValueError(msg)
618
+
619
+ # Select subset of frames
620
+ return self._frames[start:stop]
621
+
622
+ def show(
623
+ self,
624
+ title: str | None = None,
625
+ codec: str = "gif",
626
+ frame_idx: int | tuple[int, int] | None = None,
627
+ time_idx: float | tuple[float, float] | None = None,
628
+ ) -> None:
629
+ """Render specific frame(s) as a video or GIF in a window.
630
+
631
+ Args:
632
+ title (str, optional): Title for the rendered media.
633
+ codec (str, optional): Video codec/format. Defaults to "gif".
634
+ frame_idx (int or tuple, optional): Single frame index or (start, stop) frame indices.
635
+ time_idx (float or tuple, optional): Single time or (start, end) times in seconds.
636
+
637
+ Raises:
638
+ ValueError: If no frames are captured or invalid input parameters.
639
+
640
+ """
641
+ try:
642
+ # Extract frames
643
+ subset_frames = self._validate_and_extract_frames(
644
+ frame_idx=frame_idx,
645
+ time_idx=time_idx,
646
+ )
647
+
648
+ # Show the video
649
+ media.show_video(
650
+ subset_frames,
651
+ fps=1 if len(subset_frames) == 1 else self._fps,
652
+ width=self._width,
653
+ height=self._height,
654
+ codec=codec,
655
+ title=title,
656
+ )
657
+ except Exception as e:
658
+ msg = "Error while showing video subset."
659
+ raise Exception(msg) from e
660
+
661
+ def save(
662
+ self,
663
+ title: str = "render",
664
+ codec: str = "gif",
665
+ frame_idx: int | tuple[int, int] | None = None,
666
+ time_idx: float | tuple[float, float] | None = None,
667
+ ) -> str:
668
+ """Save specific frame(s) as a video or GIF to a file.
669
+
670
+ Args:
671
+ title (str, optional): Filename for the saved media.
672
+ codec (str, optional): Video codec/format. Defaults to "gif".
673
+ frame_idx (int or tuple, optional): Single frame index or (start, stop) frame indices.
674
+ time_idx (float or tuple, optional): Single time or (start, end) times in seconds.
675
+
676
+ Returns:
677
+ str: Absolute path to the saved file.
678
+
679
+ Raises:
680
+ ValueError: If no frames are captured or invalid input parameters.
681
+
682
+ """
683
+ try:
684
+ # Extract frames
685
+ subset_frames = self._validate_and_extract_frames(
686
+ frame_idx=frame_idx,
687
+ time_idx=time_idx,
688
+ )
689
+
690
+ # Ensure the title ends with the correct codec extension
691
+ if not title.endswith(f".{codec}"):
692
+ title += f".{codec}"
693
+
694
+ # Save the video
695
+ media.write_video(
696
+ title,
697
+ subset_frames,
698
+ fps=1 if len(subset_frames) == 1 else self._fps,
699
+ codec=codec,
700
+ )
701
+
702
+ return os.path.abspath(title)
703
+
704
+ except Exception as e:
705
+ msg = "Error while saving video subset."
706
+ raise Exception(msg) from e
707
+
708
+ @lru_cache(maxsize=100)
709
+ def t2f(self, t: float) -> int:
710
+ """Convert time to frame index."""
711
+ return min(
712
+ int(t * self._fps), int(self._duration * self._fps) - 1,
713
+ ) # Subtract 1 to convert to 0-based index
714
+
715
+ @lru_cache(maxsize=100)
716
+ def f2t(self, frame: int) -> float:
717
+ """Convert frame index to time."""
718
+ return frame / self._fps
719
+
720
+ def getBodyData(self, body_name: str, data_name: str | None = None) -> np.ndarray:
721
+ """Get the data for a specific body in the simulation.
722
+
723
+ Args:
724
+ body_name (str): The name of the body to retrieve data for.
725
+ data_name (str): The name of the data to retrieve.
726
+
727
+ Returns:
728
+ np.ndarray: The data for the specified body.
729
+
730
+ """
731
+ if body_name not in self._body_names:
732
+ msg = f"Body '{body_name}' not found in the model."
733
+ raise ValueError(msg)
734
+ body_id = self._model.body(body_name).id
735
+
736
+ if data_name is None:
737
+ return self._captured_data.unwrap()[body_id]
738
+ if data_name not in self._captured_data.unwrap():
739
+ msg = f"Data '{data_name}' not found for body '{body_name}'."
740
+ raise ValueError(msg)
741
+ return self._captured_data.unwrap()[body_id][data_name]
742
+
743
+ def getID(self, id: int) -> str:
744
+ """Get the name of a body given its ID.
745
+
746
+ Args:
747
+ id (int): The ID of the body.
748
+
749
+ Returns:
750
+ str: The name of the body.
751
+
752
+ """
753
+ if id < 0 or id >= self._model.nbody:
754
+ msg = f"Invalid body ID: {id}"
755
+ raise ValueError(msg)
756
+ return mujoco.mj_id2name(self._model, mujoco.mjtObj.mjOBJ_BODY, id)
757
+
758
+ def saveYAML(self, name="Model") -> None:
759
+ """Save simulation data to a YAML file.
760
+
761
+ Args:
762
+ name (str): The filename for the YAML file.
763
+
764
+ Returns:
765
+ None
766
+
767
+ """
768
+ if not name.endswith(".yml"):
769
+ name += ".yml"
770
+
771
+ try:
772
+ # Convert simData's NumPy arrays or lists to a YAML-friendly format
773
+ serialized_data = {
774
+ k: (v.tolist() if isinstance(v, np.ndarray) else v)
775
+ for k, v in self.captured_data.items()
776
+ }
777
+
778
+ with open(name, "w") as f:
779
+ yaml.dump(serialized_data, f, default_flow_style=False)
780
+
781
+ except Exception as e:
782
+ msg = f"Failed to save simulation data to '{name}'"
783
+ raise ValueError(msg) from e
784
+
785
+
786
+ class _SimulationData:
787
+ """A class to store and manage simulation data."""
788
+
789
+ __slots__ = ["_d"]
790
+
791
+ def __init__(self) -> None:
792
+ self._d = defaultdict(list)
793
+
794
+ def capture(self, mj_data: mujoco.MjData) -> None:
795
+ """Capture data from MjData object, storing specified or all public simulation data."""
796
+ from . import CAPTURE_PARAMETERS
797
+ keys = self._get_public_keys(mj_data) if CAPTURE_PARAMETERS == "all" else CAPTURE_PARAMETERS
798
+
799
+ for key in keys:
800
+ try:
801
+ value = getattr(mj_data, key)
802
+ # Check if value is a numpy array and copy if needed
803
+ if isinstance(value, np.ndarray):
804
+ self._d[key].append(value.copy())
805
+ elif np.isscalar(value):
806
+ self._d[key].append(value)
807
+ elif hasattr(value, "copy") and callable(value.copy):
808
+ # Copy if it has a copy method (e.g., MuJoCo's MjArray)
809
+ self._d[key].append(value.copy())
810
+ else:
811
+ self._d[key].append(value)
812
+ except AttributeError:
813
+ pass
814
+ except Exception:
815
+ pass
816
+
817
+ def unwrap(self) -> dict[str, np.ndarray]:
818
+ """Unwrap the captured simulation data into a structured format with NumPy arrays."""
819
+ unwrapped_data = {}
820
+
821
+ for key, value_list in self._d.items():
822
+ if not value_list: # Skip empty lists
823
+ unwrapped_data[key] = np.array([])
824
+ continue
825
+
826
+ try:
827
+ # Check if all items in the list have the same shape for array data
828
+ if isinstance(value_list[0], np.ndarray):
829
+ # Check if arrays have consistent shapes
830
+ shapes = [arr.shape for arr in value_list]
831
+ if all(shape == shapes[0] for shape in shapes):
832
+ unwrapped_data[key] = np.stack(value_list)
833
+ else:
834
+ # For arrays with different shapes, keep as a list
835
+ unwrapped_data[key] = value_list
836
+ else:
837
+ # Convert to a NumPy array if it's a list of scalars
838
+ unwrapped_data[key] = np.array(value_list)
839
+ except ValueError:
840
+ unwrapped_data[key] = value_list # Store as a list if conversion fails
841
+ except Exception:
842
+ unwrapped_data[key] = value_list
843
+
844
+ return unwrapped_data
845
+
846
+ @property
847
+ def shape(self) -> dict[str, tuple]:
848
+ """Return the shape of the captured data."""
849
+ if not self._d:
850
+ return {}
851
+
852
+ shapes = {}
853
+ for key, value_list in self._d.items():
854
+ if not value_list:
855
+ shapes[key] = ()
856
+ continue
857
+
858
+ first_value = value_list[0]
859
+ if isinstance(first_value, np.ndarray):
860
+ shapes[key] = (len(value_list), *first_value.shape)
861
+ elif isinstance(first_value, list):
862
+ shapes[key] = (len(value_list), len(first_value))
863
+ else:
864
+ shapes[key] = (len(value_list),)
865
+
866
+ return shapes
867
+
868
+ def __del__(self) -> None:
869
+ self._d.clear()
870
+ import gc
871
+
872
+ gc.collect()
873
+
874
+ def __len__(self) -> int:
875
+ if not self._d:
876
+ return 0
877
+ # Return the length of one of the data lists (assuming all have same length)
878
+ return len(next(iter(self._d.values())))
879
+
880
+ def __str__(self) -> str:
881
+ return f"{self.__class__.__name__}({len(self)} Step(s) Captured)"
882
+
883
+ def __repr__(self) -> str:
884
+ return self.__str__()
885
+
886
+ @staticmethod
887
+ def _get_public_keys(obj):
888
+ """Get all public keys of an object."""
889
+ return [
890
+ name
891
+ for name in dir(obj)
892
+ if not name.startswith("_") and not callable(getattr(obj, name))
893
+ ]