Files
2026-11-02 17:10:39 +08:00

152 lines
5.4 KiB
Python

"""Standalone S10 MJCF/FK checks that do not require mjlab or torch.
Run with the system Python:
python3 tests/test_s10_model.py
The test validates:
1. The S10 model in ``s10_supermimic/robot/model/s10.xml`` loads.
2. Joint names match the training/deployment contract.
3. The default pose puts the wheel bottom at ~0.434 m above the ground.
4. The analytic wheel-bottom formula in ``core/rolling_rewards.py`` matches
MuJoCo forward kinematics for random joint angles and arbitrary base tilt.
"""
from __future__ import annotations
import math
from pathlib import Path
import mujoco
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
MODEL = ROOT / "s10_supermimic" / "robot" / "model" / "s10.xml"
LEGS = ("fl", "fr", "hl", "hr")
LEG_JOINT_NAMES = tuple(
f"{leg}_{joint}_joint" for leg in LEGS for joint in ("hipx", "hipy", "knee")
)
WHEEL_JOINT_NAMES = tuple(f"{leg}_wheel_joint" for leg in LEGS)
DEFAULT_Q = {
"fl": (0.1, -0.35, 0.65),
"fr": (-0.1, -0.35, 0.65),
"hl": (0.1, 0.35, -0.65),
"hr": (-0.1, 0.35, -0.65),
}
# Geometry from S10.xml.
X0 = {"fl": 0.2277, "fr": 0.2277, "hl": -0.2277, "hr": -0.2277}
Y0 = {"fl": 0.0574, "fr": -0.0574, "hl": 0.0574, "hr": -0.0574}
SIDE = {"fl": 1.0, "fr": -1.0, "hl": 1.0, "hr": -1.0}
HIPY_Y = 0.0779
WHEEL_Y = 0.045891
LINK = 0.18
WHEEL_RADIUS = 0.081
WHEEL_HALF_WIDTH = 0.0185
def analytic_wheel_bottom(q, leg, base_z=0.5, up=(0.0, 0.0, 1.0)) -> float:
"""NumPy translation of the torch implementation in rolling_rewards.py."""
hipx, hipy, knee = (float(v) for v in q)
side = SIDE[leg]
x = LINK * math.sin(knee)
z = -LINK - LINK * math.cos(knee)
x3 = math.cos(hipy) * x - math.sin(hipy) * z
z3 = math.sin(hipy) * x + math.cos(hipy) * z
y3 = side * (HIPY_Y + WHEEL_Y)
y4 = math.cos(hipx) * y3 + math.sin(hipx) * z3
z4 = -math.sin(hipx) * y3 + math.cos(hipx) * z3
point = np.array([X0[leg] + x3, Y0[leg] + y4, z4])
up = np.asarray(up, dtype=float)
up /= np.linalg.norm(up)
axle_z = -up[1] * math.cos(hipx) + up[2] * math.sin(hipx)
radius_z = WHEEL_RADIUS * math.sqrt(max(1.0 - axle_z * axle_z, 0.0))
radius_z += WHEEL_HALF_WIDTH * abs(axle_z)
return base_z + float(point @ up) - radius_z
def wheel_geom_id(model: mujoco.MjModel, leg: str) -> int:
body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, f"{leg}_wheel")
for geom_id in range(model.ngeom):
if model.geom_bodyid[geom_id] != body_id:
continue
if model.geom_type[geom_id] == mujoco.mjtGeom.mjGEOM_CYLINDER:
return geom_id
raise AssertionError(f"No cylinder collision geom for {leg}_wheel")
def measured_wheel_bottom(model, data, geom_id: int) -> float:
axis = data.geom_xmat[geom_id].reshape(3, 3)[:, 2]
axle_z = abs(float(axis[2]))
radius_z = WHEEL_RADIUS * math.sqrt(max(1.0 - axle_z * axle_z, 0.0))
radius_z += WHEEL_HALF_WIDTH * axle_z
return float(data.geom_xpos[geom_id][2] - radius_z)
def set_pose(model, data, q_map, base_z=0.5, quat=(1.0, 0.0, 0.0, 0.0)):
data.qpos[:] = 0.0
data.qpos[2] = base_z
data.qpos[3:7] = quat
for joint_name, value in q_map.items():
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, joint_name)
data.qpos[model.jnt_qposadr[joint_id]] = value
mujoco.mj_forward(model, data)
def main() -> None:
assert MODEL.is_file(), MODEL
model = mujoco.MjModel.from_xml_path(str(MODEL))
data = mujoco.MjData(model)
joint_names = [model.joint(i).name for i in range(model.njnt)]
expected = ("floating_base",) + LEG_JOINT_NAMES + WHEEL_JOINT_NAMES
assert set(joint_names) == set(expected), joint_names
assert model.nq == 23 and model.nv == 22 and model.njnt == 17
assert model.nu == 0, "Robot XML must not contain actuators; mjlab adds them."
default_q = {
f"{leg}_{joint}_joint": value
for leg in LEGS
for joint, value in zip(("hipx", "hipy", "knee"), DEFAULT_Q[leg])
}
set_pose(model, data, default_q)
for leg in LEGS:
geom_id = wheel_geom_id(model, leg)
bottom = measured_wheel_bottom(model, data, geom_id)
pred = analytic_wheel_bottom(DEFAULT_Q[leg], leg)
assert abs(bottom - pred) < 1e-9, (leg, bottom, pred)
assert abs((0.5 - bottom) - 0.434145) < 1e-4, (leg, bottom)
rng = np.random.default_rng(0)
max_error = 0.0
for _ in range(200):
quat = rng.normal(size=4)
quat /= np.linalg.norm(quat)
q_map = {}
q_by_leg = {}
for leg in LEGS:
q = np.array([
rng.uniform(-0.6109, 0.6109),
rng.uniform(-2.5307, 2.5307),
rng.uniform(-2.7227, 2.7227),
])
q_by_leg[leg] = q
for joint, value in zip(("hipx", "hipy", "knee"), q):
q_map[f"{leg}_{joint}_joint"] = value
set_pose(model, data, q_map, base_z=0.5, quat=quat)
rotation = data.xmat[
mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "base_link")
].reshape(3, 3)
up = rotation.T @ np.array([0.0, 0.0, 1.0])
for leg in LEGS:
geom_id = wheel_geom_id(model, leg)
measured = measured_wheel_bottom(model, data, geom_id)
pred = analytic_wheel_bottom(q_by_leg[leg], leg, base_z=0.5, up=up)
max_error = max(max_error, abs(measured - pred))
assert max_error < 1e-9, max_error
print(f"S10 model/FK checks passed (max error {max_error:.2e} m)")
if __name__ == "__main__":
main()