Nightly motion imitation training and evaluation on Unitree G1 (1024 trials per run).
+Physics simulation throughput across tasks (4096 parallel envs, NVIDIA RTX 5090).
+No throughput data available. Run measure_throughput.py to generate data.
'; }} + + // Date-range windowing across both tracking and throughput charts. + // Setting min and clearing max also resets any zoom/pan. + function setRange(days) {{ + const min = days > 0 ? Date.now() - days * 86400000 : undefined; + [...trackingCharts, ...throughputCharts].forEach(c => {{ + c.options.scales.x.min = min; + c.options.scales.x.max = undefined; + c.update(); + }}); + document.querySelectorAll('.range-btn').forEach(b => + b.classList.toggle('active', parseInt(b.dataset.days) === days)); + }} + document.querySelectorAll('.range-btn').forEach(btn => {{ + btn.addEventListener('click', () => setRange(parseInt(btn.dataset.days))); + }}); + setRange(90); @@ -764,7 +822,11 @@ def main( if run_id in eval_results_by_id: print(f"Using cached result for {run_id}") else: - result = evaluate_run(run_path, num_envs) + try: + result = evaluate_run(run_path, num_envs) + except RuntimeError as e: + print(f"Skipping {run_path}: {e}") + continue eval_results_by_id[run_id] = result new_evals += 1 else: @@ -783,7 +845,11 @@ def main( print(f"Reached eval limit ({eval_limit}), skipping remaining new runs") break run_path = f"{entity}/{project}/{run.id}" - result = evaluate_run(run_path, num_envs) + try: + result = evaluate_run(run_path, num_envs) + except RuntimeError as e: + print(f"Skipping {run.name} ({run.id}): {e}") + continue eval_results_by_id[run.id] = result new_evals += 1 diff --git a/05_software/train/rc_mjlab/mjlab/scripts/tools/terrain_explorer.py b/05_software/train/rc_mjlab/mjlab/scripts/tools/terrain_explorer.py new file mode 100644 index 0000000..f3b8cfe --- /dev/null +++ b/05_software/train/rc_mjlab/mjlab/scripts/tools/terrain_explorer.py @@ -0,0 +1,115 @@ +"""Interactive single-patch terrain explorer (Viser + MuJoCo MjSpec). + +Run with: + uv run python scripts/tools/terrain_explorer.py + uv run python scripts/tools/terrain_explorer.py --port 8081 + +Then open the printed URL (default http://localhost:8080). +""" + +from __future__ import annotations + +import argparse +import time + +import mujoco +import numpy as np +import viser +from mjviser.conversions import merge_geoms + +from mjlab.terrains.config import ALL_TERRAIN_PRESETS +from mjlab.terrains.terrain_generator import TerrainGenerator, TerrainGeneratorCfg + +PATCH_SIZE = (8.0, 8.0) + + +# Per-preset overrides applied when building in the explorer (e.g. to surface +# difficulty-driven behavior that is off by default). +_PRESET_OVERRIDES: dict[str, dict] = { + "random_rough": {"scale_with_difficulty": True}, +} + + +def _build_terrain_mesh(preset_name: str, difficulty: float, seed: int): + """Generate a single terrain patch and return a merged trimesh (or raise).""" + preset_fn = ALL_TERRAIN_PRESETS[preset_name] + overrides = _PRESET_OVERRIDES.get(preset_name, {}) + generator_cfg = TerrainGeneratorCfg( + seed=seed, + size=PATCH_SIZE, + num_rows=1, + num_cols=1, + border_width=0.0, + curriculum=False, + # A degenerate range pins the single patch to exactly this difficulty. + difficulty_range=(difficulty, difficulty), + color_scheme="height", + sub_terrains={preset_name: preset_fn(proportion=1.0, **overrides)}, + ) + generator = TerrainGenerator(generator_cfg) + spec = mujoco.MjSpec() + generator.compile(spec) + model = spec.compile() + + terrain_body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "terrain") + geom_ids = [i for i in range(model.ngeom) if model.geom_bodyid[i] == terrain_body_id] + return merge_geoms(model, geom_ids) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--port", type=int, default=8080, help="Port for the viser server." + ) + args = parser.parse_args() + + server = viser.ViserServer(port=args.port) + preset_names = sorted(ALL_TERRAIN_PRESETS) + + terrain_dropdown = server.gui.add_dropdown( + "Terrain", options=preset_names, initial_value=preset_names[0] + ) + difficulty_slider = server.gui.add_slider( + "Difficulty", min=0.0, max=1.0, step=0.01, initial_value=0.0 + ) + seed_input = server.gui.add_number("Seed", initial_value=42, step=1) + status = server.gui.add_markdown("**Status:** ready") + + handle: viser.SceneNodeHandle | None = None + + def update() -> None: + nonlocal handle + name = terrain_dropdown.value + difficulty = float(difficulty_slider.value) + seed = int(seed_input.value) + status.content = f"**Status:** building `{name}` at difficulty {difficulty:.2f}..." + try: + mesh = _build_terrain_mesh(name, difficulty, seed) + except Exception as e: # noqa: BLE001 - surface any generation failure in the UI. + status.content = f"**Error:** {type(e).__name__}: {e}" + print(f"Failed to build {name} at difficulty {difficulty}: {e}") + return + if handle is not None: + handle.remove() + handle = server.scene.add_mesh_trimesh("/terrain", mesh) + status.content = ( + f"**Loaded** `{name}` at difficulty {difficulty:.2f} ({len(mesh.faces):,} faces)" + ) + + terrain_dropdown.on_update(lambda _: update()) + difficulty_slider.on_update(lambda _: update()) + seed_input.on_update(lambda _: update()) + + # Top-down-ish initial camera. + @server.on_client_connect + def _(client: viser.ClientHandle) -> None: + client.camera.position = np.array([10.0, 10.0, 8.0]) + client.camera.look_at = np.array([0.0, 0.0, 0.0]) + + update() + while True: + time.sleep(1.0) + + +if __name__ == "__main__": + main() diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py index eeb970f..462b3f0 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/__init__.py @@ -1,5 +1,15 @@ import os import sys + +# Default to EGL for GPU-accelerated offscreen rendering on Linux. Must be set +# before any mujoco import: mujoco's gl_context module captures MUJOCO_GL once +# at load time. Override with e.g. MUJOCO_GL=osmesa on clusters without EGL. +# Linux-only because mujoco's gl_context rejects "egl" on macOS/Windows and +# raises at import. On those platforms we leave MUJOCO_GL alone so mujoco +# defaults to GLFW. +if sys.platform.startswith("linux"): + os.environ.setdefault("MUJOCO_GL", "egl") + import traceback from importlib.metadata import entry_points from pathlib import Path diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py index c6db91a..153ee12 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/__init__.py @@ -4,6 +4,12 @@ from mjlab.actuator.actuator import Actuator as Actuator from mjlab.actuator.actuator import ActuatorCfg as ActuatorCfg from mjlab.actuator.actuator import ActuatorCmd as ActuatorCmd from mjlab.actuator.actuator import CommandField as CommandField +from mjlab.actuator.builtin_actuator import ( + BuiltinDcMotorActuator as BuiltinDcMotorActuator, +) +from mjlab.actuator.builtin_actuator import ( + BuiltinDcMotorActuatorCfg as BuiltinDcMotorActuatorCfg, +) from mjlab.actuator.builtin_actuator import ( BuiltinMotorActuator as BuiltinMotorActuator, ) @@ -16,6 +22,12 @@ from mjlab.actuator.builtin_actuator import ( from mjlab.actuator.builtin_actuator import ( BuiltinMuscleActuatorCfg as BuiltinMuscleActuatorCfg, ) +from mjlab.actuator.builtin_actuator import ( + BuiltinPdActuator as BuiltinPdActuator, +) +from mjlab.actuator.builtin_actuator import ( + BuiltinPdActuatorCfg as BuiltinPdActuatorCfg, +) from mjlab.actuator.builtin_actuator import ( BuiltinPositionActuator as BuiltinPositionActuator, ) @@ -28,6 +40,15 @@ from mjlab.actuator.builtin_actuator import ( from mjlab.actuator.builtin_actuator import ( BuiltinVelocityActuatorCfg as BuiltinVelocityActuatorCfg, ) +from mjlab.actuator.builtin_actuator import ( + DcMotorDatasheetParams as DcMotorDatasheetParams, +) +from mjlab.actuator.builtin_actuator import ( + DcMotorInputMode as DcMotorInputMode, +) +from mjlab.actuator.builtin_actuator import ( + DcMotorPhysicalParams as DcMotorPhysicalParams, +) from mjlab.actuator.builtin_group import BuiltinActuatorGroup as BuiltinActuatorGroup from mjlab.actuator.dc_actuator import DcMotorActuator as DcMotorActuator from mjlab.actuator.dc_actuator import DcMotorActuatorCfg as DcMotorActuatorCfg diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py index cb9df5c..34b130c 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/actuator.py @@ -174,15 +174,6 @@ class Actuator(ABC, Generic[ActuatorCfgT]): """Whether this actuator has delay configured.""" return self.cfg.delay_max_lag > 0 - @property - def command_field(self) -> CommandField | None: - """The primary command field this actuator consumes. - - Returns None by default. Subclasses should override to return the - appropriate field. - """ - return None - @property def target_ids(self) -> torch.Tensor: """Local indices of targets controlled by this actuator.""" @@ -271,11 +262,6 @@ class Actuator(ABC, Generic[ActuatorCfgT]): """Create delay buffer. Called during initialize().""" if not self.has_delay: return - if self.command_field is None: - raise ValueError( - f"{self.__class__.__name__}: delay is configured (delay_max_lag=" - f"{self.cfg.delay_max_lag}) but command_field is not defined." - ) self._delay_buffer = DelayBuffer( min_lag=self.cfg.delay_min_lag, max_lag=self.cfg.delay_max_lag, @@ -287,19 +273,25 @@ class Actuator(ABC, Generic[ActuatorCfgT]): ) def apply_delay(self, cmd: ActuatorCmd) -> ActuatorCmd: - """Apply delay to the command_field target. No-op without delay.""" + """Delay all command targets with one shared lag. No-op without delay. + + Every target the policy issues (position, velocity, effort) travels the same + command channel and experiences the same latency, so they are stacked and + delayed together. Feedback fields (``pos``, ``vel``) are never delayed. + """ if self._delay_buffer is None: return cmd - cf = self.command_field - if cf == "position": - self._delay_buffer.append(cmd.position_target) - return dataclasses.replace(cmd, position_target=self._delay_buffer.compute()) - elif cf == "velocity": - self._delay_buffer.append(cmd.velocity_target) - return dataclasses.replace(cmd, velocity_target=self._delay_buffer.compute()) - else: - self._delay_buffer.append(cmd.effort_target) - return dataclasses.replace(cmd, effort_target=self._delay_buffer.compute()) + targets = torch.stack( + (cmd.position_target, cmd.velocity_target, cmd.effort_target), dim=-1 + ) + self._delay_buffer.append(targets) + delayed = self._delay_buffer.compute() + return dataclasses.replace( + cmd, + position_target=delayed[..., 0], + velocity_target=delayed[..., 1], + effort_target=delayed[..., 2], + ) def set_lags( self, diff --git a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py index 151c429..2f7cfa4 100644 --- a/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py +++ b/05_software/train/rc_mjlab/mjlab/src/mjlab/actuator/builtin_actuator.py @@ -7,19 +7,21 @@ created programmatically via the MjSpec API. from __future__ import annotations from dataclasses import dataclass +from enum import IntEnum from typing import TYPE_CHECKING import mujoco +import numpy as np import torch from mjlab.actuator.actuator import ( Actuator, ActuatorCfg, ActuatorCmd, - CommandField, TransmissionType, ) from mjlab.utils.spec import ( + apply_target_overrides, create_motor_actuator, create_muscle_actuator, create_position_actuator, @@ -63,10 +65,6 @@ class BuiltinPositionActuatorCfg(ActuatorCfg): class BuiltinPositionActuator(Actuator[BuiltinPositionActuatorCfg]): """MuJoCo built-in position actuator.""" - @property - def command_field(self) -> CommandField: - return "position" - def __init__( self, cfg: BuiltinPositionActuatorCfg, @@ -96,6 +94,102 @@ class BuiltinPositionActuator(Actuator[BuiltinPositionActuatorCfg]): return cmd.position_target +@dataclass(kw_only=True) +class BuiltinPdActuatorCfg(ActuatorCfg): + """Implicit-integration version of IdealPdActuator. + + Both consume a position target and a velocity target with kp/kd gains. The + difference is in how the PD is delivered to MuJoCo: IdealPdActuator computes + the PD force in Python and feeds it to a ``