"""Robustness sweep: the same scene many times with randomized physics, in parallel on the GPU. Each copy ("world") gets its own - ground/object friction scale (geom_friction) - mass scale of every free object (body_mass + body_inertia, objects only, not the robot) - small joint noise on the robot's starting pose - one horizontal push on the robot's root body, in a random direction, at `push_at` seconds and runs `duration_s` with the same controller as /simulate/run. The result is how many worlds end with the robot fallen, and which parameters those were. Engine: MuJoCo Warp (NVIDIA Warp, one GPU kernel launch per step for all worlds) when CUDA is available, else plain MuJoCo on the CPU, one world after another. Both apply exactly the same randomization, so results agree up to floating point. """ from __future__ import annotations import json import math import time from dataclasses import asdict, dataclass from pathlib import Path import mujoco import numpy as np from api.robots import ROBOTS, reset_to_robot_keyframe from worker.sim_runner import _hold_controller, config_for MAX_WORLDS = 256 MAX_DURATION_S = 10.0 @dataclass class SweepConfig: n_worlds: int = 64 duration_s: float = 5.0 controller: str = "hold" friction: tuple = (0.5, 1.5) # scale range object_mass: tuple = (0.5, 2.0) # scale range for free objects push_n: tuple = (0.0, 80.0) # push force range (N), applied for push_s push_at: float = 1.0 push_s: float = 0.2 joint_noise: float = 0.02 # rad, uniform seed: int = 0 @dataclass class World: friction: float object_mass: float push_n: float push_dir_deg: float fell: bool = False fell_at: float | None = None min_height: float | None = None final_height: float | None = None def sample_worlds(cfg: SweepConfig) -> list[World]: rng = np.random.default_rng(cfg.seed) return [ World( friction=round(float(rng.uniform(*cfg.friction)), 3), object_mass=round(float(rng.uniform(*cfg.object_mass)), 3), push_n=round(float(rng.uniform(*cfg.push_n)), 1), push_dir_deg=round(float(rng.uniform(0, 360)), 1), ) for _ in range(cfg.n_worlds) ] def _robot_bodies(model: mujoco.MjModel, root: int) -> np.ndarray: inside = np.zeros(model.nbody, bool) for b in range(1, model.nbody): a = b while a > 0 and a != root: a = model.body_parentid[a] inside[b] = a == root and root > 0 return inside def _free_objects(model: mujoco.MjModel, robot_bodies: np.ndarray) -> np.ndarray: """Bodies (and their children) hanging off a free joint that are not part of the robot.""" free = np.zeros(model.nbody, bool) for j in range(model.njnt): b = model.jnt_bodyid[j] if model.jnt_type[j] == mujoco.mjtJoint.mjJNT_FREE and not robot_bodies[b]: free[b] = True for b in range(1, model.nbody): # children of free objects if free[model.body_parentid[b]]: free[b] = True return free def _joint_noise_mask(model: mujoco.MjModel, robot_bodies: np.ndarray) -> np.ndarray: """qpos entries of the robot's hinge/slide joints (the free joint is left alone).""" mask = np.zeros(model.nq, bool) for j in range(model.njnt): if robot_bodies[model.jnt_bodyid[j]] and model.jnt_type[j] in ( mujoco.mjtJoint.mjJNT_HINGE, mujoco.mjtJoint.mjJNT_SLIDE, ): mask[model.jnt_qposadr[j]] = True return mask def _setup(scene: Path, robot: str | None, cfg: SweepConfig): model = mujoco.MjModel.from_xml_path(str(scene)) data = mujoco.MjData(model) run_cfg = config_for(robot, cfg.duration_s, cfg.controller, render=False) reset_to_robot_keyframe(model, data, run_cfg.root_body, run_cfg.home_qpos) root = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, run_cfg.root_body) if run_cfg.root_body else -1 robot_bodies = _robot_bodies(model, root) return model, data, run_cfg, root, robot_bodies def _world_params(model, worlds, robot_bodies, cfg: SweepConfig, qpos0: np.ndarray): """Per-world arrays: friction, masses, inertias, starting qpos, push force.""" n = len(worlds) rng = np.random.default_rng(cfg.seed + 1) objects = _free_objects(model, robot_bodies) noise_mask = _joint_noise_mask(model, robot_bodies) friction = np.repeat(model.geom_friction[None], n, 0).copy() mass = np.repeat(model.body_mass[None], n, 0).copy() inertia = np.repeat(model.body_inertia[None], n, 0).copy() qpos = np.repeat(qpos0[None], n, 0).copy() force = np.zeros((n, 3)) for i, w in enumerate(worlds): friction[i, :, 0] *= w.friction # sliding friction of every geom mass[i, objects] *= w.object_mass inertia[i, objects] *= w.object_mass qpos[i, noise_mask] += rng.uniform(-cfg.joint_noise, cfg.joint_noise, noise_mask.sum()) a = math.radians(w.push_dir_deg) force[i] = (w.push_n * math.cos(a), w.push_n * math.sin(a), 0.0) return friction, mass, inertia, qpos, force def _finish(worlds, heights, fell_at, final, fall_height) -> None: for i, w in enumerate(worlds): w.min_height = round(float(heights[i]), 3) w.final_height = round(float(final[i]), 3) if fall_height is not None: w.fell = fell_at[i] is not None w.fell_at = fell_at[i] # --------------------------------------------------------------------------- engines def run_cpu(scene: Path, robot: str | None, cfg: SweepConfig, worlds: list[World]) -> None: model, data, run_cfg, root, robot_bodies = _setup(scene, robot, cfg) friction, mass, inertia, qpos, force = _world_params(model, worlds, robot_bodies, cfg, data.qpos.copy()) base = (model.geom_friction.copy(), model.body_mass.copy(), model.body_inertia.copy()) steps = int(math.ceil(cfg.duration_s / model.opt.timestep)) push = (int(cfg.push_at / model.opt.timestep), int((cfg.push_at + cfg.push_s) / model.opt.timestep)) n = len(worlds) heights, final, fell_at = np.full(n, np.inf), np.zeros(n), [None] * n for i in range(n): model.geom_friction[:], model.body_mass[:], model.body_inertia[:] = friction[i], mass[i], inertia[i] mujoco.mj_resetData(model, data) data.qpos[:] = qpos[i] mujoco.mj_forward(model, data) hold = _hold_controller(model, data, run_cfg) if cfg.controller == "hold" else None for s in range(steps): if hold is not None: hold.apply(data) if root > 0: data.xfrc_applied[root, :3] = force[i] if push[0] <= s < push[1] else 0.0 mujoco.mj_step(model, data) if root > 0: z = float(data.xpos[root, 2]) heights[i] = min(heights[i], z) if fell_at[i] is None and run_cfg.fall_height is not None and z < run_cfg.fall_height: fell_at[i] = round(float(data.time), 2) final[i] = float(data.xpos[root, 2]) if root > 0 else 0.0 model.geom_friction[:], model.body_mass[:], model.body_inertia[:] = base _finish(worlds, heights, fell_at, final, run_cfg.fall_height) def run_warp( scene: Path, robot: str | None, cfg: SweepConfig, worlds: list[World], device: str | None = None ) -> str | None: """Returns a note when the model had to be adapted for MuJoCo Warp.""" import mujoco_warp as mjw import warp as wp wp.config.quiet = True model, data, run_cfg, root, robot_bodies = _setup(scene, robot, cfg) note = None friction, mass, inertia, qpos, force = _world_params(model, worlds, robot_bodies, cfg, data.qpos.copy()) n = len(worlds) hold = _hold_controller(model, data, run_cfg) if cfg.controller == "hold" else None with wp.ScopedDevice(device): batch = {"geom_friction": n, "body_mass": n, "body_inertia": n} try: m = mjw.put_model(model, batch_sizes=batch) except NotImplementedError as e: if "margin" not in str(e): raise # e.g. Menagerie's Go2 gives its feet 1 mm contact margins, which MuJoCo Warp's CCD does not support. note = f"contact margins up to {model.geom_margin.max() * 1000:g} mm set to 0 for MuJoCo Warp" model.geom_margin[:] = 0.0 m = mjw.put_model(model, batch_sizes=batch) m.geom_friction.assign(friction.astype(np.float32)) m.body_mass.assign(mass.astype(np.float32)) m.body_inertia.assign(inertia.astype(np.float32)) d = mjw.put_data(model, data, nworld=n, nconmax=max(64, 8 * model.ngeom), njmax=max(256, 40 * model.nv)) d.qpos.assign(qpos.astype(np.float32)) mjw.forward(m, d) ctrl_base = np.repeat((hold.ctrl if hold else np.zeros(model.nu))[None], n, 0).astype(np.float32) pd = hold is not None and len(hold.motors) > 0 if pd: motors = wp.array(hold.motors.astype(np.int32), dtype=int) qadr = wp.array(hold.qadr.astype(np.int32), dtype=int) vadr = wp.array(hold.vadr.astype(np.int32), dtype=int) target, kp, kd, lo, hi = ( wp.array(a.astype(np.float32), dtype=float) for a in (hold.target, hold.kp, hold.kd, hold.lo, hold.hi) ) d.ctrl.assign(ctrl_base) xfrc = np.zeros((n, model.nbody, 6), np.float32) steps = int(math.ceil(cfg.duration_s / model.opt.timestep)) push = (int(cfg.push_at / model.opt.timestep), int((cfg.push_at + cfg.push_s) / model.opt.timestep)) sample_every = max(1, int(0.02 / model.opt.timestep)) heights, fell_at = np.full(n, np.inf), [None] * n for s in range(steps): if root > 0 and s in push: xfrc[:, root, :3] = force if s == push[0] else 0.0 d.xfrc_applied.assign(xfrc) if pd: wp.launch( _pd_kernel(), dim=(n, len(hold.motors)), inputs=[d.qpos, d.qvel, motors, qadr, vadr, target, kp, kd, lo, hi], outputs=[d.ctrl], ) mjw.step(m, d) if root > 0 and (s % sample_every == 0 or s == steps - 1): z = d.xpos.numpy()[:, root, 2] heights = np.minimum(heights, z) if run_cfg.fall_height is not None: t = round((s + 1) * model.opt.timestep, 2) for i in np.nonzero(z < run_cfg.fall_height)[0]: if fell_at[i] is None: fell_at[i] = t final = d.xpos.numpy()[:, root, 2] if root > 0 else np.zeros(n) _finish(worlds, heights, fell_at, final, run_cfg.fall_height) return note def _make_pd_kernel(): import warp as wp @wp.kernel def pd( qpos: wp.array2d(dtype=float), qvel: wp.array2d(dtype=float), motors: wp.array(dtype=int), qadr: wp.array(dtype=int), vadr: wp.array(dtype=int), target: wp.array(dtype=float), kp: wp.array(dtype=float), kd: wp.array(dtype=float), lo: wp.array(dtype=float), hi: wp.array(dtype=float), ctrl: wp.array2d(dtype=float), ): w, k = wp.tid() tau = kp[k] * (target[k] - qpos[w, qadr[k]]) - kd[k] * qvel[w, vadr[k]] ctrl[w, motors[k]] = wp.clamp(tau, lo[k], hi[k]) return pd _PD_KERNEL = None def _pd_kernel(): """Compiled on first use, so importing this module never needs Warp.""" global _PD_KERNEL if _PD_KERNEL is None: _PD_KERNEL = _make_pd_kernel() return _PD_KERNEL def cuda_available() -> bool: try: import warp as wp wp.config.quiet = True wp.init() return wp.is_cuda_available() except Exception: return False # --------------------------------------------------------------------------- job def sweep(scene: Path, robot: str | None, cfg: SweepConfig, engine: str = "auto") -> dict: if not 1 <= cfg.n_worlds <= MAX_WORLDS: raise ValueError(f"n_worlds must be 1..{MAX_WORLDS}") cfg.duration_s = min(cfg.duration_s, MAX_DURATION_S) if engine == "auto": engine = "warp" if cuda_available() else "mujoco" worlds = sample_worlds(cfg) started = time.monotonic() note = None if engine == "warp": try: note = run_warp(scene, robot, cfg, worlds, device="cuda:0" if cuda_available() else "cpu") except NotImplementedError as e: # a model feature MuJoCo Warp lacks: same sweep on the CPU engine, note = "mujoco", f"MuJoCo Warp cannot run this scene ({e}); ran on the CPU instead" worlds = sample_worlds(cfg) run_cpu(scene, robot, cfg, worlds) else: run_cpu(scene, robot, cfg, worlds) label = "MuJoCo (CPU)" if engine == "warp": label = "MuJoCo Warp (GPU)" if cuda_available() else "MuJoCo Warp (CPU device)" result = summarize(worlds, cfg, robot, label, time.monotonic() - started) if note: result["summary"]["engine_note"] = note return result def summarize(worlds: list[World], cfg: SweepConfig, robot: str | None, engine: str, wall: float) -> dict: spec = ROBOTS.get(robot) if robot else None detect = spec is not None and spec.fall_height is not None fell = [w for w in worlds if w.fell] n = len(worlds) summary = { "worlds": n, "engine": engine, "simulated_seconds_each": cfg.duration_s, "wall_seconds": round(wall, 1), "controller": cfg.controller, "ranges": { "friction_scale": list(cfg.friction), "object_mass_scale": list(cfg.object_mass), "push_newtons": list(cfg.push_n), "push_at_s": cfg.push_at, "push_duration_s": cfg.push_s, "joint_noise_rad": cfg.joint_noise, }, "seed": cfg.seed, } if detect: summary["fell"] = len(fell) summary["fall_rate"] = round(len(fell) / n, 3) if fell: summary["mean_push_n_fell"] = round(float(np.mean([w.push_n for w in fell])), 1) summary["min_push_n_fell"] = min(w.push_n for w in fell) stood = [w for w in worlds if not w.fell] if stood: summary["max_push_n_survived"] = max(w.push_n for w in stood) summary["headline"] = f"{len(fell)}/{n} fell ({100 * len(fell) / n:.0f}%)" if fell: summary["headline"] += f" ยท lightest push that toppled it: {summary['min_push_n_fell']:g} N" else: summary["headline"] = f"{n} worlds simulated (no fall detection for this robot)" return {"summary": summary, "worlds": [asdict(w) for w in worlds]} def run_sweep_job(job_input: dict, menagerie_dir: Path | None = None, engine: str = "auto") -> dict: """Worker entry point. Returns the summary and the per-world table as an inline artifact.""" import os import shutil from api.mjcf_check import SCENE_FILE, prepare_scene_dir, static_check mjcf, robot = job_input["mjcf"], job_input.get("robot") failed = static_check(mjcf, robot) if failed: raise ValueError(f"scene rejected: {failed.error}") fields = {k: job_input[k] for k in ("n_worlds", "duration_s", "controller", "seed") if k in job_input} for k in ("friction", "object_mass", "push_n"): if k in job_input: fields[k] = tuple(float(x) for x in job_input[k]) cfg = SweepConfig(**fields) menagerie = Path(menagerie_dir or os.environ.get("MENAGERIE_DIR", "/opt/mujoco_menagerie")) scene_dir = prepare_scene_dir(mjcf, robot, menagerie) try: result = sweep(scene_dir / SCENE_FILE, robot, cfg, engine) finally: shutil.rmtree(scene_dir, ignore_errors=True) worker_seconds = result["summary"]["wall_seconds"] return { "summary": result["summary"], "worker_seconds": worker_seconds, "artifacts": {"sweep.json": json.dumps(result, indent=1).encode()}, }