Some checks failed
nightly / Test against latest dependencies (py3.10) (push) Has been cancelled
nightly / Test against latest dependencies (py3.13) (push) Has been cancelled
tests / tests (3.13, locked) (push) Has been cancelled
tests / tests (3.13, unlocked) (push) Has been cancelled
tests / pyright (3.10) (push) Has been cancelled
tests / lint-format (push) Has been cancelled
tests / tests (3.10, locked) (push) Has been cancelled
tests / tests (3.11, locked) (push) Has been cancelled
tests / tests (3.12, locked) (push) Has been cancelled
tests / pyright (3.11) (push) Has been cancelled
tests / pyright (3.12) (push) Has been cancelled
tests / pyright (3.13) (push) Has been cancelled
tests / ty-check (3.10) (push) Has been cancelled
tests / ty-check (3.11) (push) Has been cancelled
tests / ty-check (3.12) (push) Has been cancelled
tests / ty-check (3.13) (push) Has been cancelled
tests / stubs (push) Has been cancelled
tests / smoke-test (push) Has been cancelled
Docker / check_paths (push) Has been cancelled
docs / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Upstream: https://github.com/michaelgillett/mjlab Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047 Upstream-Branch: main
2470 lines
75 KiB
Python
2470 lines
75 KiB
Python
"""Tests for the dr.* model-field randomization engine."""
|
||
|
||
import math
|
||
|
||
import mujoco
|
||
import pytest
|
||
import torch
|
||
from conftest import get_test_device
|
||
|
||
from mjlab.entity import EntityCfg
|
||
from mjlab.envs.mdp import dr
|
||
from mjlab.managers.scene_entity_config import SceneEntityCfg
|
||
from mjlab.scene import Scene, SceneCfg
|
||
from mjlab.sim.sim import Simulation, SimulationCfg
|
||
from mjlab.utils.lab_api.math import quat_from_euler_xyz, quat_mul
|
||
|
||
pytestmark = pytest.mark.filterwarnings(
|
||
"ignore:Use of index_put_ on expanded tensors is deprecated:UserWarning"
|
||
)
|
||
|
||
# Shared helpers.
|
||
|
||
ROBOT_XML = """
|
||
<mujoco>
|
||
<worldbody>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="base_geom" type="box" size="0.1 0.1 0.1" mass="1.0"
|
||
friction="0.5 0.01 0.005"/>
|
||
<site name="base_site" pos="0 0 0.1"/>
|
||
<camera name="front_cam" pos="0.5 0 0.3" xyaxes="0 -1 0 0 0 1" fovy="60"/>
|
||
<camera name="side_cam" pos="0 0.5 0.3" xyaxes="1 0 0 0 0 1" fovy="45"/>
|
||
<light name="top_light" pos="0 0 2" dir="0 0 -1"/>
|
||
<body name="foot1" pos="0.2 0 0">
|
||
<joint name="joint1" type="hinge" axis="0 0 1" range="0 1.57"/>
|
||
<geom name="foot1_geom" type="box" size="0.05 0.05 0.05" mass="0.1"
|
||
friction="0.5 0.01 0.005"/>
|
||
</body>
|
||
<body name="foot2" pos="-0.2 0 0">
|
||
<joint name="joint2" type="hinge" axis="0 0 1" range="0 1.57"/>
|
||
<geom name="foot2_geom" type="box" size="0.05 0.05 0.05" mass="0.1"
|
||
friction="0.5 0.01 0.005"/>
|
||
</body>
|
||
<body name="offset_body" pos="0 0.3 0">
|
||
<joint name="joint3" type="hinge" axis="1 0 0" range="-1.57 1.57"/>
|
||
<inertial pos="0.05 -0.02 0.03" mass="0.5"
|
||
fullinertia="0.001 0.002 0.0015 0.0001 -0.00005 0.00008"/>
|
||
<geom name="offset_geom" type="sphere" size="0.04"/>
|
||
</body>
|
||
</body>
|
||
</worldbody>
|
||
</mujoco>
|
||
"""
|
||
|
||
NUM_ENVS = 4
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def device():
|
||
return get_test_device()
|
||
|
||
|
||
class Env:
|
||
def __init__(self, scene, sim, device):
|
||
self.scene = scene
|
||
self.sim = sim
|
||
self.num_envs = scene.num_envs
|
||
self.device = device
|
||
|
||
|
||
def create_test_env(
|
||
device, num_envs=NUM_ENVS, expand_fields=("geom_friction", "dof_damping")
|
||
):
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(ROBOT_XML))
|
||
scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
|
||
sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
if expand_fields:
|
||
sim.expand_model_fields(expand_fields)
|
||
|
||
return Env(scene, sim, device)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def env(device):
|
||
return create_test_env(device)
|
||
|
||
|
||
# Operations: abs, scale, add.
|
||
|
||
|
||
def test_abs_operation(env):
|
||
"""Values set directly within range, diversity across envs."""
|
||
torch.manual_seed(42)
|
||
robot = env.scene["robot"]
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.3, 1.2),
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
)
|
||
|
||
friction = env.sim.model.geom_friction[:, robot.indexing.geom_ids, 0]
|
||
assert torch.all((friction >= 0.3) & (friction <= 1.2))
|
||
assert len(torch.unique(friction)) >= 2
|
||
|
||
|
||
def test_scale_operation(env):
|
||
"""Values = default * sample, within expected bounds."""
|
||
torch.manual_seed(42)
|
||
robot = env.scene["robot"]
|
||
geom_idx = robot.indexing.geom_ids[0]
|
||
|
||
default_val = env.sim.get_default_field("geom_friction")[geom_idx, 0].item()
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.5, 2.0),
|
||
operation="scale",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
result = env.sim.model.geom_friction[:, geom_idx, 0]
|
||
assert torch.all(
|
||
(result >= default_val * 0.5 - 1e-5) & (result <= default_val * 2.0 + 1e-5)
|
||
)
|
||
|
||
|
||
def test_add_operation(env):
|
||
"""Values = default + sample, within expected bounds."""
|
||
torch.manual_seed(42)
|
||
robot = env.scene["robot"]
|
||
geom_idx = robot.indexing.geom_ids[0]
|
||
|
||
default_val = env.sim.get_default_field("geom_friction")[geom_idx, 0].item()
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(-0.1, 0.1),
|
||
operation="add",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
result = env.sim.model.geom_friction[:, geom_idx, 0]
|
||
assert torch.all(
|
||
(result >= default_val - 0.1 - 1e-5) & (result <= default_val + 0.1 + 1e-5)
|
||
)
|
||
|
||
|
||
# No accumulation (scale and add share the same mechanism).
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"operation, fixed_val, expected_fn",
|
||
[
|
||
("scale", (2.0, 2.0), lambda d: d * 2.0),
|
||
("add", (0.1, 0.1), lambda d: d + 0.1),
|
||
],
|
||
)
|
||
def test_no_accumulation(device, operation, fixed_val, expected_fn):
|
||
"""3x with fixed value produces same result as 1x (uses defaults)."""
|
||
env = create_test_env(device, num_envs=2)
|
||
robot = env.scene["robot"]
|
||
geom_idx = robot.indexing.geom_ids[0]
|
||
|
||
default_friction = env.sim.get_default_field("geom_friction")[geom_idx, 0].item()
|
||
|
||
for _ in range(3):
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=fixed_val,
|
||
operation=operation,
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
final = env.sim.model.geom_friction[0, geom_idx, 0].item()
|
||
assert abs(final - expected_fn(default_friction)) < 1e-5
|
||
|
||
|
||
# Distributions.
|
||
|
||
|
||
def test_log_uniform_distribution(device):
|
||
"""Log-uniform produces values in [lo, hi], skewed toward lower."""
|
||
torch.manual_seed(42)
|
||
env = create_test_env(device, num_envs=64)
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.1, 10.0),
|
||
operation="abs",
|
||
distribution="log_uniform",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
vals = env.sim.model.geom_friction[:, env.scene["robot"].indexing.geom_ids[0], 0]
|
||
assert torch.all((vals >= 0.1 - 1e-5) & (vals <= 10.0 + 1e-5))
|
||
# Geometric mean of log-uniform(0.1, 10) is 1.0, well below (0.1+10)/2=5.05.
|
||
assert vals.median() < (0.1 + 10.0) / 2
|
||
|
||
|
||
def test_gaussian_distribution(device):
|
||
"""Gaussian: mean/std interpretation, values cluster around mean."""
|
||
torch.manual_seed(42)
|
||
env = create_test_env(device, num_envs=128)
|
||
|
||
mean, std = 0.5, 0.05
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(mean, std),
|
||
operation="abs",
|
||
distribution="gaussian",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
vals = env.sim.model.geom_friction[:, env.scene["robot"].indexing.geom_ids[0], 0]
|
||
assert abs(vals.mean().item() - mean) < 0.05
|
||
|
||
|
||
# Axes.
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"axes, changed, unchanged",
|
||
[
|
||
(None, [0], [1, 2]), # Default axes for geom_friction = [0]
|
||
([1, 2], [1, 2], [0]), # Explicit axes
|
||
],
|
||
)
|
||
def test_axes_selectivity(device, axes, changed, unchanged):
|
||
"""Only the specified axes are modified; others stay at defaults."""
|
||
env = create_test_env(device, num_envs=2)
|
||
robot = env.scene["robot"]
|
||
geom_idx = robot.indexing.geom_ids[0]
|
||
|
||
default_friction = env.sim.get_default_field("geom_friction")[geom_idx].clone()
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(2.0, 2.0),
|
||
operation="scale",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=axes,
|
||
)
|
||
|
||
final = env.sim.model.geom_friction[0, geom_idx]
|
||
for ax in changed:
|
||
assert abs(final[ax] - default_friction[ax] * 2.0) < 1e-5
|
||
for ax in unchanged:
|
||
assert abs(final[ax] - default_friction[ax]) < 1e-5
|
||
|
||
|
||
def test_dict_int_axis_ranges(device):
|
||
"""{0: (lo, hi), 1: (lo2, hi2)} per-axis ranges."""
|
||
torch.manual_seed(42)
|
||
env = create_test_env(device, num_envs=2)
|
||
robot = env.scene["robot"]
|
||
geom_idx = robot.indexing.geom_ids[0]
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges={0: (0.3, 0.4), 1: (0.001, 0.002)},
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
)
|
||
|
||
final = env.sim.model.geom_friction[0, geom_idx]
|
||
assert 0.3 - 1e-5 <= final[0].item() <= 0.4 + 1e-5
|
||
assert 0.001 - 1e-5 <= final[1].item() <= 0.002 + 1e-5
|
||
|
||
|
||
def test_invalid_axes_raises(env):
|
||
"""axes outside valid_axes raises ValueError."""
|
||
with pytest.raises(ValueError, match="Invalid axes"):
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.3, 1.2),
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[3], # geom_friction valid_axes=[0,1,2]
|
||
)
|
||
|
||
|
||
# String-keyed ranges.
|
||
|
||
|
||
def test_string_keyed_ranges(env):
|
||
"""Per-component DR with regex patterns."""
|
||
torch.manual_seed(42)
|
||
dr.joint_damping(
|
||
env,
|
||
env_ids=None,
|
||
ranges={".*joint1": (0.5, 0.5), ".*joint2": (1.5, 1.5)},
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", joint_names=(".*",)),
|
||
)
|
||
|
||
robot = env.scene["robot"]
|
||
dof_adr = robot.indexing.joint_v_adr
|
||
damping = env.sim.model.dof_damping[0, dof_adr]
|
||
assert abs(damping[0].item() - 0.5) < 1e-5
|
||
assert abs(damping[1].item() - 1.5) < 1e-5
|
||
|
||
|
||
def test_string_keyed_ranges_no_match_raises(env):
|
||
"""Pattern matching no names raises ValueError."""
|
||
with pytest.raises(ValueError, match="matched no"):
|
||
dr.joint_damping(
|
||
env,
|
||
env_ids=None,
|
||
ranges={"nonexistent_joint": (0.5, 1.5)},
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", joint_names=(".*",)),
|
||
)
|
||
|
||
|
||
# Shared random.
|
||
|
||
|
||
def test_shared_random(env):
|
||
"""All entities within same env get same value, envs differ."""
|
||
torch.manual_seed(42)
|
||
robot = env.scene["robot"]
|
||
geom_ids = robot.indexing.geom_ids
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.3, 1.2),
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
shared_random=True,
|
||
)
|
||
|
||
friction = env.sim.model.geom_friction[:, geom_ids, 0]
|
||
|
||
for env_idx in range(env.num_envs):
|
||
env_friction = friction[env_idx]
|
||
assert torch.allclose(env_friction, env_friction[0].expand_as(env_friction))
|
||
|
||
env_frictions = friction[:, 0]
|
||
assert len(torch.unique(env_frictions)) > 1
|
||
assert torch.all((friction >= 0.3) & (friction <= 1.2))
|
||
|
||
|
||
# Edge cases.
|
||
|
||
|
||
def test_single_env_without_expand(device):
|
||
"""num_envs=1 works without expand_model_fields, no accumulation."""
|
||
env = create_test_env(device, num_envs=1, expand_fields=())
|
||
robot = env.scene["robot"]
|
||
geom_idx = robot.indexing.geom_ids[0]
|
||
|
||
original_friction = env.sim.model.geom_friction[0, geom_idx, 0].item()
|
||
|
||
for _ in range(2):
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(2.0, 2.0),
|
||
operation="scale",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
final = env.sim.model.geom_friction[0, geom_idx, 0].item()
|
||
assert abs(final - original_friction * 2.0) < 1e-5
|
||
|
||
|
||
def test_partial_env_ids(env):
|
||
"""Randomizing subset of envs leaves others unchanged."""
|
||
torch.manual_seed(42)
|
||
robot = env.scene["robot"]
|
||
geom_idx = robot.indexing.geom_ids[0]
|
||
|
||
original = env.sim.model.geom_friction[:, geom_idx, 0].clone()
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=torch.tensor([0, 2], device=env.device),
|
||
ranges=(0.1, 0.2),
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
result = env.sim.model.geom_friction[:, geom_idx, 0]
|
||
# Envs 0,2 should have changed.
|
||
assert torch.all((result[0] >= 0.1) & (result[0] <= 0.2))
|
||
assert torch.all((result[2] >= 0.1) & (result[2] <= 0.2))
|
||
# Envs 1,3 should be unchanged.
|
||
assert result[1] == original[1]
|
||
assert result[3] == original[3]
|
||
|
||
|
||
# CUDA graph.
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
not torch.cuda.is_available(), reason="CUDA required for graph capture"
|
||
)
|
||
def test_expand_model_fields_recreates_cuda_graph(device):
|
||
"""Verify CUDA graph is recreated after expand_model_fields."""
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(ROBOT_XML))
|
||
scene_cfg = SceneCfg(num_envs=NUM_ENVS, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
|
||
sim = Simulation(num_envs=NUM_ENVS, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
|
||
if not sim.use_cuda_graph:
|
||
pytest.skip("CUDA graph capture not enabled on this device")
|
||
|
||
original_step_graph = sim.step_graph
|
||
|
||
sim.expand_model_fields(("geom_friction",))
|
||
|
||
assert sim.step_graph is not original_step_graph, (
|
||
"CUDA graph was not recreated after expand_model_fields"
|
||
)
|
||
|
||
|
||
# Integration test.
|
||
|
||
|
||
@pytest.mark.slow
|
||
def test_g1_foot_friction_shared_across_geoms(device):
|
||
"""G1 velocity env has uniform foot friction across all collision geoms."""
|
||
import io
|
||
import warnings
|
||
from contextlib import redirect_stderr, redirect_stdout
|
||
|
||
from mjlab.envs.manager_based_rl_env import ManagerBasedRlEnv
|
||
from mjlab.tasks.velocity.config.g1.env_cfgs import unitree_g1_flat_env_cfg
|
||
|
||
cfg = unitree_g1_flat_env_cfg()
|
||
|
||
with warnings.catch_warnings():
|
||
warnings.simplefilter("ignore")
|
||
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
||
env = ManagerBasedRlEnv(cfg, device=device)
|
||
|
||
try:
|
||
robot = env.scene["robot"]
|
||
|
||
foot_geom_names = [
|
||
f"{side}_foot{i}_collision" for side in ("left", "right") for i in range(1, 8)
|
||
]
|
||
foot_geom_ids, _ = robot.find_geoms(foot_geom_names)
|
||
foot_geom_indices = robot.indexing.geom_ids[foot_geom_ids]
|
||
|
||
friction = env.sim.model.geom_friction[:, foot_geom_indices, 0]
|
||
|
||
for env_idx in range(env.num_envs):
|
||
env_friction = friction[env_idx]
|
||
assert torch.allclose(env_friction, env_friction[0].expand_as(env_friction))
|
||
|
||
if env.num_envs > 1:
|
||
env_frictions = friction[:, 0]
|
||
assert len(torch.unique(env_frictions)) > 1
|
||
finally:
|
||
env.close()
|
||
|
||
|
||
# Quaternion DR tests.
|
||
|
||
|
||
def _make_quat_env(device, num_envs=NUM_ENVS):
|
||
"""Create an env with body_quat, geom_quat, site_quat expanded."""
|
||
return create_test_env(
|
||
device,
|
||
num_envs=num_envs,
|
||
expand_fields=("body_quat", "geom_quat", "site_quat"),
|
||
)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def quat_env(device):
|
||
return _make_quat_env(device)
|
||
|
||
|
||
_QUAT_CASES = [
|
||
pytest.param(
|
||
dr.body_quat,
|
||
"body_quat",
|
||
SceneEntityCfg("robot", body_names=(".*",)),
|
||
"body_ids",
|
||
id="body",
|
||
),
|
||
pytest.param(
|
||
dr.geom_quat,
|
||
"geom_quat",
|
||
SceneEntityCfg("robot", geom_names=(".*",)),
|
||
"geom_ids",
|
||
id="geom",
|
||
),
|
||
pytest.param(
|
||
dr.site_quat,
|
||
"site_quat",
|
||
SceneEntityCfg("robot", site_names=(".*",)),
|
||
"site_ids",
|
||
id="site",
|
||
),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize("dr_func, field, asset_cfg, ids_attr", _QUAT_CASES)
|
||
def test_quat_is_unit_quaternion(quat_env, dr_func, field, asset_cfg, ids_attr):
|
||
"""All output quaternions must have unit norm (within 1e-6)."""
|
||
torch.manual_seed(0)
|
||
env = quat_env
|
||
robot = env.scene["robot"]
|
||
|
||
dr_func(
|
||
env,
|
||
env_ids=None,
|
||
roll_range=(-0.5, 0.5),
|
||
pitch_range=(-0.5, 0.5),
|
||
yaw_range=(-math.pi, math.pi),
|
||
asset_cfg=asset_cfg,
|
||
)
|
||
|
||
ids = getattr(robot.indexing, ids_attr)
|
||
quats = getattr(env.sim.model, field)[:, ids, :]
|
||
norms = quats.norm(dim=-1)
|
||
assert torch.all((norms - 1.0).abs() < 1e-6), (
|
||
f"Non-unit quaternion found; max deviation: {(norms - 1.0).abs().max()}"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("dr_func, field, asset_cfg, ids_attr", _QUAT_CASES)
|
||
def test_quat_zero_range_unchanged(quat_env, dr_func, field, asset_cfg, ids_attr):
|
||
"""All ranges (0, 0): result equals default quaternion."""
|
||
env = quat_env
|
||
robot = env.scene["robot"]
|
||
ids = getattr(robot.indexing, ids_attr)
|
||
default_quat = env.sim.get_default_field(field)[ids].clone()
|
||
|
||
dr_func(env, env_ids=None, asset_cfg=asset_cfg)
|
||
|
||
result = getattr(env.sim.model, field)[:, ids, :]
|
||
assert torch.allclose(result, default_quat.unsqueeze(0).expand_as(result), atol=1e-6)
|
||
|
||
|
||
@pytest.mark.parametrize("dr_func, field, asset_cfg, ids_attr", _QUAT_CASES)
|
||
def test_quat_composes_with_default(quat_env, dr_func, field, asset_cfg, ids_attr):
|
||
"""Fixed yaw=pi/4 matches manual quat_mul(q_yaw, q_default)."""
|
||
env = quat_env
|
||
robot = env.scene["robot"]
|
||
ids = getattr(robot.indexing, ids_attr)
|
||
n = len(ids)
|
||
|
||
yaw = math.pi / 4
|
||
dr_func(env, env_ids=None, yaw_range=(yaw, yaw), asset_cfg=asset_cfg)
|
||
|
||
result = getattr(env.sim.model, field)[:, ids, :]
|
||
q_default = env.sim.get_default_field(field)[ids]
|
||
zeros = torch.zeros(n, device=env.device)
|
||
q_yaw = quat_from_euler_xyz(zeros, zeros, zeros + yaw)
|
||
q_expected = quat_mul(q_yaw, q_default)
|
||
assert torch.allclose(result, q_expected.unsqueeze(0).expand_as(result), atol=1e-6)
|
||
|
||
|
||
def test_body_quat_only_specified_axes(quat_env):
|
||
"""Yaw-only perturbation: roll/pitch of result match default."""
|
||
torch.manual_seed(1)
|
||
env = quat_env
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base",))
|
||
body_cfg.resolve(env.scene)
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
dr.body_quat(
|
||
env,
|
||
env_ids=None,
|
||
yaw_range=(-0.3, 0.3),
|
||
asset_cfg=SceneEntityCfg("robot", body_names=("base",)),
|
||
)
|
||
|
||
result = env.sim.model.body_quat[:, body_ids, :]
|
||
# For the default quat [1,0,0,0] composed with a yaw-only rotation, qx and qy should
|
||
# remain 0 (pure yaw -> no roll/pitch).
|
||
q_default = env.sim.get_default_field("body_quat")[body_ids]
|
||
if torch.allclose(q_default, torch.tensor([[1.0, 0.0, 0.0, 0.0]], device=env.device)):
|
||
assert torch.allclose(result[..., 1], torch.zeros_like(result[..., 1]), atol=1e-6)
|
||
assert torch.allclose(result[..., 2], torch.zeros_like(result[..., 2]), atol=1e-6)
|
||
|
||
|
||
# pseudo_inertia tests.
|
||
|
||
|
||
def _make_inertia_env(device, num_envs=NUM_ENVS):
|
||
"""Create an env with all inertia-related fields expanded."""
|
||
return create_test_env(
|
||
device,
|
||
num_envs=num_envs,
|
||
expand_fields=("body_mass", "body_ipos", "body_inertia", "body_iquat"),
|
||
)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def inertia_env(device):
|
||
return _make_inertia_env(device)
|
||
|
||
|
||
def _matrix_from_quat(q: torch.Tensor) -> torch.Tensor:
|
||
"""Convert wxyz quaternion(s) to 3x3 rotation matrix.
|
||
|
||
Shape: (..., 4) → (..., 3, 3).
|
||
"""
|
||
w, x, y, z = q[..., 0], q[..., 1], q[..., 2], q[..., 3]
|
||
return torch.stack(
|
||
[
|
||
torch.stack(
|
||
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], dim=-1
|
||
),
|
||
torch.stack(
|
||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], dim=-1
|
||
),
|
||
torch.stack(
|
||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], dim=-1
|
||
),
|
||
],
|
||
dim=-2,
|
||
)
|
||
|
||
|
||
def _reconstruct_J(mass, ipos, inertia, iquat):
|
||
"""Reconstruct pseudo-inertia matrix from MuJoCo fields (test helper).
|
||
|
||
Correctly accounts for body_iquat (the principal-frame rotation) and the
|
||
parallel-axis theorem (body_ipos) so that the reconstructed J is exact regardless of
|
||
shear magnitude.
|
||
"""
|
||
I3 = torch.eye(3, device=mass.device, dtype=mass.dtype)
|
||
# MuJoCo body_iquat maps principal→body.
|
||
R = _matrix_from_quat(iquat) # (..., 3, 3)
|
||
# Inertia tensor at COM in body frame: I_com = R @ diag(inertia) @ R^T
|
||
I_com = R @ torch.diag_embed(inertia) @ R.mT # (..., 3, 3)
|
||
|
||
# Parallel-axis theorem: shift from COM to body origin.
|
||
c = ipos # (..., 3)
|
||
c_sq = (c * c).sum(dim=-1) # (...,)
|
||
c_outer = c.unsqueeze(-1) * c.unsqueeze(-2) # (..., 3, 3)
|
||
m = mass.unsqueeze(-1).unsqueeze(-1) # (..., 1, 1)
|
||
I_origin = I_com + m * (c_sq.unsqueeze(-1).unsqueeze(-1) * I3 - c_outer)
|
||
|
||
trace = I_origin.diagonal(dim1=-2, dim2=-1).sum(dim=-1)
|
||
sigma = 0.5 * trace.unsqueeze(-1).unsqueeze(-1) * I3 - I_origin
|
||
h = mass.unsqueeze(-1) * ipos
|
||
batch = mass.shape
|
||
J = torch.zeros(*batch, 4, 4, device=mass.device, dtype=mass.dtype)
|
||
J[..., :3, :3] = sigma
|
||
J[..., :3, 3] = h
|
||
J[..., 3, :3] = h
|
||
J[..., 3, 3] = mass
|
||
return J
|
||
|
||
|
||
def test_pseudo_inertia_physical_consistency(inertia_env):
|
||
"""After wide perturbation, J ≻ 0, mass > 0, triangle inequality holds."""
|
||
torch.manual_seed(10)
|
||
env = inertia_env
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base", "foot1", "foot2"))
|
||
body_cfg.resolve(env.scene)
|
||
|
||
dr.pseudo_inertia(
|
||
env,
|
||
env_ids=None,
|
||
alpha_range=(-0.5, 0.5),
|
||
d_range=(-0.3, 0.3),
|
||
s12_range=(-0.1, 0.1),
|
||
s13_range=(-0.1, 0.1),
|
||
s23_range=(-0.1, 0.1),
|
||
t_range=(-0.05, 0.05),
|
||
asset_cfg=body_cfg,
|
||
)
|
||
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
mass = env.sim.model.body_mass[:, body_ids]
|
||
ipos = env.sim.model.body_ipos[:, body_ids, :]
|
||
inertia = env.sim.model.body_inertia[:, body_ids, :]
|
||
iquat = env.sim.model.body_iquat[:, body_ids, :]
|
||
|
||
# Mass must be positive.
|
||
assert torch.all(mass > 0), f"Non-positive mass: {mass.min()}"
|
||
|
||
# Principal moments must all be positive.
|
||
assert torch.all(inertia > 0), f"Non-positive principal moment: {inertia.min()}"
|
||
|
||
# body_iquat must be a unit quaternion.
|
||
iquat_norms = iquat.norm(dim=-1)
|
||
assert torch.all((iquat_norms - 1.0).abs() < 1e-5), (
|
||
f"Non-unit body_iquat; max deviation: {(iquat_norms - 1.0).abs().max()}"
|
||
)
|
||
|
||
# Triangle inequality on principal moments: D_i + D_j >= D_k.
|
||
d1, d2, d3 = inertia[..., 0], inertia[..., 1], inertia[..., 2]
|
||
assert torch.all(d1 + d2 >= d3 - 1e-6)
|
||
assert torch.all(d1 + d3 >= d2 - 1e-6)
|
||
assert torch.all(d2 + d3 >= d1 - 1e-6)
|
||
|
||
# Round-trip check: reconstruct J from all four stored fields (including body_iquat)
|
||
# and verify it is positive definite. This is exact because the implementation writes
|
||
# the eigendecomposition of J'.
|
||
J = _reconstruct_J(mass, ipos, inertia, iquat)
|
||
eigvals = torch.linalg.eigvalsh(J)
|
||
assert torch.all(eigvals > -1e-5), f"Non-positive J eigenvalue: {eigvals.min()}"
|
||
|
||
|
||
def test_pseudo_inertia_zero_perturbation_unchanged(inertia_env):
|
||
"""All ranges (0, 0): mass, ipos, inertia, iquat equal defaults (1e-5)."""
|
||
env = inertia_env
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base", "foot1", "foot2"))
|
||
body_cfg.resolve(env.scene)
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
def_mass = env.sim.get_default_field("body_mass")[body_ids].clone()
|
||
def_ipos = env.sim.get_default_field("body_ipos")[body_ids].clone()
|
||
def_inertia = env.sim.get_default_field("body_inertia")[body_ids].clone()
|
||
def_iquat = env.sim.get_default_field("body_iquat")[body_ids].clone()
|
||
|
||
dr.pseudo_inertia(env, env_ids=None, asset_cfg=body_cfg)
|
||
|
||
mass = env.sim.model.body_mass[:, body_ids]
|
||
ipos = env.sim.model.body_ipos[:, body_ids, :]
|
||
inertia = env.sim.model.body_inertia[:, body_ids, :]
|
||
iquat = env.sim.model.body_iquat[:, body_ids, :]
|
||
|
||
assert torch.allclose(mass, def_mass.unsqueeze(0).expand_as(mass), atol=1e-5)
|
||
assert torch.allclose(ipos, def_ipos.unsqueeze(0).expand_as(ipos), atol=1e-5)
|
||
# For bodies with degenerate eigenvalues (2+ equal principal moments), the
|
||
# eigenvector basis is non-unique, so the (inertia, iquat) decomposition may differ
|
||
# while producing the same physical inertia tensor. Compare the full tensor
|
||
# R @ diag(inertia) @ R^T rather than individual components.
|
||
R_def = _matrix_from_quat(def_iquat)
|
||
I_def = R_def @ torch.diag_embed(def_inertia) @ R_def.mT
|
||
R_new = _matrix_from_quat(iquat)
|
||
I_new = R_new @ torch.diag_embed(inertia) @ R_new.mT
|
||
assert torch.allclose(I_new, I_def.unsqueeze(0).expand_as(I_new), atol=1e-5), (
|
||
"Inertia tensor changed unexpectedly under zero perturbation"
|
||
)
|
||
|
||
|
||
def test_pseudo_inertia_zero_perturbation_offset_body(inertia_env):
|
||
"""Zero perturbation preserves physics for a body with non-trivial ipos/iquat.
|
||
|
||
Since ``eigh`` returns eigenvalues in ascending order while MuJoCo may store them
|
||
differently, we compare the reconstructed pseudo-inertia matrix J (which is
|
||
representation-independent) rather than the raw principal moments and iquat
|
||
individually.
|
||
"""
|
||
env = inertia_env
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("offset_body",))
|
||
body_cfg.resolve(env.scene)
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
def_mass = env.sim.get_default_field("body_mass")[body_ids].clone()
|
||
def_ipos = env.sim.get_default_field("body_ipos")[body_ids].clone()
|
||
def_inertia = env.sim.get_default_field("body_inertia")[body_ids].clone()
|
||
def_iquat = env.sim.get_default_field("body_iquat")[body_ids].clone()
|
||
|
||
# Verify the test body actually has non-trivial ipos and iquat.
|
||
assert not torch.allclose(def_ipos, torch.zeros_like(def_ipos), atol=1e-6), (
|
||
"Test body should have non-zero ipos"
|
||
)
|
||
identity_q = torch.tensor([1.0, 0.0, 0.0, 0.0], device=env.device)
|
||
assert not (
|
||
torch.allclose(def_iquat, identity_q.expand_as(def_iquat), atol=1e-6)
|
||
or torch.allclose(def_iquat, -identity_q.expand_as(def_iquat), atol=1e-6)
|
||
), "Test body should have non-identity iquat"
|
||
|
||
dr.pseudo_inertia(env, env_ids=None, asset_cfg=body_cfg)
|
||
|
||
mass = env.sim.model.body_mass[:, body_ids]
|
||
ipos = env.sim.model.body_ipos[:, body_ids, :]
|
||
inertia = env.sim.model.body_inertia[:, body_ids, :]
|
||
iquat = env.sim.model.body_iquat[:, body_ids, :]
|
||
|
||
# Mass and ipos must be exactly preserved.
|
||
assert torch.allclose(mass, def_mass.unsqueeze(0).expand_as(mass), atol=1e-5)
|
||
assert torch.allclose(ipos, def_ipos.unsqueeze(0).expand_as(ipos), atol=1e-5)
|
||
|
||
# Inertia and iquat may have different eigenvalue ordering, so compare the
|
||
# reconstructed J matrix instead.
|
||
J_default = _reconstruct_J(def_mass, def_ipos, def_inertia, def_iquat)
|
||
J_result = _reconstruct_J(mass, ipos, inertia, iquat)
|
||
assert torch.allclose(
|
||
J_result, J_default.unsqueeze(0).expand_as(J_result), atol=1e-5
|
||
), (
|
||
f"Reconstructed J differs under zero perturbation.\n"
|
||
f"Max deviation: {(J_result - J_default.unsqueeze(0)).abs().max()}"
|
||
)
|
||
|
||
|
||
def test_pseudo_inertia_alpha_only_scales_mass(inertia_env):
|
||
"""alpha-only: mass and inertia scale by e^{2α}, ipos unchanged."""
|
||
env = inertia_env
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base", "foot1", "foot2"))
|
||
body_cfg.resolve(env.scene)
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
a = 0.3
|
||
scale = math.exp(2 * a)
|
||
|
||
def_mass = env.sim.get_default_field("body_mass")[body_ids].clone()
|
||
def_ipos = env.sim.get_default_field("body_ipos")[body_ids].clone()
|
||
def_inertia = env.sim.get_default_field("body_inertia")[body_ids].clone()
|
||
|
||
dr.pseudo_inertia(env, env_ids=None, alpha_range=(a, a), asset_cfg=body_cfg)
|
||
|
||
mass = env.sim.model.body_mass[:, body_ids]
|
||
ipos = env.sim.model.body_ipos[:, body_ids, :]
|
||
inertia = env.sim.model.body_inertia[:, body_ids, :]
|
||
|
||
expected_mass = def_mass * scale
|
||
expected_inertia = def_inertia * scale
|
||
|
||
assert torch.allclose(mass, expected_mass.unsqueeze(0).expand_as(mass), atol=1e-5)
|
||
assert torch.allclose(
|
||
inertia, expected_inertia.unsqueeze(0).expand_as(inertia), atol=1e-5
|
||
)
|
||
assert torch.allclose(ipos, def_ipos.unsqueeze(0).expand_as(ipos), atol=1e-5)
|
||
|
||
|
||
def test_pseudo_inertia_t1_shifts_com(inertia_env):
|
||
"""t1-only: mass unchanged, ipos[0] shifts by exactly t1."""
|
||
env = inertia_env
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base", "foot1", "foot2"))
|
||
body_cfg.resolve(env.scene)
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
t = 0.05
|
||
def_mass = env.sim.get_default_field("body_mass")[body_ids].clone()
|
||
def_ipos = env.sim.get_default_field("body_ipos")[body_ids].clone()
|
||
|
||
dr.pseudo_inertia(env, env_ids=None, t1_range=(t, t), asset_cfg=body_cfg)
|
||
|
||
mass = env.sim.model.body_mass[:, body_ids]
|
||
ipos = env.sim.model.body_ipos[:, body_ids, :]
|
||
|
||
assert torch.allclose(mass, def_mass.unsqueeze(0).expand_as(mass), atol=1e-5)
|
||
# ipos x-component shifts by t; y and z are unchanged.
|
||
assert torch.allclose(
|
||
ipos[..., 0], (def_ipos[..., 0] + t).unsqueeze(0).expand_as(ipos[..., 0]), atol=1e-5
|
||
)
|
||
assert torch.allclose(
|
||
ipos[..., 1:], def_ipos[..., 1:].unsqueeze(0).expand_as(ipos[..., 1:]), atol=1e-5
|
||
)
|
||
|
||
|
||
def test_pseudo_inertia_d_isotropic_vs_per_axis(device):
|
||
"""d_range=(r,r) gives same result as d1=d2=d3=(r,r)."""
|
||
r = 0.2
|
||
seed = 42
|
||
|
||
torch.manual_seed(seed)
|
||
env1 = _make_inertia_env(device)
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base", "foot1", "foot2"))
|
||
body_cfg.resolve(env1.scene)
|
||
dr.pseudo_inertia(env1, env_ids=None, d_range=(r, r), asset_cfg=body_cfg)
|
||
|
||
torch.manual_seed(seed)
|
||
env2 = _make_inertia_env(device)
|
||
dr.pseudo_inertia(
|
||
env2,
|
||
env_ids=None,
|
||
d1_range=(r, r),
|
||
d2_range=(r, r),
|
||
d3_range=(r, r),
|
||
asset_cfg=body_cfg,
|
||
)
|
||
|
||
robot1 = env1.scene["robot"]
|
||
body_ids = robot1.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
assert torch.allclose(
|
||
env1.sim.model.body_mass[:, body_ids],
|
||
env2.sim.model.body_mass[:, body_ids],
|
||
atol=1e-6,
|
||
)
|
||
assert torch.allclose(
|
||
env1.sim.model.body_inertia[:, body_ids, :],
|
||
env2.sim.model.body_inertia[:, body_ids, :],
|
||
atol=1e-6,
|
||
)
|
||
|
||
|
||
def test_pseudo_inertia_no_accumulation(device):
|
||
"""3x with alpha=0.1 gives same result as 1x (uses defaults each time)."""
|
||
env = _make_inertia_env(device)
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base",))
|
||
body_cfg.resolve(env.scene)
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
for _ in range(3):
|
||
dr.pseudo_inertia(env, env_ids=None, alpha_range=(0.1, 0.1), asset_cfg=body_cfg)
|
||
|
||
mass_3x = env.sim.model.body_mass[:, body_ids].clone()
|
||
|
||
env2 = _make_inertia_env(device)
|
||
dr.pseudo_inertia(env2, env_ids=None, alpha_range=(0.1, 0.1), asset_cfg=body_cfg)
|
||
mass_1x = env2.sim.model.body_mass[:, body_ids]
|
||
|
||
assert torch.allclose(mass_3x, mass_1x, atol=1e-5)
|
||
|
||
|
||
def test_pseudo_inertia_partial_env_ids(device):
|
||
"""Randomizing only env 0 leaves env 1 unchanged."""
|
||
env = _make_inertia_env(device, num_envs=2)
|
||
robot = env.scene["robot"]
|
||
body_cfg = SceneEntityCfg("robot", body_names=("base",))
|
||
body_cfg.resolve(env.scene)
|
||
body_ids = robot.indexing.body_ids[body_cfg.body_ids]
|
||
|
||
mass_before = env.sim.model.body_mass[:, body_ids].clone()
|
||
|
||
dr.pseudo_inertia(
|
||
env,
|
||
env_ids=torch.tensor([0], device=device),
|
||
alpha_range=(0.5, 0.5),
|
||
asset_cfg=body_cfg,
|
||
)
|
||
|
||
mass_after = env.sim.model.body_mass[:, body_ids]
|
||
# Env 0 changed.
|
||
assert not torch.allclose(mass_after[0], mass_before[0], atol=1e-6)
|
||
# Env 1 unchanged.
|
||
assert torch.allclose(mass_after[1], mass_before[1], atol=1e-6)
|
||
|
||
|
||
# Camera / Light DR tests.
|
||
|
||
|
||
def _make_cam_light_env(device, num_envs=NUM_ENVS):
|
||
"""Create an env with camera and light fields expanded."""
|
||
return create_test_env(
|
||
device,
|
||
num_envs=num_envs,
|
||
expand_fields=(
|
||
"cam_fovy",
|
||
"cam_pos",
|
||
"cam_quat",
|
||
"cam_intrinsic",
|
||
"light_pos",
|
||
"light_dir",
|
||
"light_diffuse",
|
||
"light_specular",
|
||
"light_ambient",
|
||
"light_attenuation",
|
||
"light_cutoff",
|
||
"light_exponent",
|
||
),
|
||
)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def cam_light_env(device):
|
||
return _make_cam_light_env(device)
|
||
|
||
|
||
def test_cam_fovy_abs(cam_light_env):
|
||
"""Set fovy to absolute range, check bounds."""
|
||
torch.manual_seed(42)
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
cam_cfg = SceneEntityCfg("robot", camera_names=(".*",))
|
||
cam_cfg.resolve(env.scene)
|
||
cam_ids = robot.indexing.cam_ids[cam_cfg.camera_ids]
|
||
|
||
dr.cam_fovy(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(30.0, 90.0),
|
||
operation="abs",
|
||
asset_cfg=cam_cfg,
|
||
)
|
||
|
||
fovy = env.sim.model.cam_fovy[:, cam_ids]
|
||
assert torch.all((fovy >= 30.0 - 1e-3) & (fovy <= 90.0 + 1e-3))
|
||
|
||
|
||
def test_cam_pos_add(cam_light_env):
|
||
"""Add offset to camera positions, check default + offset."""
|
||
torch.manual_seed(42)
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
cam_cfg = SceneEntityCfg("robot", camera_names=("front_cam",))
|
||
cam_cfg.resolve(env.scene)
|
||
cam_ids = robot.indexing.cam_ids[cam_cfg.camera_ids]
|
||
|
||
default_pos = env.sim.get_default_field("cam_pos")[cam_ids].clone()
|
||
|
||
dr.cam_pos(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(-0.1, 0.1),
|
||
operation="add",
|
||
asset_cfg=cam_cfg,
|
||
)
|
||
|
||
result = env.sim.model.cam_pos[:, cam_ids, :]
|
||
for ax in range(3):
|
||
lo = default_pos[..., ax] - 0.1 - 1e-5
|
||
hi = default_pos[..., ax] + 0.1 + 1e-5
|
||
assert torch.all((result[..., ax] >= lo) & (result[..., ax] <= hi))
|
||
|
||
|
||
def test_cam_quat_zero_unchanged(cam_light_env):
|
||
"""Zero RPY range preserves default quaternion."""
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
cam_cfg = SceneEntityCfg("robot", camera_names=(".*",))
|
||
cam_cfg.resolve(env.scene)
|
||
cam_ids = robot.indexing.cam_ids[cam_cfg.camera_ids]
|
||
|
||
default_quat = env.sim.get_default_field("cam_quat")[cam_ids].clone()
|
||
|
||
dr.cam_quat(env, env_ids=None, asset_cfg=cam_cfg)
|
||
|
||
result = env.sim.model.cam_quat[:, cam_ids, :]
|
||
assert torch.allclose(
|
||
result,
|
||
default_quat.unsqueeze(0).expand_as(result),
|
||
atol=1e-6,
|
||
)
|
||
|
||
|
||
def test_light_pos_abs(cam_light_env):
|
||
"""Set light position to absolute range, check bounds."""
|
||
torch.manual_seed(42)
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
light_cfg = SceneEntityCfg("robot", light_names=(".*",))
|
||
light_cfg.resolve(env.scene)
|
||
light_ids = robot.indexing.light_ids[light_cfg.light_ids]
|
||
|
||
dr.light_pos(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(-5.0, 5.0),
|
||
operation="abs",
|
||
asset_cfg=light_cfg,
|
||
)
|
||
|
||
pos = env.sim.model.light_pos[:, light_ids, :]
|
||
assert torch.all((pos >= -5.0 - 1e-3) & (pos <= 5.0 + 1e-3))
|
||
|
||
|
||
def test_light_dir_add(cam_light_env):
|
||
"""Add offset to light direction, check default + offset."""
|
||
torch.manual_seed(42)
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
light_cfg = SceneEntityCfg("robot", light_names=(".*",))
|
||
light_cfg.resolve(env.scene)
|
||
light_ids = robot.indexing.light_ids[light_cfg.light_ids]
|
||
|
||
default_dir = env.sim.get_default_field("light_dir")[light_ids].clone()
|
||
|
||
dr.light_dir(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(-0.5, 0.5),
|
||
operation="add",
|
||
asset_cfg=light_cfg,
|
||
)
|
||
|
||
result = env.sim.model.light_dir[:, light_ids, :]
|
||
for ax in range(3):
|
||
lo = default_dir[..., ax] - 0.5 - 1e-5
|
||
hi = default_dir[..., ax] + 0.5 + 1e-5
|
||
assert torch.all((result[..., ax] >= lo) & (result[..., ax] <= hi))
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("func_name", "field", "ranges"),
|
||
[
|
||
("light_diffuse", "light_diffuse", (0.2, 0.8)),
|
||
("light_specular", "light_specular", (0.0, 1.0)),
|
||
("light_ambient", "light_ambient", (0.1, 0.5)),
|
||
("light_attenuation", "light_attenuation", (0.0, 1.0)),
|
||
],
|
||
)
|
||
def test_light_vec3_fields_abs(cam_light_env, func_name, field, ranges):
|
||
"""Vec3 light fields randomize per selected light."""
|
||
torch.manual_seed(42)
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
light_cfg = SceneEntityCfg("robot", light_names=(".*",))
|
||
light_cfg.resolve(env.scene)
|
||
light_ids = robot.indexing.light_ids[light_cfg.light_ids]
|
||
func = getattr(dr, func_name)
|
||
|
||
func(
|
||
env,
|
||
env_ids=None,
|
||
ranges=ranges,
|
||
operation="abs",
|
||
asset_cfg=light_cfg,
|
||
)
|
||
|
||
values = getattr(env.sim.model, field)[:, light_ids, :]
|
||
lower, upper = ranges
|
||
assert torch.all((values >= lower - 1e-5) & (values <= upper + 1e-5))
|
||
assert len(torch.unique(values[:, 0, 0])) >= 2
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("func_name", "field", "ranges"),
|
||
[
|
||
("light_cutoff", "light_cutoff", (20.0, 60.0)),
|
||
("light_exponent", "light_exponent", (1.0, 20.0)),
|
||
],
|
||
)
|
||
def test_light_scalar_fields_abs(cam_light_env, func_name, field, ranges):
|
||
"""Scalar light fields randomize per selected light."""
|
||
torch.manual_seed(42)
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
light_cfg = SceneEntityCfg("robot", light_names=(".*",))
|
||
light_cfg.resolve(env.scene)
|
||
light_ids = robot.indexing.light_ids[light_cfg.light_ids]
|
||
func = getattr(dr, func_name)
|
||
|
||
func(
|
||
env,
|
||
env_ids=None,
|
||
ranges=ranges,
|
||
operation="abs",
|
||
asset_cfg=light_cfg,
|
||
)
|
||
|
||
values = getattr(env.sim.model, field)[:, light_ids]
|
||
lower, upper = ranges
|
||
assert torch.all((values >= lower - 1e-5) & (values <= upper + 1e-5))
|
||
assert len(torch.unique(values[:, 0])) >= 2
|
||
|
||
|
||
def test_camera_partial_env_ids(cam_light_env):
|
||
"""Subset of envs randomized, others unchanged."""
|
||
torch.manual_seed(42)
|
||
env = cam_light_env
|
||
robot = env.scene["robot"]
|
||
cam_cfg = SceneEntityCfg("robot", camera_names=(".*",))
|
||
cam_cfg.resolve(env.scene)
|
||
cam_ids = robot.indexing.cam_ids[cam_cfg.camera_ids]
|
||
|
||
original_fovy = env.sim.model.cam_fovy[:, cam_ids].clone()
|
||
|
||
dr.cam_fovy(
|
||
env,
|
||
env_ids=torch.tensor([0, 2], device=env.device),
|
||
ranges=(10.0, 20.0),
|
||
operation="abs",
|
||
asset_cfg=cam_cfg,
|
||
)
|
||
|
||
result = env.sim.model.cam_fovy[:, cam_ids]
|
||
# Envs 0, 2 should have changed.
|
||
assert torch.all((result[0] >= 10.0 - 1e-3) & (result[0] <= 20.0 + 1e-3))
|
||
assert torch.all((result[2] >= 10.0 - 1e-3) & (result[2] <= 20.0 + 1e-3))
|
||
# Envs 1, 3 should be unchanged.
|
||
assert torch.allclose(result[1], original_fovy[1])
|
||
assert torch.allclose(result[3], original_fovy[3])
|
||
|
||
|
||
# geom_size DR tests.
|
||
|
||
GEOM_SIZE_XML = """
|
||
<mujoco>
|
||
<worldbody>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="box_geom" type="box" size="0.1 0.2 0.3" mass="1.0"/>
|
||
<geom name="sphere_geom" type="sphere" size="0.15" mass="0.5"/>
|
||
<geom name="capsule_geom" type="capsule" size="0.05 0.2" mass="0.3"/>
|
||
<geom name="cylinder_geom" type="cylinder" size="0.08 0.15" mass="0.4"/>
|
||
<geom name="ellipsoid_geom" type="ellipsoid" size="0.1 0.2 0.05"
|
||
mass="0.2"/>
|
||
</body>
|
||
</worldbody>
|
||
</mujoco>
|
||
"""
|
||
|
||
|
||
def _make_geom_size_env(device, num_envs=NUM_ENVS):
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(GEOM_SIZE_XML))
|
||
scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
sim.expand_model_fields(("geom_size", "geom_rbound", "geom_aabb"))
|
||
return Env(scene, sim, device)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def geom_size_env(device):
|
||
return _make_geom_size_env(device)
|
||
|
||
|
||
def _expected_rbound(geom_type, s0, s1, s2):
|
||
"""Reference rbound computation matching MuJoCo GetRBound."""
|
||
if geom_type == "sphere":
|
||
return s0
|
||
elif geom_type == "capsule":
|
||
return s0 + s1
|
||
elif geom_type == "cylinder":
|
||
return math.sqrt(s0**2 + s1**2)
|
||
elif geom_type == "ellipsoid":
|
||
return max(s0, s1, s2)
|
||
elif geom_type == "box":
|
||
return math.sqrt(s0**2 + s1**2 + s2**2)
|
||
raise ValueError(geom_type)
|
||
|
||
|
||
def _expected_aabb_half(geom_type, s0, s1, s2):
|
||
"""Reference aabb half-size computation matching MuJoCo ComputeAABB."""
|
||
if geom_type == "sphere":
|
||
return (s0, s0, s0)
|
||
elif geom_type == "capsule":
|
||
return (s0, s0, s0 + s1)
|
||
elif geom_type == "cylinder":
|
||
return (s0, s0, s1)
|
||
elif geom_type == "ellipsoid":
|
||
return (s0, s1, s2)
|
||
elif geom_type == "box":
|
||
return (s0, s1, s2)
|
||
raise ValueError(geom_type)
|
||
|
||
|
||
def test_geom_size_scale_updates_bounds(geom_size_env):
|
||
"""Scaling geom_size updates rbound and aabb consistently."""
|
||
torch.manual_seed(42)
|
||
env = geom_size_env
|
||
robot = env.scene["robot"]
|
||
geom_cfg = SceneEntityCfg("robot", geom_names=(".*",))
|
||
geom_cfg.resolve(env.scene)
|
||
|
||
dr.geom_size(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.8, 1.5),
|
||
operation="scale",
|
||
asset_cfg=geom_cfg,
|
||
)
|
||
|
||
geom_ids = robot.indexing.geom_ids[geom_cfg.geom_ids]
|
||
size = env.sim.model.geom_size[:, geom_ids] # (E, G, 3)
|
||
rbound = env.sim.model.geom_rbound[:, geom_ids] # (E, G)
|
||
aabb = env.sim.model.geom_aabb[:, geom_ids] # (E, G, 2, 3)
|
||
|
||
geom_names = list(robot.geom_names)
|
||
type_names = {
|
||
"box_geom": "box",
|
||
"sphere_geom": "sphere",
|
||
"capsule_geom": "capsule",
|
||
"cylinder_geom": "cylinder",
|
||
"ellipsoid_geom": "ellipsoid",
|
||
}
|
||
|
||
for g_local, gname in enumerate(geom_names):
|
||
tname = type_names[gname]
|
||
for e in range(env.num_envs):
|
||
s0 = size[e, g_local, 0].item()
|
||
s1 = size[e, g_local, 1].item()
|
||
s2 = size[e, g_local, 2].item()
|
||
|
||
expected_rb = _expected_rbound(tname, s0, s1, s2)
|
||
actual_rb = rbound[e, g_local].item()
|
||
assert abs(actual_rb - expected_rb) < 1e-5, (
|
||
f"{gname} env {e}: rbound {actual_rb} != {expected_rb}"
|
||
)
|
||
|
||
ex, ey, ez = _expected_aabb_half(tname, s0, s1, s2)
|
||
actual_half = aabb[e, g_local, 1] # half-size is index 1
|
||
assert abs(actual_half[0].item() - ex) < 1e-5
|
||
assert abs(actual_half[1].item() - ey) < 1e-5
|
||
assert abs(actual_half[2].item() - ez) < 1e-5
|
||
|
||
# Center should be zero for all primitives.
|
||
center = aabb[e, g_local, 0]
|
||
assert torch.allclose(center, torch.zeros(3, device=env.device), atol=1e-6)
|
||
|
||
|
||
def test_geom_size_no_accumulation(device):
|
||
"""Repeated scale operations don't accumulate."""
|
||
env = _make_geom_size_env(device, num_envs=2)
|
||
robot = env.scene["robot"]
|
||
geom_cfg = SceneEntityCfg("robot", geom_names=("box_geom",))
|
||
geom_cfg.resolve(env.scene)
|
||
geom_ids = robot.indexing.geom_ids[geom_cfg.geom_ids]
|
||
|
||
default_size = env.sim.get_default_field("geom_size")[geom_ids].clone()
|
||
|
||
for _ in range(3):
|
||
dr.geom_size(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(2.0, 2.0),
|
||
operation="scale",
|
||
asset_cfg=geom_cfg,
|
||
)
|
||
|
||
result = env.sim.model.geom_size[0, geom_ids]
|
||
expected = default_size * 2.0
|
||
assert torch.allclose(result, expected, atol=1e-5)
|
||
|
||
|
||
def test_geom_size_separate_axes_dont_clobber(device):
|
||
"""Events targeting different axes of the same geom compose, not clobber."""
|
||
env = _make_geom_size_env(device, num_envs=2)
|
||
robot = env.scene["robot"]
|
||
geom_cfg = SceneEntityCfg("robot", geom_names=("box_geom",))
|
||
geom_cfg.resolve(env.scene)
|
||
geom_ids = robot.indexing.geom_ids[geom_cfg.geom_ids]
|
||
|
||
default_size = env.sim.get_default_field("geom_size")[geom_ids].clone()
|
||
|
||
# Scale axis 0 by 0.1, then axis 1 by 3.0 in a separate event.
|
||
dr.geom_size(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.1, 0.1),
|
||
operation="scale",
|
||
axes=[0],
|
||
asset_cfg=geom_cfg,
|
||
)
|
||
dr.geom_size(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(3.0, 3.0),
|
||
operation="scale",
|
||
axes=[1],
|
||
asset_cfg=geom_cfg,
|
||
)
|
||
|
||
result = env.sim.model.geom_size[0, geom_ids]
|
||
expected = default_size.clone()
|
||
expected[..., 0] *= 0.1
|
||
expected[..., 1] *= 3.0
|
||
assert torch.allclose(result, expected, atol=1e-5)
|
||
|
||
|
||
def test_geom_size_partial_env_ids(device):
|
||
"""Only specified envs are updated."""
|
||
env = _make_geom_size_env(device, num_envs=4)
|
||
robot = env.scene["robot"]
|
||
geom_cfg = SceneEntityCfg("robot", geom_names=("sphere_geom",))
|
||
geom_cfg.resolve(env.scene)
|
||
geom_ids = robot.indexing.geom_ids[geom_cfg.geom_ids]
|
||
|
||
original_rbound = env.sim.model.geom_rbound[:, geom_ids].clone()
|
||
|
||
dr.geom_size(
|
||
env,
|
||
env_ids=torch.tensor([0, 2], device=device),
|
||
ranges=(2.0, 2.0),
|
||
operation="scale",
|
||
asset_cfg=geom_cfg,
|
||
)
|
||
|
||
rbound = env.sim.model.geom_rbound[:, geom_ids]
|
||
# Envs 0, 2 should have changed.
|
||
assert not torch.allclose(rbound[0], original_rbound[0])
|
||
assert not torch.allclose(rbound[2], original_rbound[2])
|
||
# Envs 1, 3 should be unchanged.
|
||
assert torch.allclose(rbound[1], original_rbound[1])
|
||
assert torch.allclose(rbound[3], original_rbound[3])
|
||
|
||
|
||
def test_geom_size_raises_on_unsupported_type(device):
|
||
"""dr.geom_size raises ValueError when a non-primitive geom type is selected."""
|
||
PLANE_XML = """
|
||
<mujoco>
|
||
<worldbody>
|
||
<geom name="floor" type="plane" size="5 5 0.1"/>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="box_geom" type="box" size="0.1 0.1 0.1" mass="1.0"/>
|
||
</body>
|
||
</worldbody>
|
||
</mujoco>
|
||
"""
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(PLANE_XML))
|
||
scene_cfg = SceneCfg(num_envs=2, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
sim = Simulation(num_envs=2, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
sim.expand_model_fields(("geom_size", "geom_rbound", "geom_aabb"))
|
||
env = Env(scene, sim, device)
|
||
|
||
plane_cfg = SceneEntityCfg("robot", geom_names=("floor",))
|
||
plane_cfg.resolve(env.scene)
|
||
|
||
with pytest.raises(ValueError, match="unsupported types"):
|
||
dr.geom_size(env, env_ids=None, ranges=(1.0, 2.0), asset_cfg=plane_cfg)
|
||
|
||
|
||
# Tendon DR tests.
|
||
|
||
TENDON_XML = """
|
||
<mujoco>
|
||
<worldbody>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="base_geom" type="box" size="0.1 0.1 0.1" mass="1.0"/>
|
||
<site name="anchor" pos="0 0 0.1"/>
|
||
<body name="child" pos="0.3 0 0">
|
||
<joint name="hinge1" type="hinge" axis="0 0 1"/>
|
||
<geom name="child_geom" type="sphere" size="0.05" mass="0.2"/>
|
||
<site name="hook" pos="0 0 -0.05"/>
|
||
</body>
|
||
</body>
|
||
</worldbody>
|
||
<tendon>
|
||
<spatial name="wire" limited="true" range="0 0.5" width="0.003"
|
||
damping="2.0" stiffness="10.0" frictionloss="0.5" armature="0.1">
|
||
<site site="anchor"/>
|
||
<site site="hook"/>
|
||
</spatial>
|
||
</tendon>
|
||
</mujoco>
|
||
"""
|
||
|
||
|
||
def _make_tendon_env(device, num_envs=NUM_ENVS):
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(TENDON_XML))
|
||
scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
sim.expand_model_fields(
|
||
(
|
||
"tendon_damping",
|
||
"tendon_stiffness",
|
||
"tendon_frictionloss",
|
||
"tendon_armature",
|
||
)
|
||
)
|
||
|
||
return Env(scene, sim, device)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def tendon_env(device):
|
||
return _make_tendon_env(device)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"dr_func, field",
|
||
[
|
||
(dr.tendon_damping, "tendon_damping"),
|
||
(dr.tendon_stiffness, "tendon_stiffness"),
|
||
(dr.tendon_friction, "tendon_frictionloss"),
|
||
(dr.tendon_armature, "tendon_armature"),
|
||
],
|
||
)
|
||
def test_tendon_field_scale(tendon_env, dr_func, field):
|
||
"""Scale operation on tendon fields: result = default * sample."""
|
||
torch.manual_seed(42)
|
||
env = tendon_env
|
||
robot = env.scene["robot"]
|
||
tendon_cfg = SceneEntityCfg("robot", tendon_names=(".*",))
|
||
tendon_cfg.resolve(env.scene)
|
||
tendon_ids = robot.indexing.tendon_ids[tendon_cfg.tendon_ids]
|
||
|
||
default_val = env.sim.get_default_field(field)[tendon_ids].clone()
|
||
|
||
dr_func(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.5, 2.0),
|
||
operation="scale",
|
||
asset_cfg=tendon_cfg,
|
||
)
|
||
|
||
result = getattr(env.sim.model, field)[:, tendon_ids]
|
||
lo = default_val * 0.5 - 1e-5
|
||
hi = default_val * 2.0 + 1e-5
|
||
assert torch.all((result >= lo) & (result <= hi))
|
||
|
||
|
||
def test_tendon_armature_no_accumulation(device):
|
||
"""Repeated scale does not accumulate."""
|
||
env = _make_tendon_env(device, num_envs=2)
|
||
robot = env.scene["robot"]
|
||
tendon_cfg = SceneEntityCfg("robot", tendon_names=(".*",))
|
||
tendon_cfg.resolve(env.scene)
|
||
tendon_ids = robot.indexing.tendon_ids[tendon_cfg.tendon_ids]
|
||
|
||
default_val = env.sim.get_default_field("tendon_armature")[tendon_ids]
|
||
|
||
for _ in range(3):
|
||
dr.tendon_armature(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(2.0, 2.0),
|
||
operation="scale",
|
||
asset_cfg=tendon_cfg,
|
||
)
|
||
|
||
result = env.sim.model.tendon_armature[0, tendon_ids]
|
||
assert torch.allclose(result, default_val * 2.0, atol=1e-5)
|
||
|
||
|
||
# Extensible Operation / Distribution types.
|
||
|
||
|
||
def test_operation_instance_abs(device):
|
||
"""Passing an Operation instance works identically to the string."""
|
||
from mjlab.envs.mdp.dr._types import abs as abs_op
|
||
|
||
torch.manual_seed(42)
|
||
env1 = create_test_env(device)
|
||
robot = env1.scene["robot"]
|
||
|
||
dr.geom_friction(
|
||
env1,
|
||
env_ids=None,
|
||
ranges=(0.3, 1.2),
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
)
|
||
result_str = env1.sim.model.geom_friction[:, robot.indexing.geom_ids, 0].clone()
|
||
|
||
torch.manual_seed(42)
|
||
env2 = create_test_env(device)
|
||
|
||
dr.geom_friction(
|
||
env2,
|
||
env_ids=None,
|
||
ranges=(0.3, 1.2),
|
||
operation=abs_op,
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
)
|
||
result_inst = env2.sim.model.geom_friction[:, robot.indexing.geom_ids, 0]
|
||
|
||
assert torch.allclose(result_str, result_inst)
|
||
|
||
|
||
def test_distribution_instance_uniform(device):
|
||
"""Passing a Distribution instance works identically to the string."""
|
||
from mjlab.envs.mdp.dr._types import uniform as uniform_dist
|
||
|
||
torch.manual_seed(42)
|
||
env1 = create_test_env(device)
|
||
robot = env1.scene["robot"]
|
||
|
||
dr.geom_friction(
|
||
env1,
|
||
env_ids=None,
|
||
ranges=(0.3, 1.2),
|
||
distribution="uniform",
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
)
|
||
result_str = env1.sim.model.geom_friction[:, robot.indexing.geom_ids, 0].clone()
|
||
|
||
torch.manual_seed(42)
|
||
env2 = create_test_env(device)
|
||
|
||
dr.geom_friction(
|
||
env2,
|
||
env_ids=None,
|
||
ranges=(0.3, 1.2),
|
||
distribution=uniform_dist,
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
)
|
||
result_inst = env2.sim.model.geom_friction[:, robot.indexing.geom_ids, 0]
|
||
|
||
assert torch.allclose(result_str, result_inst)
|
||
|
||
|
||
def test_custom_operation(env):
|
||
"""A user-defined Operation works end-to-end."""
|
||
from mjlab.envs.mdp.dr._types import Operation
|
||
|
||
clamp_op = Operation(
|
||
name="clamp",
|
||
initialize=torch.Tensor.clone,
|
||
combine=lambda _base, random: torch.clamp(random, min=0.4, max=0.9),
|
||
uses_defaults=False,
|
||
)
|
||
|
||
torch.manual_seed(42)
|
||
robot = env.scene["robot"]
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.1, 1.5),
|
||
operation=clamp_op,
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
)
|
||
|
||
friction = env.sim.model.geom_friction[:, robot.indexing.geom_ids, 0]
|
||
assert torch.all((friction >= 0.4 - 1e-5) & (friction <= 0.9 + 1e-5))
|
||
|
||
|
||
def test_custom_distribution(env):
|
||
"""A user-defined Distribution works end-to-end."""
|
||
from mjlab.envs.mdp.dr._types import Distribution
|
||
|
||
# Distribution that always returns the midpoint.
|
||
midpoint_dist = Distribution(
|
||
name="midpoint",
|
||
sample=lambda lo, hi, shape, _device: ((lo + hi) / 2).expand(shape),
|
||
)
|
||
|
||
robot = env.scene["robot"]
|
||
|
||
dr.geom_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.2, 0.8),
|
||
distribution=midpoint_dist,
|
||
operation="abs",
|
||
asset_cfg=SceneEntityCfg("robot", geom_names=(".*",)),
|
||
axes=[0],
|
||
)
|
||
|
||
friction = env.sim.model.geom_friction[:, robot.indexing.geom_ids, 0]
|
||
assert torch.allclose(friction, torch.tensor(0.5, device=env.device), atol=1e-5)
|
||
|
||
|
||
def test_resolve_unknown_operation_raises(device):
|
||
"""Unknown operation string raises ValueError."""
|
||
from mjlab.envs.mdp.dr._types import resolve_operation
|
||
|
||
with pytest.raises(ValueError, match="Unknown operation"):
|
||
resolve_operation("nonexistent")
|
||
|
||
|
||
def test_resolve_unknown_distribution_raises(device):
|
||
"""Unknown distribution string raises ValueError."""
|
||
from mjlab.envs.mdp.dr._types import resolve_distribution
|
||
|
||
with pytest.raises(ValueError, match="Unknown distribution"):
|
||
resolve_distribution("nonexistent")
|
||
|
||
|
||
# mat_rgba tests.
|
||
|
||
MAT_XML = """
|
||
<mujoco>
|
||
<asset>
|
||
<material name="test_mat" rgba="1 1 1 1" emission="0.1"
|
||
specular="0.2" shininess="0.3" texrepeat="1 2"/>
|
||
</asset>
|
||
<worldbody>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="base_geom" type="box" size="0.1 0.1 0.1" mass="1.0"
|
||
material="test_mat"/>
|
||
</body>
|
||
</worldbody>
|
||
</mujoco>
|
||
"""
|
||
|
||
|
||
def _make_mat_env(device, num_envs=NUM_ENVS):
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(MAT_XML))
|
||
scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
sim.expand_model_fields(
|
||
("mat_rgba", "mat_emission", "mat_specular", "mat_shininess", "mat_texrepeat")
|
||
)
|
||
return Env(scene, sim, device)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def mat_env(device):
|
||
return _make_mat_env(device)
|
||
|
||
|
||
def test_mat_rgba_abs(mat_env):
|
||
"""Values within range, diversity across envs."""
|
||
torch.manual_seed(42)
|
||
env = mat_env
|
||
|
||
dr.mat_rgba(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.2, 0.8),
|
||
asset_cfg=SceneEntityCfg("robot", material_names=("test_mat",)),
|
||
operation="abs",
|
||
)
|
||
|
||
mat_id = mujoco.mj_name2id(
|
||
env.sim.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "robot/test_mat"
|
||
)
|
||
rgba = env.sim.model.mat_rgba[:, mat_id, :]
|
||
assert torch.all((rgba >= 0.2 - 1e-5) & (rgba <= 0.8 + 1e-5))
|
||
assert len(torch.unique(rgba[:, 0])) >= 2
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("func_name", "field", "ranges"),
|
||
[
|
||
("mat_emission", "mat_emission", (0.1, 0.7)),
|
||
("mat_specular", "mat_specular", (0.0, 1.0)),
|
||
("mat_shininess", "mat_shininess", (0.2, 0.9)),
|
||
],
|
||
)
|
||
def test_scalar_material_fields_abs(mat_env, func_name, field, ranges):
|
||
"""Scalar material fields randomize per selected material."""
|
||
torch.manual_seed(42)
|
||
env = mat_env
|
||
func = getattr(dr, func_name)
|
||
|
||
func(
|
||
env,
|
||
env_ids=None,
|
||
ranges=ranges,
|
||
asset_cfg=SceneEntityCfg("robot", material_names=("test_mat",)),
|
||
operation="abs",
|
||
)
|
||
|
||
mat_id = mujoco.mj_name2id(
|
||
env.sim.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "robot/test_mat"
|
||
)
|
||
values = getattr(env.sim.model, field)[:, mat_id]
|
||
lower, upper = ranges
|
||
assert torch.all((values >= lower - 1e-5) & (values <= upper + 1e-5))
|
||
assert len(torch.unique(values)) >= 2
|
||
|
||
|
||
def test_mat_texrepeat_abs(mat_env):
|
||
"""Texture repeat randomizes S/T axes for selected materials."""
|
||
torch.manual_seed(42)
|
||
env = mat_env
|
||
|
||
dr.mat_texrepeat(
|
||
env,
|
||
env_ids=None,
|
||
ranges={0: (1.0, 3.0), 1: (4.0, 6.0)},
|
||
asset_cfg=SceneEntityCfg("robot", material_names=("test_mat",)),
|
||
operation="abs",
|
||
)
|
||
|
||
mat_id = mujoco.mj_name2id(
|
||
env.sim.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "robot/test_mat"
|
||
)
|
||
texrepeat = env.sim.model.mat_texrepeat[:, mat_id, :]
|
||
assert torch.all((texrepeat[:, 0] >= 1.0 - 1e-5) & (texrepeat[:, 0] <= 3.0 + 1e-5))
|
||
assert torch.all((texrepeat[:, 1] >= 4.0 - 1e-5) & (texrepeat[:, 1] <= 6.0 + 1e-5))
|
||
assert len(torch.unique(texrepeat[:, 0])) >= 2
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("func_name", "field"),
|
||
[
|
||
("mat_emission", "mat_emission"),
|
||
("mat_specular", "mat_specular"),
|
||
("mat_shininess", "mat_shininess"),
|
||
],
|
||
)
|
||
def test_scalar_material_fields_scale_use_defaults(mat_env, func_name, field):
|
||
"""Scale operation uses compiled defaults for scalar material fields."""
|
||
env = mat_env
|
||
func = getattr(dr, func_name)
|
||
|
||
func(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(2.0, 2.0),
|
||
asset_cfg=SceneEntityCfg("robot", material_names=("test_mat",)),
|
||
operation="scale",
|
||
)
|
||
|
||
mat_id = mujoco.mj_name2id(
|
||
env.sim.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "robot/test_mat"
|
||
)
|
||
expected = env.sim.get_default_field(field)[mat_id] * 2.0
|
||
values = getattr(env.sim.model, field)[:, mat_id]
|
||
assert torch.allclose(values, expected.expand_as(values))
|
||
|
||
|
||
def test_mat_texrepeat_scale_uses_defaults(mat_env):
|
||
"""Scale operation uses compiled defaults and both S/T axes by default."""
|
||
env = mat_env
|
||
|
||
dr.mat_texrepeat(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(2.0, 2.0),
|
||
asset_cfg=SceneEntityCfg("robot", material_names=("test_mat",)),
|
||
operation="scale",
|
||
)
|
||
|
||
mat_id = mujoco.mj_name2id(
|
||
env.sim.mj_model, mujoco.mjtObj.mjOBJ_MATERIAL, "robot/test_mat"
|
||
)
|
||
expected = env.sim.get_default_field("mat_texrepeat")[mat_id] * 2.0
|
||
texrepeat = env.sim.model.mat_texrepeat[:, mat_id, :]
|
||
assert torch.allclose(texrepeat, expected.expand_as(texrepeat))
|
||
|
||
|
||
def test_mat_rgba_invalid_name(mat_env):
|
||
"""ValueError for unknown material name."""
|
||
env = mat_env
|
||
|
||
with pytest.raises(ValueError, match="nonexistent_material"):
|
||
cfg = SceneEntityCfg("robot", material_names=("nonexistent_material",))
|
||
cfg.resolve(env.scene)
|
||
dr.mat_rgba(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.2, 0.8),
|
||
asset_cfg=cfg,
|
||
)
|
||
|
||
|
||
# geom_matid DR tests.
|
||
|
||
GEOM_MATID_XML = """
|
||
<mujoco>
|
||
<asset>
|
||
<material name="mat_a" rgba="1 0 0 1"/>
|
||
<material name="mat_b" rgba="0 1 0 1"/>
|
||
<material name="mat_c" rgba="0 0 1 1"/>
|
||
</asset>
|
||
<worldbody>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="geom1" type="box" size="0.1 0.1 0.1" mass="1.0" material="mat_a"/>
|
||
<geom name="geom2" type="sphere" size="0.05" mass="0.3" material="mat_b"/>
|
||
</body>
|
||
</worldbody>
|
||
</mujoco>
|
||
"""
|
||
|
||
|
||
def _make_matid_env(device, num_envs=NUM_ENVS):
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(GEOM_MATID_XML))
|
||
scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
sim.expand_model_fields(("geom_matid",))
|
||
return Env(scene, sim, device)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def matid_env(device):
|
||
return _make_matid_env(device)
|
||
|
||
|
||
def test_geom_matid_draws_from_pool(matid_env):
|
||
"""Assigned matids come from the pool and actually change from the defaults."""
|
||
torch.manual_seed(42)
|
||
env = matid_env
|
||
robot = env.scene["robot"]
|
||
asset_cfg = SceneEntityCfg("robot", geom_names=(".*",), material_names=(".*",))
|
||
asset_cfg.resolve(env.scene)
|
||
|
||
geom_ids = robot.indexing.geom_ids[asset_cfg.geom_ids]
|
||
mat_ids = robot.indexing.mat_ids[asset_cfg.material_ids]
|
||
original = env.sim.model.geom_matid[:, geom_ids].clone()
|
||
|
||
dr.geom_matid(env, env_ids=None, asset_cfg=asset_cfg)
|
||
|
||
assigned = env.sim.model.geom_matid[:, geom_ids]
|
||
# Every assignment is a valid member of the selected pool.
|
||
assert torch.all(torch.isin(assigned, mat_ids))
|
||
# Randomization is not a no-op: values differ from the baked defaults, and at
|
||
# least one geom draws a material it did not start with (proving the full pool
|
||
# is sampled, not just the compile-time defaults).
|
||
assert not torch.equal(assigned, original)
|
||
assert torch.isin(assigned, torch.unique(original), invert=True).any()
|
||
|
||
|
||
def test_geom_matid_partial_env_ids(matid_env):
|
||
"""Randomizing subset of envs leaves others unchanged."""
|
||
torch.manual_seed(42)
|
||
env = matid_env
|
||
robot = env.scene["robot"]
|
||
asset_cfg = SceneEntityCfg("robot", geom_names=(".*",), material_names=(".*",))
|
||
asset_cfg.resolve(env.scene)
|
||
geom_ids = robot.indexing.geom_ids[asset_cfg.geom_ids]
|
||
|
||
original = env.sim.model.geom_matid[:, geom_ids].clone()
|
||
|
||
dr.geom_matid(
|
||
env, env_ids=torch.tensor([0, 2], device=env.device), asset_cfg=asset_cfg
|
||
)
|
||
|
||
result = env.sim.model.geom_matid[:, geom_ids]
|
||
assert torch.all(result[1] == original[1])
|
||
assert torch.all(result[3] == original[3])
|
||
|
||
|
||
def test_geom_matid_invalid_material_name(matid_env):
|
||
"""Unknown material name is rejected during config resolution."""
|
||
env = matid_env
|
||
|
||
with pytest.raises(ValueError, match="nonexistent_material"):
|
||
cfg = SceneEntityCfg(
|
||
"robot", geom_names=(".*",), material_names=("nonexistent_material",)
|
||
)
|
||
cfg.resolve(env.scene)
|
||
dr.geom_matid(env, env_ids=None, asset_cfg=cfg)
|
||
|
||
|
||
def test_geom_matid_empty_material_selection(matid_env):
|
||
"""geom_matid raises when the resolved material pool is empty."""
|
||
env = matid_env
|
||
cfg = SceneEntityCfg("robot", geom_names=(".*",), material_names=())
|
||
cfg.resolve(env.scene)
|
||
|
||
with pytest.raises(ValueError, match="No materials selected"):
|
||
dr.geom_matid(env, env_ids=None, asset_cfg=cfg)
|
||
|
||
|
||
def test_geom_matid_shared_random(matid_env):
|
||
"""All geoms within same env get same material, envs differ."""
|
||
torch.manual_seed(42)
|
||
env = matid_env
|
||
robot = env.scene["robot"]
|
||
asset_cfg = SceneEntityCfg("robot", geom_names=(".*",), material_names=(".*",))
|
||
asset_cfg.resolve(env.scene)
|
||
geom_ids = robot.indexing.geom_ids[asset_cfg.geom_ids]
|
||
|
||
dr.geom_matid(env, env_ids=None, asset_cfg=asset_cfg, shared_random=True)
|
||
|
||
assigned = env.sim.model.geom_matid[:, geom_ids]
|
||
for env_idx in range(env.num_envs):
|
||
env_matids = assigned[env_idx]
|
||
assert torch.all(env_matids == env_matids[0])
|
||
|
||
assert len(torch.unique(assigned[:, 0])) > 1
|
||
|
||
|
||
# mat_texid DR tests.
|
||
|
||
MAT_TEXID_XML = """
|
||
<mujoco>
|
||
<asset>
|
||
<texture name="tex_a" type="2d" builtin="flat" rgb1="1 0 0" width="4" height="4"/>
|
||
<texture name="tex_b" type="2d" builtin="flat" rgb1="0 1 0" width="4" height="4"/>
|
||
<texture name="tex_c" type="2d" builtin="flat" rgb1="0 0 1" width="4" height="4"/>
|
||
<texture name="tex_d" type="2d" builtin="flat" rgb1="1 1 0" width="4" height="4"/>
|
||
<material name="mat_a" texture="tex_a"/>
|
||
<material name="mat_b" texture="tex_b"/>
|
||
</asset>
|
||
<worldbody>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="geom1" type="box" size="0.1 0.1 0.1" mass="1.0" material="mat_a"/>
|
||
<geom name="geom2" type="sphere" size="0.05" mass="0.3" material="mat_b"/>
|
||
</body>
|
||
</worldbody>
|
||
</mujoco>
|
||
"""
|
||
|
||
|
||
def _make_texid_env(device, num_envs=NUM_ENVS):
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(MAT_TEXID_XML))
|
||
scene_cfg = SceneCfg(num_envs=num_envs, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
sim = Simulation(num_envs=num_envs, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
sim.expand_model_fields(("mat_texid",))
|
||
return Env(scene, sim, device)
|
||
|
||
|
||
@pytest.fixture
|
||
def texid_env(device):
|
||
# Function-scoped so tests compare against pristine mat_texid defaults.
|
||
return _make_texid_env(device)
|
||
|
||
|
||
def test_mat_texid_draws_from_pool(texid_env):
|
||
"""Assigned texids come from the pool and actually change from the defaults."""
|
||
torch.manual_seed(42)
|
||
env = texid_env
|
||
robot = env.scene["robot"]
|
||
asset_cfg = SceneEntityCfg("robot", material_names=(".*",), texture_names=(".*",))
|
||
asset_cfg.resolve(env.scene)
|
||
|
||
mat_ids = robot.indexing.mat_ids[asset_cfg.material_ids]
|
||
tex_ids = robot.indexing.tex_ids[asset_cfg.texture_ids]
|
||
role = mujoco.mjtTextureRole.mjTEXROLE_RGB.value
|
||
original = env.sim.model.mat_texid[:, mat_ids, role].clone()
|
||
|
||
dr.mat_texid(env, env_ids=None, asset_cfg=asset_cfg)
|
||
|
||
assigned = env.sim.model.mat_texid[:, mat_ids, role]
|
||
assert torch.all(torch.isin(assigned, tex_ids))
|
||
assert not torch.equal(assigned, original)
|
||
assert torch.isin(assigned, torch.unique(original), invert=True).any()
|
||
|
||
|
||
def test_mat_texid_respects_texture_subset(texid_env):
|
||
"""Textures outside the selection are never sampled."""
|
||
torch.manual_seed(42)
|
||
env = texid_env
|
||
robot = env.scene["robot"]
|
||
# The pool excludes tex_a and tex_b, which are the two materials' defaults.
|
||
asset_cfg = SceneEntityCfg(
|
||
"robot", material_names=(".*",), texture_names=("tex_c", "tex_d")
|
||
)
|
||
asset_cfg.resolve(env.scene)
|
||
|
||
mat_ids = robot.indexing.mat_ids[asset_cfg.material_ids]
|
||
tex_ids = robot.indexing.tex_ids[asset_cfg.texture_ids]
|
||
role = mujoco.mjtTextureRole.mjTEXROLE_RGB.value
|
||
excluded = torch.unique(env.sim.model.mat_texid[:, mat_ids, role])
|
||
|
||
dr.mat_texid(env, env_ids=None, asset_cfg=asset_cfg)
|
||
|
||
assigned = env.sim.model.mat_texid[:, mat_ids, role]
|
||
assert torch.all(torch.isin(assigned, tex_ids))
|
||
assert not torch.isin(assigned, excluded).any()
|
||
assert len(torch.unique(assigned)) >= 2
|
||
|
||
|
||
def test_mat_texid_partial_env_ids(texid_env):
|
||
"""Randomizing subset of envs leaves others unchanged."""
|
||
torch.manual_seed(42)
|
||
env = texid_env
|
||
robot = env.scene["robot"]
|
||
asset_cfg = SceneEntityCfg(
|
||
"robot", material_names=(".*",), texture_names=("tex_c", "tex_d")
|
||
)
|
||
asset_cfg.resolve(env.scene)
|
||
mat_ids = robot.indexing.mat_ids[asset_cfg.material_ids]
|
||
tex_ids = robot.indexing.tex_ids[asset_cfg.texture_ids]
|
||
role = mujoco.mjtTextureRole.mjTEXROLE_RGB.value
|
||
|
||
original = env.sim.model.mat_texid[:, mat_ids, role].clone()
|
||
|
||
dr.mat_texid(
|
||
env, env_ids=torch.tensor([0, 2], device=env.device), asset_cfg=asset_cfg
|
||
)
|
||
|
||
result = env.sim.model.mat_texid[:, mat_ids, role]
|
||
# Envs 0, 2 changed, the pool excludes their defaults.
|
||
assert torch.all(torch.isin(result[0], tex_ids))
|
||
assert torch.all(torch.isin(result[2], tex_ids))
|
||
assert not torch.equal(result[0], original[0])
|
||
assert not torch.equal(result[2], original[2])
|
||
# Envs 1, 3 unchanged.
|
||
assert torch.all(result[1] == original[1])
|
||
assert torch.all(result[3] == original[3])
|
||
|
||
|
||
def test_mat_texid_invalid_texture_name(texid_env):
|
||
"""Unknown texture name is rejected during config resolution."""
|
||
env = texid_env
|
||
|
||
with pytest.raises(ValueError, match="nonexistent_texture"):
|
||
cfg = SceneEntityCfg(
|
||
"robot", material_names=(".*",), texture_names=("nonexistent_texture",)
|
||
)
|
||
cfg.resolve(env.scene)
|
||
dr.mat_texid(env, env_ids=None, asset_cfg=cfg)
|
||
|
||
|
||
def test_mat_texid_empty_texture_selection(texid_env):
|
||
"""mat_texid raises when the resolved texture pool is empty."""
|
||
env = texid_env
|
||
cfg = SceneEntityCfg("robot", material_names=(".*",), texture_names=())
|
||
cfg.resolve(env.scene)
|
||
|
||
with pytest.raises(ValueError, match="No textures selected"):
|
||
dr.mat_texid(env, env_ids=None, asset_cfg=cfg)
|
||
|
||
|
||
def test_mat_texid_shared_random(texid_env):
|
||
"""All materials within same env get same texture, envs differ."""
|
||
torch.manual_seed(42)
|
||
env = texid_env
|
||
robot = env.scene["robot"]
|
||
asset_cfg = SceneEntityCfg("robot", material_names=(".*",), texture_names=(".*",))
|
||
asset_cfg.resolve(env.scene)
|
||
mat_ids = robot.indexing.mat_ids[asset_cfg.material_ids]
|
||
role = mujoco.mjtTextureRole.mjTEXROLE_RGB.value
|
||
|
||
dr.mat_texid(env, env_ids=None, asset_cfg=asset_cfg, shared_random=True)
|
||
|
||
assigned = env.sim.model.mat_texid[:, mat_ids, role]
|
||
for env_idx in range(env.num_envs):
|
||
env_texids = assigned[env_idx]
|
||
assert torch.all(env_texids == env_texids[0])
|
||
|
||
assert len(torch.unique(assigned[:, 0])) > 1
|
||
|
||
|
||
# pair_friction tests.
|
||
|
||
PAIR_XML = """
|
||
<mujoco>
|
||
<worldbody>
|
||
<body name="base" pos="0 0 1">
|
||
<freejoint name="free_joint"/>
|
||
<geom name="base_geom" type="box" size="0.1 0.1 0.1" mass="1.0"/>
|
||
<body name="foot" pos="0 0 -0.15">
|
||
<geom name="foot_geom" type="box" size="0.05 0.05 0.02" mass="0.1"/>
|
||
</body>
|
||
</body>
|
||
<geom name="floor" type="plane" size="2 2 0.1"/>
|
||
</worldbody>
|
||
<contact>
|
||
<pair name="base_floor" geom1="base_geom" geom2="floor"
|
||
friction="0.5 0.5 0.005 0.0001 0.0001"/>
|
||
<pair name="foot_floor" geom1="foot_geom" geom2="floor"
|
||
friction="0.3 0.3 0.003 0.0002 0.0002"/>
|
||
</contact>
|
||
</mujoco>
|
||
"""
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def pair_env(device):
|
||
entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(PAIR_XML))
|
||
scene_cfg = SceneCfg(num_envs=NUM_ENVS, entities={"robot": entity_cfg})
|
||
scene = Scene(scene_cfg, device)
|
||
model = scene.compile()
|
||
sim = Simulation(num_envs=NUM_ENVS, cfg=SimulationCfg(), model=model, device=device)
|
||
scene.initialize(model, sim.model, sim.data)
|
||
sim.expand_model_fields(("pair_friction",))
|
||
return Env(scene, sim, device)
|
||
|
||
|
||
def test_pair_friction_abs(pair_env):
|
||
"""Values set directly within range, diverse across envs."""
|
||
torch.manual_seed(42)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
|
||
dr.pair_friction(env, env_ids=None, ranges=(0.4, 1.5), operation="abs")
|
||
|
||
friction = env.sim.model.pair_friction[:, robot.indexing.pair_ids, 0]
|
||
assert torch.all((friction >= 0.4) & (friction <= 1.5))
|
||
assert len(torch.unique(friction)) >= 2
|
||
|
||
|
||
def test_pair_friction_scale(pair_env):
|
||
"""Values = default * sample; no accumulation after 3 calls."""
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
pair_idx = robot.indexing.pair_ids[0]
|
||
default_val = env.sim.get_default_field("pair_friction")[pair_idx, 0].item()
|
||
|
||
for _ in range(3):
|
||
dr.pair_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(2.0, 2.0),
|
||
operation="scale",
|
||
asset_cfg=SceneEntityCfg("robot", pair_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
result = env.sim.model.pair_friction[:, pair_idx, 0]
|
||
assert torch.allclose(result, torch.full_like(result, default_val * 2.0), atol=1e-5)
|
||
|
||
|
||
def test_pair_friction_add(pair_env):
|
||
"""Values = default + sample; no accumulation after 3 calls."""
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
pair_idx = robot.indexing.pair_ids[0]
|
||
default_val = env.sim.get_default_field("pair_friction")[pair_idx, 0].item()
|
||
|
||
for _ in range(3):
|
||
dr.pair_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.1, 0.1),
|
||
operation="add",
|
||
asset_cfg=SceneEntityCfg("robot", pair_ids=[0]),
|
||
axes=[0],
|
||
)
|
||
|
||
result = env.sim.model.pair_friction[:, pair_idx, 0]
|
||
assert torch.allclose(result, torch.full_like(result, default_val + 0.1), atol=1e-5)
|
||
|
||
|
||
def test_pair_friction_axes_selectivity(pair_env):
|
||
"""Only the targeted axis changes; others stay at their original values."""
|
||
torch.manual_seed(7)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
|
||
before = env.sim.model.pair_friction.clone()
|
||
|
||
dr.pair_friction(env, env_ids=None, ranges=(0.8, 1.2), operation="abs", axes=[0])
|
||
|
||
after = env.sim.model.pair_friction
|
||
pair_ids = robot.indexing.pair_ids
|
||
# Axis 0 changed.
|
||
assert not torch.allclose(after[:, pair_ids, 0], before[:, pair_ids, 0])
|
||
# Axes 1-4 unchanged.
|
||
for ax in range(1, 5):
|
||
assert torch.allclose(after[:, pair_ids, ax], before[:, pair_ids, ax])
|
||
|
||
|
||
def test_pair_friction_multi_axis(pair_env):
|
||
"""axes=[0, 1] randomizes both tangent components; axes 2-4 stay unchanged."""
|
||
torch.manual_seed(17)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
|
||
before = env.sim.model.pair_friction.clone()
|
||
|
||
dr.pair_friction(env, env_ids=None, ranges=(0.4, 1.0), operation="abs", axes=[0, 1])
|
||
|
||
after = env.sim.model.pair_friction
|
||
pair_ids = robot.indexing.pair_ids
|
||
# Both tangent axes changed.
|
||
assert not torch.allclose(after[:, pair_ids, 0], before[:, pair_ids, 0])
|
||
assert not torch.allclose(after[:, pair_ids, 1], before[:, pair_ids, 1])
|
||
# Axes 2-4 unchanged.
|
||
for ax in range(2, 5):
|
||
assert torch.allclose(after[:, pair_ids, ax], before[:, pair_ids, ax])
|
||
# Axis 0 and axis 1 are sampled independently (not forced equal).
|
||
assert not torch.allclose(after[:, pair_ids, 0], after[:, pair_ids, 1])
|
||
|
||
|
||
def test_pair_friction_partial_env_ids(pair_env):
|
||
"""Randomizing a subset of envs leaves the others unchanged."""
|
||
torch.manual_seed(99)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
|
||
before = env.sim.model.pair_friction.clone()
|
||
randomized_ids = torch.tensor([0, 1], device=env.device)
|
||
|
||
dr.pair_friction(
|
||
env, env_ids=randomized_ids, ranges=(2.0, 3.0), operation="abs", axes=[0]
|
||
)
|
||
|
||
after = env.sim.model.pair_friction
|
||
pair_ids = robot.indexing.pair_ids
|
||
unchanged_ids = torch.tensor([2, 3], device=env.device)
|
||
assert torch.allclose(
|
||
after[unchanged_ids[:, None], pair_ids[None, :], :],
|
||
before[unchanged_ids[:, None], pair_ids[None, :], :],
|
||
)
|
||
|
||
|
||
def test_pair_friction_shared_random(pair_env):
|
||
"""All pairs in the same env get the same value; envs differ."""
|
||
torch.manual_seed(5)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
|
||
dr.pair_friction(
|
||
env, env_ids=None, ranges=(0.5, 2.0), operation="abs", axes=[0], shared_random=True
|
||
)
|
||
|
||
pair_ids = robot.indexing.pair_ids
|
||
friction = env.sim.model.pair_friction[:, pair_ids, 0]
|
||
# Within each env, all pairs have the same value.
|
||
for e in range(env.num_envs):
|
||
row = friction[e]
|
||
assert torch.allclose(row, row[0].expand_as(row), atol=1e-6)
|
||
# Across envs, values differ.
|
||
assert not torch.allclose(friction[0], friction[1])
|
||
|
||
|
||
def test_pair_friction_by_name(pair_env):
|
||
"""Targeting pair by name only modifies that pair."""
|
||
torch.manual_seed(11)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
|
||
foot_cfg = SceneEntityCfg("robot", pair_names=("foot_floor",))
|
||
foot_cfg.resolve(env.scene)
|
||
base_cfg = SceneEntityCfg("robot", pair_names=("base_floor",))
|
||
base_cfg.resolve(env.scene)
|
||
foot_pair_id = robot.indexing.pair_ids[foot_cfg.pair_ids]
|
||
base_pair_id = robot.indexing.pair_ids[base_cfg.pair_ids]
|
||
|
||
before = env.sim.model.pair_friction.clone()
|
||
dr.pair_friction(
|
||
env, env_ids=None, ranges=(1.5, 2.5), operation="abs", asset_cfg=foot_cfg
|
||
)
|
||
|
||
after = env.sim.model.pair_friction
|
||
# foot_floor changed.
|
||
assert not torch.allclose(after[:, foot_pair_id, 0], before[:, foot_pair_id, 0])
|
||
# base_floor unchanged.
|
||
assert torch.allclose(after[:, base_pair_id, :], before[:, base_pair_id, :])
|
||
|
||
|
||
def test_pair_friction_string_ranges(pair_env):
|
||
"""Dict with pair name patterns randomizes the matched pairs correctly."""
|
||
torch.manual_seed(13)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
|
||
dr.pair_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges={"base_floor": (1.0, 1.0), "foot_floor": (0.5, 0.5)},
|
||
operation="abs",
|
||
axes=[0],
|
||
)
|
||
|
||
base_cfg = SceneEntityCfg("robot", pair_names=("base_floor",))
|
||
base_cfg.resolve(env.scene)
|
||
foot_cfg = SceneEntityCfg("robot", pair_names=("foot_floor",))
|
||
foot_cfg.resolve(env.scene)
|
||
base_id = robot.indexing.pair_ids[base_cfg.pair_ids]
|
||
foot_id = robot.indexing.pair_ids[foot_cfg.pair_ids]
|
||
assert torch.allclose(
|
||
env.sim.model.pair_friction[:, base_id, 0],
|
||
torch.ones(env.num_envs, device=env.device),
|
||
atol=1e-5,
|
||
)
|
||
assert torch.allclose(
|
||
env.sim.model.pair_friction[:, foot_id, 0],
|
||
torch.full((env.num_envs,), 0.5, device=env.device),
|
||
atol=1e-5,
|
||
)
|
||
|
||
|
||
def test_pair_friction_isotropic_tangent(pair_env):
|
||
"""isotropic=True mirrors tangent2 = tangent1; spin and roll axes unchanged."""
|
||
torch.manual_seed(21)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
pair_ids = robot.indexing.pair_ids
|
||
|
||
before = env.sim.model.pair_friction.clone()
|
||
dr.pair_friction(
|
||
env, env_ids=None, ranges=(0.4, 1.0), operation="abs", axes=[0], isotropic=True
|
||
)
|
||
|
||
after = env.sim.model.pair_friction
|
||
# Axis 0 was randomized into range.
|
||
assert torch.all((after[:, pair_ids, 0] >= 0.4) & (after[:, pair_ids, 0] <= 1.0))
|
||
# Axis 1 equals axis 0 exactly.
|
||
assert torch.allclose(after[:, pair_ids, 1], after[:, pair_ids, 0])
|
||
# Axes 2, 3, 4 untouched.
|
||
for ax in (2, 3, 4):
|
||
assert torch.allclose(after[:, pair_ids, ax], before[:, pair_ids, ax])
|
||
|
||
|
||
def test_pair_friction_isotropic_shared(pair_env):
|
||
"""isotropic=True + shared_random=True: all pairs same value, tangent2 == tangent1."""
|
||
torch.manual_seed(33)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
pair_ids = robot.indexing.pair_ids
|
||
|
||
dr.pair_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges=(0.4, 1.0),
|
||
operation="abs",
|
||
axes=[0],
|
||
shared_random=True,
|
||
isotropic=True,
|
||
)
|
||
|
||
friction = env.sim.model.pair_friction[:, pair_ids, :]
|
||
# All pairs in each env share the same tangent1 value.
|
||
for e in range(env.num_envs):
|
||
assert torch.allclose(
|
||
friction[e, :, 0], friction[e, 0, 0].expand(len(pair_ids)), atol=1e-6
|
||
)
|
||
# Across envs, values differ.
|
||
assert not torch.allclose(friction[0, 0, 0], friction[1, 0, 0])
|
||
# tangent2 == tangent1 everywhere.
|
||
assert torch.allclose(friction[:, :, 1], friction[:, :, 0])
|
||
|
||
|
||
def test_pair_friction_isotropic_roll(pair_env):
|
||
"""isotropic=True with roll axes mirrors roll2 = roll1; tangent axes unchanged."""
|
||
torch.manual_seed(41)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
pair_ids = robot.indexing.pair_ids
|
||
|
||
before = env.sim.model.pair_friction.clone()
|
||
dr.pair_friction(
|
||
env, env_ids=None, ranges=(0.001, 0.01), operation="abs", axes=[3], isotropic=True
|
||
)
|
||
|
||
after = env.sim.model.pair_friction
|
||
# Axis 3 was randomized.
|
||
assert not torch.allclose(after[:, pair_ids, 3], before[:, pair_ids, 3])
|
||
# Axis 4 equals axis 3 exactly.
|
||
assert torch.allclose(after[:, pair_ids, 4], after[:, pair_ids, 3])
|
||
# Tangent and spin axes untouched.
|
||
for ax in (0, 1, 2):
|
||
assert torch.allclose(after[:, pair_ids, ax], before[:, pair_ids, ax])
|
||
|
||
|
||
def test_pair_friction_isotropic_roll_dict_ranges(pair_env):
|
||
"""isotropic=True with dict-int ranges containing roll axis triggers roll2 = roll1."""
|
||
torch.manual_seed(53)
|
||
env = pair_env
|
||
robot = env.scene["robot"]
|
||
pair_ids = robot.indexing.pair_ids
|
||
|
||
before = env.sim.model.pair_friction.clone()
|
||
dr.pair_friction(
|
||
env,
|
||
env_ids=None,
|
||
ranges={3: (0.001, 0.01)},
|
||
operation="abs",
|
||
isotropic=True,
|
||
)
|
||
|
||
after = env.sim.model.pair_friction
|
||
# Axis 3 was randomized.
|
||
assert not torch.allclose(after[:, pair_ids, 3], before[:, pair_ids, 3])
|
||
# Axis 4 equals axis 3 exactly (dict-int ranges must trigger the roll copy).
|
||
assert torch.allclose(after[:, pair_ids, 4], after[:, pair_ids, 3])
|
||
# Tangent and spin axes untouched.
|
||
for ax in (0, 1, 2):
|
||
assert torch.allclose(after[:, pair_ids, ax], before[:, pair_ids, ax])
|
||
|
||
|
||
def test_pair_friction_invalid_name(pair_env):
|
||
"""ValueError for unknown pair name."""
|
||
env = pair_env
|
||
|
||
with pytest.raises(ValueError, match="nonexistent_pair"):
|
||
cfg = SceneEntityCfg("robot", pair_names=("nonexistent_pair",))
|
||
cfg.resolve(env.scene)
|