"""Robots from a model file + a YAML spec, without code (the Studio's *Add robot*).
Each robot is a folder in ``<config>/robots/<id>/`` (:func:`wbc_mjlab.user_config.config_dir`,
``~/.config/wbc-mjlab``)::
robot.yaml the spec: model file, anchor / keybodies / end-effectors / feet, PD gains
per joint, initial pose (:class:`CustomRobotSpec`)
<model>.xml|urdf the uploaded model and its meshes, untouched
wbc_robot.xml the model as wbc-mjlab uses it, written next to the source on save:
actuators, keyframes and world geoms removed (wbc-mjlab adds its own PD
actuators and terrain), a free joint on the root, and the sensors the WBC
tasks read (``imu_ang_vel`` / ``imu_lin_vel`` IMU, ``root_angmom``) when the
model has none
:func:`register_all` (run by the ``mjlab.tasks`` entry point, so in every CLI and the Studio)
registers each robot like an extension: robot id ``<id>``, tasks ``Wbc-<Id>`` (the default WBC
stack), ``Wbc-<Id>-SE`` (state-estimation actor) and ``Wbc-<Id>-Terrain-Scan`` (curriculum
terrain + height scan), data in ``data/<id>/``.
"""
from __future__ import annotations
import dataclasses
import math
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
import mujoco
import numpy as np
import yaml
SPEC_FILE = "robot.yaml"
PREPARED = "wbc_robot.xml"
ID_RE = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
MODEL_SUFFIXES = (".xml", ".urdf", ".mjcf")
IMU_SITE = "imu"
GYRO, VELOCIMETER, ANGMOM = "imu_ang_vel", "imu_lin_vel", "root_angmom"
NATURAL_FREQ_HZ = 10.0 # PD design as in the H2 extension: ω = 10 Hz, ζ = 2
DAMPING_RATIO = 2.0
DEFAULT_ARMATURE = 0.01
DEFAULT_EFFORT = 100.0
[docs]
@dataclass
class JointGains:
effort: float = DEFAULT_EFFORT
"""Peak torque magnitude (N·m)."""
stiffness: float = 0.0
damping: float = 0.0
armature: float = DEFAULT_ARMATURE
"""Reflected rotor inertia (kg·m²)."""
[docs]
@dataclass
class CustomRobotSpec:
id: str
model: str
"""Source model file, relative to the robot folder."""
anchor_body: str
keybodies: list[str]
"""Bodies the motion command tracks (rewards, RSI, terminations)."""
ee_bodies: list[str]
"""End-effectors (EE height termination, the EE task's actor command)."""
feet_bodies: list[str]
foot_sites: list[str] = field(default_factory=list)
"""Sites under the feet for the foot-slip reward (none: the reward is dropped)."""
foot_geoms: list[str] = field(default_factory=list)
"""Named collision geoms of the feet for friction randomization (none: not randomized)."""
foot_sole_z: float = 0.0
"""Sole below the foot bodies (m), for z debias in conversion."""
init_height: float = 1.0
init_joint_pos: dict[str, float] = field(default_factory=dict)
joints: dict[str, JointGains] = field(default_factory=dict)
action_scale: float = 0.25
"""Action = scale × effort / stiffness per joint (as G1 / H2)."""
description: str = ""
nconmax: int = 64
njmax: int = 400
# ------------------------------------------------------------------ paths
@property
def folder(self) -> Path:
return robots_dir() / self.id
@property
def source(self) -> Path:
return self.folder / self.model
@property
def prepared(self) -> Path:
return self.source.parent / PREPARED
@property
def task_id(self) -> str:
return task_id_for(self.id)
def to_dict(self) -> dict:
d = dataclasses.asdict(self)
d["joints"] = {k: dataclasses.asdict(v) for k, v in self.joints.items()}
return d
@classmethod
def from_dict(cls, d: dict) -> CustomRobotSpec:
d = dict(d)
d["joints"] = {k: JointGains(**v) for k, v in (d.get("joints") or {}).items()}
known = {f.name for f in dataclasses.fields(cls)}
return cls(**{k: v for k, v in d.items() if k in known})
def robots_dir() -> Path:
from wbc_mjlab.user_config import config_dir
return config_dir() / "robots"
[docs]
def task_id_for(robot_id: str) -> str:
"""``h1`` → ``Wbc-H1``, ``h2x`` → ``Wbc-H2X``, ``my_bot`` → ``Wbc-My-Bot``."""
parts = [
p.upper() if len(p) <= 3 and any(c.isdigit() for c in p) else p.capitalize()
for p in robot_id.split("_")
if p
]
return "Wbc-" + "-".join(parts)
def check_id(robot_id: str) -> str:
if not ID_RE.match(robot_id or ""):
raise ValueError(
"robot ids: lowercase letters, digits and '_', starting with a letter"
)
return robot_id
# ---------------------------------------------------------------------- model preparation
def _is_urdf(path: Path) -> bool:
return path.suffix.lower() == ".urdf"
MESH_FORMATS = (".stl", ".obj", ".msh")
def _mesh_setup(path: Path, spec: mujoco.MjSpec) -> None:
"""URDF meshes come as ``package://pkg/…``, absolute or relative paths: point MuJoCo at the
folder (around the URDF) they resolve from, or strip paths and use the folder holding them.
MuJoCo reads STL / OBJ / MSH only."""
names = re.findall(r'filename="([^"]+)"', path.read_text(errors="replace"))
if not names:
return
bad = sorted({Path(n).suffix.lower() for n in names} - set(MESH_FORMATS))
if bad:
raise ValueError(
f"MuJoCo can't read {', '.join(bad)} meshes: convert them to .stl or .obj (e.g. with "
"Blender or trimesh), or use the robot's MJCF"
)
rel = names[0].split("://", 1)[-1]
if "://" in names[0]:
rel = rel.split("/", 1)[-1] if "/" in rel else rel # drop the package name
roots = [path.parent, *path.parents[:3]]
for root in roots:
if (root / rel).is_file():
spec.meshdir = str(root)
if "://" in names[0]:
spec.strippath = False
return
base = Path(rel).name
for root in roots:
hit = next(root.rglob(base), None)
if hit is not None:
spec.meshdir = str(hit.parent)
spec.strippath = True
return
def _root_body(spec: mujoco.MjSpec):
"""The robot: the world body's child with the largest subtree (scenes may add props)."""
def size(b) -> int:
return 1 + sum(size(c) for c in b.bodies)
bodies = list(spec.worldbody.bodies)
if not bodies:
raise ValueError("the model has no bodies")
return max(bodies, key=size)
def _actuator_efforts(spec: mujoco.MjSpec) -> dict[str, float]:
"""Peak torque per joint from the model's own actuators (before they are removed)."""
out: dict[str, float] = {}
for a in spec.actuators:
if a.trntype != mujoco.mjtTrn.mjTRN_JOINT or not a.target:
continue
gear = abs(float(a.gear[0])) or 1.0
if a.forcelimited == mujoco.mjtLimited.mjLIMITED_TRUE or (
a.forcerange[0] != 0 or a.forcerange[1] != 0
):
eff = max(abs(a.forcerange[0]), abs(a.forcerange[1]))
elif a.biastype == mujoco.mjtBias.mjBIAS_NONE and (
a.ctrlrange[0] or a.ctrlrange[1]
):
eff = max(abs(a.ctrlrange[0]), abs(a.ctrlrange[1])) * gear # motor
else:
continue
if eff > 0:
out[a.target] = float(eff)
for j in spec.joints:
if j.actfrcrange[0] or j.actfrcrange[1]:
out.setdefault(j.name, float(max(abs(j.actfrcrange[0]), abs(j.actfrcrange[1]))))
return out
def load_source(path: Path) -> mujoco.MjSpec:
path = Path(path)
spec = mujoco.MjSpec.from_file(str(path))
if _is_urdf(path):
_mesh_setup(path, spec)
spec.compiler.discardvisual = False
return spec
[docs]
def prepare(path: Path) -> tuple[mujoco.MjSpec, dict]:
"""The model as wbc-mjlab uses it (see the module docstring) and what was changed."""
spec = load_source(path)
notes: list[str] = []
efforts = _actuator_efforts(spec)
if spec.actuators:
notes.append(
f"removed {len(spec.actuators)} actuators (wbc-mjlab adds PD actuators)"
)
for a in list(spec.actuators):
spec.delete(a)
if spec.keys:
notes.append(f"removed {len(spec.keys)} keyframes")
for k in list(spec.keys):
spec.delete(k)
root = _root_body(spec)
extra = [g for g in spec.worldbody.geoms] + [
b for b in spec.worldbody.bodies if b.name != root.name
]
if extra:
notes.append(f"removed {len(extra)} world geoms / bodies (floor, props)")
for e in extra:
spec.delete(e)
joints = list(root.joints)
if not joints:
root.add_freejoint(name="floating_base_joint")
notes.append(f"added a free joint to {root.name}")
elif joints[0].type != mujoco.mjtJoint.mjJNT_FREE:
raise ValueError(
f"the root body {root.name} has a non-free joint: fixed-base robots aren't supported"
)
have = {(s.type, s.name) for s in spec.sensors}
names = {s.name for s in spec.sensors}
if not any(t == mujoco.mjtSensor.mjSENS_GYRO for t, _ in have) or GYRO not in names:
site = spec.site(IMU_SITE) # the model's own IMU site, wherever it is
if site is None:
site = root.add_site(name=IMU_SITE, pos=[0, 0, 0], size=[0.01, 0.01, 0.01])
if GYRO not in names:
spec.add_sensor(
name=GYRO,
type=mujoco.mjtSensor.mjSENS_GYRO,
objtype=mujoco.mjtObj.mjOBJ_SITE,
objname=site.name,
)
if VELOCIMETER not in names:
spec.add_sensor(
name=VELOCIMETER,
type=mujoco.mjtSensor.mjSENS_VELOCIMETER,
objtype=mujoco.mjtObj.mjOBJ_SITE,
objname=site.name,
)
notes.append(f"added IMU sensors {GYRO} / {VELOCIMETER} on {root.name}")
if ANGMOM not in names:
spec.add_sensor(
name=ANGMOM,
type=mujoco.mjtSensor.mjSENS_SUBTREEANGMOM,
objtype=mujoco.mjtObj.mjOBJ_BODY,
objname=root.name,
)
notes.append(f"added {ANGMOM} sensor on {root.name}")
return spec, {"notes": notes, "efforts": efforts, "root": root.name}
# ---------------------------------------------------------------------- inspection
_HINGE = (mujoco.mjtJoint.mjJNT_HINGE, mujoco.mjtJoint.mjJNT_SLIDE)
def _name(m, kind, i) -> str:
return mujoco.mj_id2name(m, kind, i) or ""
def _subtree(m: mujoco.MjModel, b: int) -> list[int]:
return [i for i in range(m.nbody) if _is_descendant(m, i, b)]
def _is_descendant(m: mujoco.MjModel, i: int, b: int) -> bool:
while i > 0:
if i == b:
return True
i = int(m.body_parentid[i])
return b == 0
def _geom_bottom(m: mujoco.MjModel, d: mujoco.MjData, g: int) -> float:
"""Lowest world z of a geom (exact for meshes, boxes, spheres, capsules, cylinders)."""
t, size = m.geom_type[g], m.geom_size[g]
rot, pos = d.geom_xmat[g].reshape(3, 3), d.geom_xpos[g]
if t == mujoco.mjtGeom.mjGEOM_MESH:
mesh = int(m.geom_dataid[g])
a, n = int(m.mesh_vertadr[mesh]), int(m.mesh_vertnum[mesh])
return float((m.mesh_vert[a : a + n] @ rot.T + pos)[:, 2].min())
if t == mujoco.mjtGeom.mjGEOM_SPHERE:
return float(pos[2] - size[0])
if t == mujoco.mjtGeom.mjGEOM_BOX:
return float(pos[2] - np.abs(rot[2]) @ size)
if t in (mujoco.mjtGeom.mjGEOM_CAPSULE, mujoco.mjtGeom.mjGEOM_CYLINDER):
axis_z = abs(rot[2, 2]) * size[1]
radial = size[0] * (
1.0
if t == mujoco.mjtGeom.mjGEOM_CAPSULE
else math.sqrt(max(0.0, 1 - rot[2, 2] ** 2))
)
return float(pos[2] - axis_z - radial)
if t == mujoco.mjtGeom.mjGEOM_PLANE:
return float("inf")
return float(pos[2] - m.geom_rbound[g])
def _default_qpos(
m: mujoco.MjModel, keys: dict[str, np.ndarray]
) -> tuple[np.ndarray, str]:
for k in ("home", "stand", "standing", "init", "default", "knees_bent"):
if k in keys:
return keys[k].copy(), k
q = m.qpos0.copy()
for j in range(m.njnt):
if m.jnt_type[j] in _HINGE and m.jnt_limited[j]:
lo, hi = m.jnt_range[j]
adr = m.jnt_qposadr[j]
q[adr] = min(max(q[adr], lo), hi)
return q, ""
# PD defaults when the model has no armature / effort: G1's per joint class (armature of its
# motors, peak torque), scaled by robot mass; gains then follow the H2 rule (ω = 10 Hz, ζ = 2).
G1_MASS = 35.0
_JOINT_CLASSES = (
(r"knee", 0.0251, 139.0),
(r"hip", 0.0177, 88.0),
(r"ankle|foot|toe", 0.0072, 50.0),
(r"waist|torso|spine|trunk|chest", 0.0087, 88.0),
(r"wrist|hand|finger|grip", 0.0043, 5.0),
(r"shoulder|elbow|arm", 0.0036, 25.0),
(r"head|neck", 0.0036, 10.0),
)
def joint_class(name: str) -> tuple[float, float]:
"""(armature, peak torque) of a G1-sized joint of this kind."""
for pat, arm, eff in _JOINT_CLASSES:
if re.search(pat, name, re.I):
return arm, eff
return 0.0072, 50.0
def _pick(names: list[str], *patterns: str) -> list[str]:
return [n for n in names if any(re.search(p, n, re.I) for p in patterns)]
[docs]
def suggest(m: mujoco.MjModel, d: mujoco.MjData, root: int) -> dict:
"""Best-guess wiring from body names and the kinematic tree."""
bodies = [_name(m, mujoco.mjtObj.mjOBJ_BODY, i) for i in range(m.nbody)]
children = {i: [] for i in range(m.nbody)}
for i in range(1, m.nbody):
children[int(m.body_parentid[i])].append(i)
leaves = [i for i in range(1, m.nbody) if not children[i] and m.body_mass[i] > 0]
root_name = bodies[root]
torso = _pick(
bodies, r"^torso", r"torso_link$", r"chest", r"^trunk$", r"^waist_?link"
)
anchor = torso[0] if torso else root_name
def lowest(ids: list[int]) -> list[int]:
return sorted(ids, key=lambda i: d.xpos[i][2])
def chain(i: int) -> list[int]:
out = []
while i > 0:
out.append(i)
i = int(m.body_parentid[i])
return out[::-1]
# feet: named ankle / foot bodies, deepest per side; else the two lowest leaves
named = _pick(bodies, r"ankle_roll", r"foot", r"ankle", r"toe")
feet: list[str] = []
for side in (r"(^|_)l(eft)?(_|$)|^left", r"(^|_)r(ight)?(_|$)|^right"):
cands = [n for n in named if re.search(side, n, re.I)]
if cands:
feet.append(max(cands, key=lambda n: len(chain(bodies.index(n)))))
if len(feet) < 2:
feet = [bodies[i] for i in lowest(leaves)[:2]]
hand_named = _pick(bodies, r"wrist_yaw", r"hand", r"palm", r"wrist", r"gripper")
hands: list[str] = []
for side in (r"(^|_)l(eft)?(_|$)|^left", r"(^|_)r(ight)?(_|$)|^right"):
cands = [n for n in hand_named if re.search(side, n, re.I)]
if cands:
hands.append(max(cands, key=lambda n: len(chain(bodies.index(n)))))
if len(hands) < 2:
# the deepest leaf on each side (y > 0 left, y < 0 right) that isn't a foot or a head
not_hand = re.compile(r"head|neck|camera|lidar|imu|sensor|eye", re.I)
cands = [
i for i in leaves if bodies[i] not in feet and not not_hand.search(bodies[i])
]
hands = []
for sign in (1, -1):
side = [i for i in cands if sign * (d.xpos[i][1] - d.xpos[root][1]) > 0.02]
if side:
hands.append(bodies[max(side, key=lambda i: (len(chain(i)), -d.xpos[i][2]))])
ees = [*feet, *hands]
# keybodies: root, anchor and 3 bodies per limb — proximal (hip / shoulder), middle
# (knee / elbow), the end-effector; by name, else evenly along the chain
keys = [root_name] + ([anchor] if anchor != root_name else [])
stages = (
(r"hip_roll", r"shoulder_roll", r"hip", r"shoulder", r"thigh", r"upper_?arm"),
(r"knee", r"elbow", r"shin", r"calf", r"fore_?arm", r"lower_?arm"),
)
for ee in ees:
c = [i for i in chain(bodies.index(ee)) if i != root and bodies[i] != anchor]
picks = []
for k, pats in enumerate(stages):
hit = next((i for p in pats for i in c if re.search(p, bodies[i], re.I)), None)
picks.append(
hit
if hit is not None
else c[min(len(c) - 1, (k + 1) * len(c) // 3 - 1)]
if c
else None
)
picks.append(bodies.index(ee))
for i in picks:
if i is not None and bodies[i] not in keys:
keys.append(bodies[i])
sites = [_name(m, mujoco.mjtObj.mjOBJ_SITE, s) for s in range(m.nsite)]
foot_ids = [bodies.index(f) for f in feet]
foot_sites = [
s
for i, s in enumerate(sites)
if s
and any(_is_descendant(m, int(m.site_bodyid[i]), f) for f in foot_ids)
and s != IMU_SITE
]
foot_geoms = []
sole = 0.0
for f in foot_ids:
gs = [
g
for g in range(m.ngeom)
if _is_descendant(m, int(m.geom_bodyid[g]), f)
and (m.geom_contype[g] or m.geom_conaffinity[g])
]
foot_geoms += [n for n in (_name(m, mujoco.mjtObj.mjOBJ_GEOM, g) for g in gs) if n]
if gs:
sole = max(sole, float(d.xpos[f][2] - min(_geom_bottom(m, d, g) for g in gs)))
return {
"anchor_body": anchor,
"keybodies": keys,
"ee_bodies": ees,
"feet_bodies": feet,
"foot_sites": foot_sites[: len(feet)]
if len(foot_sites) >= len(feet)
else foot_sites,
"foot_geoms": foot_geoms,
"foot_sole_z": round(sole, 4),
}
[docs]
def inspect_model(path: Path) -> dict:
"""What the Studio's *Add robot* form needs: the tree, joints with suggested PD gains,
sites and a suggested wiring (all computed on the prepared model)."""
spec, info = prepare(path)
m = spec.compile()
keys = {}
src = load_source(path)
try:
sm = src.compile()
for k in range(sm.nkey):
if sm.nq == m.nq:
keys[_name(sm, mujoco.mjtObj.mjOBJ_KEY, k)] = sm.key_qpos[k].copy()
except Exception: # noqa: BLE001
pass
q, key_name = _default_qpos(m, keys)
d = mujoco.MjData(m)
q[0:3] = 0.0
q[3:7] = (1, 0, 0, 0)
d.qpos[:] = q
mujoco.mj_kinematics(m, d)
bottoms = [
_geom_bottom(m, d, g)
for g in range(m.ngeom)
if m.geom_contype[g] or m.geom_conaffinity[g]
]
if not bottoms:
bottoms = [_geom_bottom(m, d, g) for g in range(m.ngeom)]
floor = min(bottoms) if bottoms else 0.0
tops = [float(d.xpos[i][2]) for i in range(1, m.nbody)]
root = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_BODY, info["root"])
w = 2.0 * math.pi * NATURAL_FREQ_HZ
scale = float(m.body_mass.sum()) / G1_MASS
joints = []
for j in range(m.njnt):
if m.jnt_type[j] not in _HINGE:
continue
name = _name(m, mujoco.mjtObj.mjOBJ_JOINT, j)
cls_arm, cls_eff = joint_class(name)
arm = float(m.dof_armature[m.jnt_dofadr[j]]) or cls_arm * scale
eff = float(info["efforts"].get(name, round(cls_eff * scale, 1)))
lo, hi = m.jnt_range[j] if m.jnt_limited[j] else (-math.pi, math.pi)
joints.append(
{
"name": name,
"range": [float(lo), float(hi)],
"default": float(q[m.jnt_qposadr[j]]),
"effort": eff,
"armature": arm,
"armature_from_model": bool(m.dof_armature[m.jnt_dofadr[j]]),
"effort_from_model": name in info["efforts"],
"stiffness": arm * w * w,
"damping": 2.0 * DAMPING_RATIO * arm * w,
}
)
bodies = []
for i in range(1, m.nbody):
bodies.append(
{
"name": _name(m, mujoco.mjtObj.mjOBJ_BODY, i),
"parent": _name(m, mujoco.mjtObj.mjOBJ_BODY, int(m.body_parentid[i])),
"mass": float(m.body_mass[i]),
"z": float(d.xpos[i][2] - floor),
}
)
ncollide = sum(
1 for g in range(m.ngeom) if m.geom_contype[g] or m.geom_conaffinity[g]
)
return {
"model": str(path),
"notes": info["notes"],
"root": info["root"],
"bodies": bodies,
"joints": joints,
"sites": [_name(m, mujoco.mjtObj.mjOBJ_SITE, s) for s in range(m.nsite)],
"keyframe": key_name,
"mass": float(m.body_mass.sum()),
"height": float(max(tops) - floor) if tops else 0.0,
"init_height": round(float(-floor) + 0.01, 3),
"dof": len(joints),
"collision_geoms": ncollide,
"pd": {"natural_freq_hz": NATURAL_FREQ_HZ, "damping_ratio": DAMPING_RATIO},
"suggested": suggest(m, d, root)
| {"nconmax": max(35, 4 * ncollide), "njmax": max(250, 12 * ncollide)},
}
# ---------------------------------------------------------------------- check
[docs]
def settle_test(spec: CustomRobotSpec, seconds: float = 0.5) -> dict:
"""Drop the robot in its initial pose onto a floor and hold the pose with the spec's PD
gains (CPU MuJoCo, no policy) for ``seconds``. Plain PD doesn't balance a humanoid for long
(G1 with its own gains sags after ~1 s), so this checks the setup, not standing:
stable simulation, no start inside the floor, lands on its feet, holds the pose, torque
limits not saturated. Returns ``{"ok", "checks": [{name, ok, text, warn_only?}], …}``
(``ok``: no failed check; a ``warn_only`` check only warns)."""
ms, _ = prepare(spec.source)
ms.worldbody.add_geom(
name="wbc_floor", type=mujoco.mjtGeom.mjGEOM_PLANE, size=[0, 0, 0.05]
)
ms.option.timestep = 0.005
m = ms.compile()
d = mujoco.MjData(m)
kp, kd, eff, target, qa, va = [], [], [], [], [], []
q0 = m.qpos0.copy()
q0[0:3] = (0.0, 0.0, spec.init_height)
q0[3:7] = (1.0, 0.0, 0.0, 0.0)
for j in range(m.njnt):
if m.jnt_type[j] not in _HINGE:
continue
name = _name(m, mujoco.mjtObj.mjOBJ_JOINT, j)
g = spec.joints.get(name) or _default_gains()
adr, dof = int(m.jnt_qposadr[j]), int(m.jnt_dofadr[j])
q0[adr] = spec.init_joint_pos.get(name, q0[adr])
m.dof_armature[dof] = g.armature
qa.append(adr)
va.append(dof)
kp.append(g.stiffness)
kd.append(g.damping)
eff.append(g.effort)
target.append(q0[adr])
kp, kd, eff, target = map(np.asarray, (kp, kd, eff, target))
qa, va = np.asarray(qa, dtype=int), np.asarray(va, dtype=int)
d.qpos[:] = q0
mujoco.mj_forward(m, d)
floor = mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_GEOM, "wbc_floor")
def floor_contacts() -> tuple[set[int], float]:
bodies, depth = set(), 0.0
for c in d.contact[: d.ncon]:
if floor in (c.geom1, c.geom2):
other = c.geom2 if c.geom1 == floor else c.geom1
bodies.add(int(m.geom_bodyid[other]))
depth = max(depth, -float(c.dist))
return bodies, depth
_, penetration = floor_contacts()
z0, saturated, unstable = float(d.qpos[2]), 0.0, False
steps = max(1, int(seconds / m.opt.timestep))
for _ in range(steps):
tau = kp * (target - d.qpos[qa]) - kd * d.qvel[va]
saturated += float(np.mean(np.abs(tau) > eff))
d.qfrc_applied[:] = 0.0
d.qfrc_applied[va] = np.clip(tau, -eff, eff)
mujoco.mj_step(m, d)
if (
not np.isfinite(d.qpos).all()
or d.warning[mujoco.mjtWarning.mjWARN_BADQACC].number
):
unstable = True
break
if unstable:
return {
"ok": False,
"checks": [
{
"name": "stable",
"ok": False,
"text": "the simulation blew up: lower the gains or raise the armature",
}
],
}
z1 = float(d.qpos[2])
rot = np.zeros(9)
mujoco.mju_quat2Mat(rot, d.qpos[3:7])
tilt = math.degrees(math.acos(float(np.clip(rot[8], -1.0, 1.0))))
touching, _ = floor_contacts()
feet = {mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_BODY, f) for f in spec.feet_bodies}
on_feet = {b for b in touching if any(_is_descendant(m, b, f) for f in feet)}
others = sorted(_name(m, mujoco.mjtObj.mjOBJ_BODY, b) for b in touching - on_feet)
sag = float(np.max(np.abs(d.qpos[qa] - target))) if len(qa) else 0.0
sat = saturated / steps
checks = [
{
"name": "stable",
"ok": True,
"text": f"{seconds:g} s simulated without blowing up",
},
{
"name": "start",
"ok": penetration < 0.02,
"text": f"starts {penetration * 100:.1f} cm inside the floor: raise the init height"
if penetration >= 0.02
else "starts on or just above the floor",
},
{
"name": "feet",
"ok": bool(on_feet) and not others,
"text": "lands on its feet"
if on_feet and not others
else f"touches the floor with {', '.join(others) or 'nothing'}: check the init height, pose and feet",
},
{
"name": "holds",
"ok": z1 > 0.8 * z0 and tilt < 20.0 and sag < 0.6,
"warn_only": True, # plain PD isn't expected to balance for long
"text": f"root {z0:.2f} → {z1:.2f} m, tilt {tilt:.0f}°, joints within {math.degrees(sag):.0f}° of the pose"
+ (
""
if z1 > 0.8 * z0 and tilt < 20.0 and sag < 0.6
else ": sags fast — stiffer gains or a bent-knee pose may help"
),
},
{
"name": "torque",
"ok": sat < 0.2,
"text": f"torque limits saturated {sat * 100:.0f}% of the time"
+ (": raise the efforts" if sat >= 0.2 else ""),
},
]
return {
"ok": all(c["ok"] or c.get("warn_only") for c in checks),
"checks": checks,
"root_start": z0,
"root_end": z1,
"tilt_deg": tilt,
}
# ---------------------------------------------------------------------- storage
def list_specs() -> list[CustomRobotSpec]:
out = []
d = robots_dir()
for p in sorted(d.glob(f"*/{SPEC_FILE}")) if d.is_dir() else []:
try:
out.append(load_spec(p.parent.name))
except Exception as exc: # noqa: BLE001
print(f"[wbc-mjlab] custom robot {p.parent.name}: {exc}")
return out
[docs]
def load_spec(robot_id: str) -> CustomRobotSpec:
path = robots_dir() / check_id(robot_id) / SPEC_FILE
return CustomRobotSpec.from_dict(yaml.safe_load(path.read_text()) or {})
[docs]
def save_spec(spec: CustomRobotSpec) -> CustomRobotSpec:
"""Validate, write ``wbc_robot.xml`` + ``robot.yaml`` and (re)register the robot."""
check_id(spec.id)
from wbc_mjlab.robots.ids import known_robot_ids
if spec.id in ("g1",) or (
spec.id in known_robot_ids() and spec.id not in {s.id for s in list_specs()}
):
raise ValueError(f"{spec.id!r} is already a robot of wbc-mjlab or an extension")
if not spec.source.is_file():
raise ValueError(f"model not found: {spec.source}")
prepared, _ = prepare(spec.source)
m = prepared.compile()
bodies = {_name(m, mujoco.mjtObj.mjOBJ_BODY, i) for i in range(m.nbody)}
for label, names in (
("anchor", [spec.anchor_body]),
("keybodies", spec.keybodies),
("end-effectors", spec.ee_bodies),
("feet", spec.feet_bodies),
):
missing = [n for n in names if n not in bodies]
if missing:
raise ValueError(f"{label}: no body {', '.join(missing)} in the model")
if not spec.keybodies or not spec.feet_bodies:
raise ValueError("pick at least the keybodies and the feet")
sites = {_name(m, mujoco.mjtObj.mjOBJ_SITE, s) for s in range(m.nsite)}
spec.foot_sites = [s for s in spec.foot_sites if s in sites]
geoms = {_name(m, mujoco.mjtObj.mjOBJ_GEOM, g) for g in range(m.ngeom)}
spec.foot_geoms = [g for g in spec.foot_geoms if g in geoms]
joints = {
_name(m, mujoco.mjtObj.mjOBJ_JOINT, j)
for j in range(m.njnt)
if m.jnt_type[j] in _HINGE
}
unknown = set(spec.joints) - joints
if unknown:
raise ValueError(f"gains for unknown joints: {', '.join(sorted(unknown))}")
for j in joints - set(spec.joints):
spec.joints[j] = _default_gains()
for g in spec.joints.values():
if g.stiffness <= 0:
raise ValueError("every joint needs a stiffness > 0")
spec.folder.mkdir(parents=True, exist_ok=True)
spec.prepared.write_text(prepared.to_xml())
(spec.folder / SPEC_FILE).write_text(yaml.safe_dump(spec.to_dict(), sort_keys=False))
register(spec)
return spec
def delete(robot_id: str) -> None:
folder = robots_dir() / check_id(robot_id)
if folder.is_dir():
shutil.rmtree(folder)
_unregister(robot_id)
def _default_gains() -> JointGains:
w = 2.0 * math.pi * NATURAL_FREQ_HZ
return JointGains(
effort=DEFAULT_EFFORT,
stiffness=DEFAULT_ARMATURE * w * w,
damping=2.0 * DAMPING_RATIO * DEFAULT_ARMATURE * w,
armature=DEFAULT_ARMATURE,
)
# ---------------------------------------------------------------------- env config
def _entity_cfg(spec: CustomRobotSpec):
from mjlab.actuator.pd_actuator import IdealPdActuatorCfg
from mjlab.entity import EntityArticulationInfoCfg, EntityCfg
path = str(spec.prepared)
actuators = tuple(
IdealPdActuatorCfg(
target_names_expr=(f"^{re.escape(name)}$",),
stiffness=float(g.stiffness),
damping=float(g.damping),
effort_limit=float(g.effort),
armature=float(g.armature),
)
for name, g in sorted(spec.joints.items())
)
return EntityCfg(
init_state=EntityCfg.InitialStateCfg(
pos=(0.0, 0.0, float(spec.init_height)),
joint_pos={f"^{re.escape(k)}$": float(v) for k, v in spec.init_joint_pos.items()}
or {".*": 0.0},
joint_vel={".*": 0.0},
),
spec_fn=lambda: mujoco.MjSpec.from_file(path),
articulation=EntityArticulationInfoCfg(
actuators=actuators, soft_joint_pos_limit_factor=0.9
),
)
def base_env_cfg(spec: CustomRobotSpec):
"""The shared WBC template wired to this robot (what ``g1_base_cfg`` does for G1)."""
from mjlab.managers.observation_manager import ObservationTermCfg
from mjlab.managers.scene_entity_config import SceneEntityCfg
from mjlab.sensor import ContactMatch, ContactSensorCfg
import wbc_mjlab.env.mdp as mdp
from wbc_mjlab.env.wbc_env_cfg import make_base_wbc_env_cfg
cfg = make_base_wbc_env_cfg(use_reference_residual_action=True)
cfg.scene.entities = {"robot": _entity_cfg(spec)}
feet = "|".join(re.escape(f) for f in spec.feet_bodies)
root = _root_name(spec)
keys = tuple(spec.keybodies)
cfg.scene.sensors = (
ContactSensorCfg(
name="feet_ground_contact",
primary=ContactMatch(mode="subtree", pattern=rf"^({feet})$", entity="robot"),
secondary=ContactMatch(mode="body", pattern="terrain"),
fields=("found", "force"),
reduce="netforce",
num_slots=1,
track_air_time=True,
),
ContactSensorCfg(
name="self_collision",
primary=ContactMatch(mode="subtree", pattern=re.escape(root), entity="robot"),
secondary=ContactMatch(mode="subtree", pattern=re.escape(root), entity="robot"),
fields=("found", "force"),
reduce="none",
num_slots=1,
history_length=4,
),
ContactSensorCfg(
name="keybodies_ground_contact",
primary=ContactMatch(
mode="body",
pattern=r"^(" + "|".join(re.escape(k) for k in keys) + r")$",
entity="robot",
),
secondary=ContactMatch(mode="body", pattern="terrain"),
fields=("found", "force"),
reduce="netforce",
num_slots=len(keys),
track_air_time=False,
),
)
cfg.actions["joint_pos"].scale = {
f"^{re.escape(n)}$": spec.action_scale * g.effort / g.stiffness
for n, g in spec.joints.items()
}
for group in ("actor", "critic"):
terms = cfg.observations[group].terms
for name, sensor in (("base_ang_vel", GYRO), ("base_lin_vel", VELOCIMETER)):
t = terms.get(name)
if t is not None and "sensor_name" in (t.params or {}):
t.params["sensor_name"] = f"robot/{sensor}"
motion = cfg.commands["motion"]
motion.anchor_body_name = spec.anchor_body
motion.body_names = keys
anchor = (spec.anchor_body,)
ev = cfg.events
ev["assistive_wrench"].params["asset_cfg"].body_names = anchor
ev["assistive_wrench"].params["body_name"] = spec.anchor_body
ev["pull_robot"].params["asset_cfg"].body_names = anchor
ev["base_com"].params["asset_cfg"].body_names = anchor
if spec.foot_geoms:
ev["foot_friction"].params["asset_cfg"].geom_names = tuple(
f"^{re.escape(g)}$" for g in spec.foot_geoms
)
else:
ev.pop("foot_friction", None)
cfg.terminations["ee_body_pos"].params["body_names"] = tuple(spec.ee_bodies)
cfg.observations["critic"].terms["keybody_contact_forces"] = ObservationTermCfg(
func=mdp.keybody_contact_forces,
params={"sensor_name": "keybodies_ground_contact"},
)
if spec.foot_sites:
cfg.rewards["foot_slip"].params["asset_cfg"] = SceneEntityCfg(
"robot", site_names=tuple(spec.foot_sites)
)
else:
cfg.rewards.pop("foot_slip", None)
cfg.sim.nconmax = int(spec.nconmax)
cfg.sim.njmax = int(spec.njmax)
cfg.viewer.body_name = spec.anchor_body
cfg.viewer.distance = max(2.5, 2.2 * float(spec.init_height) + 1.0)
cfg.viewer.elevation = -5.0
cfg.viewer.azimuth = 120.0
return cfg
def _root_name(spec: CustomRobotSpec) -> str:
text = spec.prepared.read_text() if spec.prepared.is_file() else ""
m = re.search(r"<worldbody>\s*<body[^>]*name=\"([^\"]+)\"", text)
return m.group(1) if m else spec.anchor_body
[docs]
def env_cfg(
spec: CustomRobotSpec, *, se: bool = False, terrain: bool = False, play: bool = False
):
"""``Wbc-<Id>`` (``se``: ``-SE``, ``terrain``: ``-Terrain-Scan`` — procedural curriculum
terrain, a height scan under the root body and terrain-aware terminations, as G1)."""
from wbc_mjlab.presets.se_actor import apply_se_actor
from wbc_mjlab.presets.terrain import apply_terrain_scan
from wbc_mjlab.presets.wbc import apply_wbc
cfg = base_env_cfg(spec)
apply_wbc(
cfg,
motion_body_names=tuple(spec.keybodies),
ee_termination_bodies=tuple(spec.ee_bodies),
)
if se:
apply_se_actor(cfg)
for group in ("actor", "critic"):
t = cfg.observations[group].terms.get("base_lin_vel")
if t is not None and "sensor_name" in (t.params or {}):
t.params["sensor_name"] = f"robot/{VELOCIMETER}"
if terrain:
apply_terrain_scan(
cfg,
scan_body_name=_root_name(spec),
scan_offset=float(spec.init_height), # root height standing on flat ground
ee_termination_bodies=tuple(spec.ee_bodies),
)
cfg.sim.nconmax = max(cfg.sim.nconmax, int(spec.nconmax))
cfg.sim.njmax = max(cfg.sim.njmax, int(spec.njmax))
if play:
if terrain: # no curriculum in play: spread envs over every difficulty row
cfg.scene.terrain.max_init_terrain_level = None
cfg.episode_length_s = int(1e9)
cfg.observations["actor"].enable_corruption = False
cfg.curriculum = {}
cfg.events.pop("push_robot", None)
motion = cfg.commands["motion"]
motion.pose_range = {}
motion.velocity_range = {}
motion.assistive_wrench_enabled = False
if "assistive_wrench" in cfg.events:
cfg.events["assistive_wrench"].params["enabled"] = False
return cfg
# ---------------------------------------------------------------------- registration
def tasks_for(spec: CustomRobotSpec):
from wbc_mjlab.tasks.config import WbcTaskConfig
tid = spec.task_id
base = spec.description or f"custom robot {spec.id}"
return (
WbcTaskConfig(
task_id=tid,
robot_id=spec.id,
description=f"{base}: default WBC tracking + RSI.",
experiment_name=f"wbc_{spec.id}",
build_env_cfg=lambda: env_cfg(spec),
),
WbcTaskConfig(
task_id=f"{tid}-Terrain-Scan",
robot_id=spec.id,
description=f"{tid} on curriculum terrain + actor height scan; terrain-aware terminations.",
experiment_name=f"wbc_{spec.id}_terrain_scan",
build_env_cfg=lambda: env_cfg(spec, terrain=True),
),
WbcTaskConfig(
task_id=f"{tid}-SE",
robot_id=spec.id,
description=f"{tid} + SE obs (anchor pose tracking error, IMU base lin vel).",
experiment_name=f"wbc_{spec.id}_se",
build_env_cfg=lambda: env_cfg(spec, se=True),
),
)
def _unregister(robot_id: str) -> None:
from mjlab.tasks import registry as mj_registry
from wbc_mjlab import tasks as wbc_tasks
for tid in [t for t, c in wbc_tasks._TASK_BY_ID.items() if c.robot_id == robot_id]: # noqa: SLF001
wbc_tasks._TASK_BY_ID.pop(tid, None) # noqa: SLF001
wbc_tasks._MJLAB_REGISTERED.discard(tid) # noqa: SLF001
mj_registry._REGISTRY.pop(tid, None) # noqa: SLF001
[docs]
def register(spec: CustomRobotSpec) -> None:
"""Register (or re-register after an edit) one custom robot and its tasks."""
from wbc_mjlab.extension import WbcRobotSpec, register_wbc_extension
from wbc_mjlab.motion.robot_assets import RobotMotionSpec
from wbc_mjlab.robots.g1.rl_cfg import g1_wbc_rl_cfg
_unregister(spec.id)
by_id = {t.task_id: t for t in tasks_for(spec)}
def make_env_cfg(*, play: bool = False, task_id: str = spec.task_id, **kwargs):
if kwargs:
raise TypeError(f"unknown env cfg kwargs for {spec.id}: {', '.join(kwargs)}")
if task_id not in by_id:
raise KeyError(f"unknown task {task_id!r} for {spec.id}: {', '.join(by_id)}")
return env_cfg(
spec,
se=task_id.endswith("-SE"),
terrain=task_id.endswith("-Terrain-Scan"),
play=play,
)
register_wbc_extension(
WbcRobotSpec(
robot_id=spec.id,
make_env_cfg=make_env_cfg,
make_rl_cfg=g1_wbc_rl_cfg,
motion_spec=RobotMotionSpec(
scene_cfg_fn=lambda: env_cfg(spec).scene,
foot_body_names=tuple(spec.feet_bodies),
foot_sole_z=float(spec.foot_sole_z),
mjcf_path=spec.prepared,
),
mjcf_path=spec.prepared,
),
tuple(by_id.values()),
)
[docs]
def register_all() -> list[str]:
"""Register every custom robot (a broken one is reported and skipped)."""
done = []
for spec in list_specs():
try:
if not spec.prepared.is_file():
raise FileNotFoundError(f"{spec.prepared} is missing: re-save the robot")
register(spec)
done.append(spec.id)
except Exception as exc: # noqa: BLE001
print(f"[wbc-mjlab] custom robot {spec.id} not registered: {exc}")
return done
__all__ = [
"CustomRobotSpec",
"JointGains",
"delete",
"env_cfg",
"inspect_model",
"list_specs",
"load_spec",
"prepare",
"register",
"register_all",
"robots_dir",
"save_spec",
"task_id_for",
]