mjlab/tests/test_raycast_sensor.py
Upstream Snapshot 32a241c28f
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
Import upstream snapshot c19f713c415a699a79d71cd96aa13c3104a05047
Upstream: https://github.com/michaelgillett/mjlab
Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047
Upstream-Branch: main
2026-08-28 15:42:17 +08:00

1245 lines
39 KiB
Python

"""Tests for raycast_sensor.py."""
from __future__ import annotations
import math
import pytest
import torch
from conftest import get_test_device, make_scene_and_sim
from mjlab.envs.mdp.observations import height_scan
from mjlab.sensor import (
GridPatternCfg,
ObjRef,
PinholeCameraPatternCfg,
RayCastData,
RayCastSensorCfg,
RingPatternCfg,
)
from mjlab.sensor.raycast_sensor import _geom_groups_to_vec6
from mjlab.sim import MujocoCfg, SimulationCfg
@pytest.fixture(scope="module")
def device():
return get_test_device()
def test_basic_raycast_hit_detection(robot_with_floor_xml, device):
"""Verify rays detect the ground plane and return correct distances."""
raycast_cfg = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(
size=(0.5, 0.5), resolution=0.25, direction=(0.0, 0.0, -1.0)
),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(
device, robot_with_floor_xml, sensors=(raycast_cfg,), num_envs=2
)
sensor = scene["terrain_scan"]
sim.step()
sim.sense()
data = sensor.data
assert isinstance(data, RayCastData)
assert data.distances.shape[0] == 2 # num_envs
assert data.distances.shape[1] == sensor.num_rays
assert data.normals_w.shape == (2, sensor.num_rays, 3)
# All rays should hit the floor (distance > 0).
assert torch.all(data.distances >= 0)
# Distance should be approximately 2m (body at z=2, floor at z=0).
assert torch.allclose(data.distances, torch.full_like(data.distances, 2.0), atol=0.1)
def test_raycast_normals_point_up(robot_with_floor_xml, device):
"""Verify surface normals point upward when hitting a horizontal floor."""
raycast_cfg = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(
size=(0.3, 0.3), resolution=0.15, direction=(0.0, 0.0, -1.0)
),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, robot_with_floor_xml, sensors=(raycast_cfg,))
sensor = scene["terrain_scan"]
sim.step()
sim.sense()
data = sensor.data
# Normals should point up (+Z) for a horizontal floor.
expected = torch.zeros_like(data.normals_w)
expected[:, :, 2] = 1.0
assert torch.allclose(data.normals_w, expected)
def test_raycast_miss_returns_negative_one(device):
"""Verify rays that miss return distance of -1."""
no_floor_xml = """
<mujoco>
<worldbody>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="box" size="0.2 0.2 0.1" mass="5.0"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(
size=(0.3, 0.3), resolution=0.15, direction=(0.0, 0.0, -1.0)
),
max_distance=10.0,
exclude_parent_body=True,
)
scene, sim = make_scene_and_sim(device, no_floor_xml, sensors=(raycast_cfg,))
sensor = scene["terrain_scan"]
sim.step()
sim.sense()
data = sensor.data
# All rays should miss (distance = -1).
assert torch.all(data.distances == -1)
def test_raycast_exclude_parent_body(robot_with_floor_xml, device):
"""Verify parent body is excluded from ray intersection when configured."""
raycast_cfg = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.1, 0.1), resolution=0.1, direction=(0.0, 0.0, -1.0)),
max_distance=10.0,
exclude_parent_body=True,
)
scene, sim = make_scene_and_sim(device, robot_with_floor_xml, sensors=(raycast_cfg,))
sensor = scene["terrain_scan"]
sim.step()
sim.sense()
data = sensor.data
# Rays should hit the floor, not the parent body geom.
# Floor is at z=0, body is at z=2, so distance should be ~2m.
assert torch.allclose(data.distances, torch.full_like(data.distances, 2.0), atol=0.1)
def test_raycast_include_geom_groups(device):
"""Verify include_geom_groups filters which geoms are hit."""
groups_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0" group="0"/>
<geom name="platform" type="box" size="1 1 0.1" pos="0 0 1" group="1"/>
<body name="base" pos="0 0 3">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
# Only include group 0 (floor) - should skip the platform in group 1.
raycast_cfg = RayCastSensorCfg(
name="group_filter_test",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1, direction=(0.0, 0.0, -1.0)),
max_distance=10.0,
include_geom_groups=(0,),
)
scene, sim = make_scene_and_sim(device, groups_xml, sensors=(raycast_cfg,))
sensor = scene["group_filter_test"]
sim.step()
sim.sense()
data = sensor.data
# Should hit floor at z=0, not platform at z=1.1. Distance from z=3 to z=0 is 3m.
assert torch.allclose(data.distances, torch.full_like(data.distances, 3.0), atol=0.1)
# Now test with group 1 included - should hit platform instead.
raycast_cfg_group1 = RayCastSensorCfg(
name="group1_test",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1, direction=(0.0, 0.0, -1.0)),
max_distance=10.0,
include_geom_groups=(1,),
)
scene2, sim2 = make_scene_and_sim(device, groups_xml, sensors=(raycast_cfg_group1,))
sensor2 = scene2["group1_test"]
sim2.step()
sim2.sense()
data2 = sensor2.data
# Should hit platform at z=1.1. Distance from z=3 to z=1.1 is 1.9m.
assert torch.allclose(
data2.distances, torch.full_like(data2.distances, 1.9), atol=0.1
)
def test_raycast_frame_attachment_geom(device):
"""Verify rays can be attached to a geom frame."""
geom_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="sensor_mount" type="box" size="0.1 0.1 0.05" pos="0 0 -0.05"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="geom_scan",
frame=ObjRef(type="geom", name="sensor_mount", entity="robot"),
pattern=GridPatternCfg(size=(0.2, 0.2), resolution=0.1, direction=(0.0, 0.0, -1.0)),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, geom_xml, sensors=(raycast_cfg,))
sensor = scene["geom_scan"]
sim.step()
sim.sense()
data = sensor.data
assert isinstance(data, RayCastData)
# Geom is at z=1.95 (body at z=2, geom offset -0.05), floor at z=0.
assert torch.allclose(data.distances, torch.full_like(data.distances, 1.95), atol=0.1)
def test_raycast_frame_attachment_site(robot_with_floor_xml, device):
"""Verify rays can be attached to a site frame."""
raycast_cfg = RayCastSensorCfg(
name="site_scan",
frame=ObjRef(type="site", name="base_site", entity="robot"),
pattern=GridPatternCfg(size=(0.2, 0.2), resolution=0.1, direction=(0.0, 0.0, -1.0)),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, robot_with_floor_xml, sensors=(raycast_cfg,))
sensor = scene["site_scan"]
sim.step()
sim.sense()
data = sensor.data
assert isinstance(data, RayCastData)
# Site is at z=1.9 (body at z=2, site offset -0.1), floor at z=0.
assert torch.allclose(data.distances, torch.full_like(data.distances, 1.9), atol=0.1)
def test_raycast_grid_pattern_num_rays(device):
"""Verify grid pattern generates correct number of rays."""
simple_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1"/>
<body name="base" pos="0 0 1">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1"/>
</body>
</worldbody>
</mujoco>
"""
# Grid: size=(1.0, 0.5), resolution=0.5.
# X: from -0.5 to 0.5 step 0.5 -> 3 points.
# Y: from -0.25 to 0.25 step 0.5 -> 2 points.
# Total: 3 * 2 = 6 rays.
raycast_cfg = RayCastSensorCfg(
name="grid_test",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(1.0, 0.5), resolution=0.5),
)
scene, sim = make_scene_and_sim(device, simple_xml, sensors=(raycast_cfg,))
sensor = scene["grid_test"]
assert sensor.num_rays == 6
def test_raycast_different_direction(device):
"""Verify rays work with non-default direction."""
wall_xml = """
<mujoco>
<worldbody>
<geom name="wall" type="box" size="0.1 5 5" pos="2 0 2"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="forward_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.2, 0.2), resolution=0.1, direction=(1.0, 0.0, 0.0)),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, wall_xml, sensors=(raycast_cfg,))
sensor = scene["forward_scan"]
sim.step()
sim.sense()
data = sensor.data
# Wall is at x=1.9 (wall center at x=2, size 0.1), body at x=0.
# Distance should be ~1.9m.
assert torch.allclose(data.distances, torch.full_like(data.distances, 1.9), atol=0.1)
# Normal should point in -X direction (toward the body).
assert torch.allclose(
data.normals_w[:, :, 0], -torch.ones_like(data.normals_w[:, :, 0]), atol=0.01
)
def test_raycast_error_on_invalid_frame_type(device):
"""Verify ValueError is raised for invalid frame type."""
simple_xml = """
<mujoco>
<worldbody>
<body name="base"><geom type="sphere" size="0.1"/></body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="invalid",
frame=ObjRef(type="joint", name="some_joint", entity="robot"),
pattern=GridPatternCfg(size=(0.1, 0.1), resolution=0.1),
)
with pytest.raises(ValueError, match="must be 'body', 'site', or 'geom'"):
make_scene_and_sim(device, simple_xml, sensors=(raycast_cfg,))
def test_raycast_hit_pos_w_correctness(robot_with_floor_xml, device):
"""Verify hit_pos_w returns correct world-space hit positions."""
raycast_cfg = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.4, 0.4), resolution=0.2, direction=(0.0, 0.0, -1.0)),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, robot_with_floor_xml, sensors=(raycast_cfg,))
sensor = scene["terrain_scan"]
sim.step()
sim.sense()
data = sensor.data
# All hit positions should be on the floor (z=0).
assert torch.allclose(
data.hit_pos_w[:, :, 2], torch.zeros_like(data.hit_pos_w[:, :, 2]), atol=0.01
)
# Hit positions X and Y should match the ray grid pattern offset from body origin.
# Body is at (0, 0, 2), grid is 0.4x0.4 with 0.2 resolution = 3x3 = 9 rays.
# X positions should be in range [-0.2, 0.2], Y positions in range [-0.2, 0.2].
assert torch.all(data.hit_pos_w[:, :, 0] >= -0.3)
assert torch.all(data.hit_pos_w[:, :, 0] <= 0.3)
assert torch.all(data.hit_pos_w[:, :, 1] >= -0.3)
assert torch.all(data.hit_pos_w[:, :, 1] <= 0.3)
def test_raycast_max_distance_clamping(device):
"""Verify hits beyond max_distance are reported as misses."""
far_floor_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 5">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="short_range",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.2, 0.2), resolution=0.1, direction=(0.0, 0.0, -1.0)),
max_distance=3.0,
)
scene, sim = make_scene_and_sim(device, far_floor_xml, sensors=(raycast_cfg,))
sensor = scene["short_range"]
sim.step()
sim.sense()
data = sensor.data
# All rays should miss (floor is beyond max_distance).
assert torch.all(data.distances == -1)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Likely bug on CPU MjWarp")
def test_raycast_body_rotation_affects_rays(device):
"""Verify rays rotate with the body frame."""
rotated_body_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="rotated_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1, direction=(0.0, 0.0, -1.0)),
max_distance=10.0,
)
sim_cfg = SimulationCfg(mujoco=MujocoCfg(gravity=(0, 0, 0)), njmax=20)
scene, sim = make_scene_and_sim(
device, rotated_body_xml, sensors=(raycast_cfg,), sim_cfg=sim_cfg
)
sensor = scene["rotated_scan"]
# First, verify baseline: unrotated body, rays hit floor at ~2m.
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data_unrotated = sensor.data
assert torch.allclose(
data_unrotated.distances, torch.full_like(data_unrotated.distances, 2.0), atol=0.1
)
# Now tilt body 45 degrees around X axis.
# Ray direction -Z in body frame becomes diagonal in world frame.
# Distance to floor should be 2 / cos(45) = 2 * sqrt(2) ≈ 2.83m.
angle = math.pi / 4
quat = [math.cos(angle / 2), math.sin(angle / 2), 0, 0] # w, x, y, z
sim.data.qpos[0, 3:7] = torch.tensor(quat, device=device)
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data_rotated = sensor.data
expected_distance = 2.0 / math.cos(angle) # ~2.83m
assert torch.allclose(
data_rotated.distances,
torch.full_like(data_rotated.distances, expected_distance),
atol=0.15,
), f"Expected ~{expected_distance:.2f}m, got {data_rotated.distances}"
# ============================================================================
# Pinhole Camera Pattern Tests
# ============================================================================
def test_pinhole_camera_pattern_num_rays(device):
"""Verify pinhole pattern generates width * height rays."""
simple_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1"/>
<body name="base" pos="0 0 1">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="camera_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=PinholeCameraPatternCfg(width=16, height=12, fovy=74.0),
)
scene, sim = make_scene_and_sim(device, simple_xml, sensors=(raycast_cfg,))
sensor = scene["camera_scan"]
assert sensor.num_rays == 16 * 12
def test_pinhole_from_intrinsic_matrix():
"""Verify from_intrinsic_matrix creates correct config."""
# Intrinsic matrix with fx=500, fy=500, cx=320, cy=240.
intrinsic = [500.0, 0, 320, 0, 500.0, 240, 0, 0, 1]
width, height = 640, 480
cfg = PinholeCameraPatternCfg.from_intrinsic_matrix(intrinsic, width, height)
# Expected vertical FOV: 2 * atan(480 / (2 * 500)) = 2 * atan(0.48) ≈ 51.3 degrees.
fy = intrinsic[4]
expected_fov = 2 * math.atan(height / (2 * fy)) * 180 / math.pi
assert abs(cfg.fovy - expected_fov) < 0.1
assert cfg.width == width
assert cfg.height == height
def test_pinhole_from_mujoco_camera(device):
"""Verify pinhole pattern can be created from MuJoCo camera."""
# XML with a camera that has explicit resolution, sensorsize, and focal.
camera_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
<camera name="depth_cam" pos="0 0 0" resolution="64 48"
sensorsize="0.00389 0.00292" focal="0.00193 0.00193"/>
</body>
</worldbody>
</mujoco>
"""
# Use from_mujoco_camera() to get params from MuJoCo camera.
raycast_cfg = RayCastSensorCfg(
name="camera_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=PinholeCameraPatternCfg.from_mujoco_camera("robot/depth_cam"),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, camera_xml, sensors=(raycast_cfg,))
sensor = scene["camera_scan"]
# Should have 64 * 48 = 3072 rays.
assert sensor.num_rays == 64 * 48
# Verify rays work.
sim.step()
sim.sense()
data = sensor.data
assert torch.all(data.distances >= 0) # Should hit floor
def test_pinhole_from_mujoco_camera_fovy_mode(device):
"""Verify pinhole pattern works with MuJoCo camera using fovy (not sensorsize)."""
# XML with a camera using fovy mode (no sensorsize/focal).
camera_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
<camera name="fovy_cam" pos="0 0 0" fovy="60" resolution="32 24"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="camera_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=PinholeCameraPatternCfg.from_mujoco_camera("robot/fovy_cam"),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, camera_xml, sensors=(raycast_cfg,))
sensor = scene["camera_scan"]
# Should have 32 * 24 = 768 rays.
assert sensor.num_rays == 32 * 24
# Verify rays work.
sim.step()
sim.sense()
data = sensor.data
assert torch.all(data.distances >= 0) # Should hit floor
# ============================================================================
# Ray Alignment Tests
# ============================================================================
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Likely bug on CPU MjWarp")
def test_ray_alignment_yaw(device):
"""Verify yaw alignment ignores pitch/roll."""
rotated_body_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
# With yaw alignment, tilting the body should NOT affect ray direction.
raycast_cfg = RayCastSensorCfg(
name="yaw_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1, direction=(0.0, 0.0, -1.0)),
ray_alignment="yaw",
max_distance=10.0,
)
sim_cfg = SimulationCfg(mujoco=MujocoCfg(gravity=(0, 0, 0)), njmax=20)
scene, sim = make_scene_and_sim(
device, rotated_body_xml, sensors=(raycast_cfg,), sim_cfg=sim_cfg
)
sensor = scene["yaw_scan"]
# Baseline: unrotated.
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data_unrotated = sensor.data
baseline_dist = data_unrotated.distances.clone()
# Tilt body 45 degrees around X axis.
angle = math.pi / 4
quat = [math.cos(angle / 2), math.sin(angle / 2), 0, 0] # w, x, y, z
sim.data.qpos[0, 3:7] = torch.tensor(quat, device=device)
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data_tilted = sensor.data
# With yaw alignment, distance should remain ~2m (not change due to tilt).
assert torch.allclose(data_tilted.distances, baseline_dist, atol=0.1), (
f"Expected ~2m, got {data_tilted.distances}"
)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Likely bug on CPU MjWarp")
def test_ray_alignment_world(device):
"""Verify world alignment keeps rays fixed."""
rotated_body_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
# With world alignment, rotating body should NOT affect ray direction.
raycast_cfg = RayCastSensorCfg(
name="world_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1, direction=(0.0, 0.0, -1.0)),
ray_alignment="world",
max_distance=10.0,
)
sim_cfg = SimulationCfg(mujoco=MujocoCfg(gravity=(0, 0, 0)), njmax=20)
scene, sim = make_scene_and_sim(
device, rotated_body_xml, sensors=(raycast_cfg,), sim_cfg=sim_cfg
)
sensor = scene["world_scan"]
# Baseline: unrotated.
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data_unrotated = sensor.data
baseline_dist = data_unrotated.distances.clone()
# Rotate body 90 degrees around Z (yaw), then tilt 45 degrees around X.
# With world alignment, distance should still be ~2m.
yaw_angle = math.pi / 2
pitch_angle = math.pi / 4
# Compose quaternions: yaw then pitch.
cy, sy = math.cos(yaw_angle / 2), math.sin(yaw_angle / 2)
cp, sp = math.cos(pitch_angle / 2), math.sin(pitch_angle / 2)
# q_yaw = [cy, 0, 0, sy], q_pitch = [cp, sp, 0, 0]
# q = q_pitch * q_yaw
qw = cp * cy
qx = sp * cy
qy = sp * sy
qz = cp * sy
sim.data.qpos[0, 3:7] = torch.tensor([qw, qx, qy, qz], device=device)
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data_rotated = sensor.data
# With world alignment, distance should remain ~2m.
assert torch.allclose(data_rotated.distances, baseline_dist, atol=0.1), (
f"Expected ~2m, got {data_rotated.distances}"
)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Likely bug on CPU MjWarp")
def test_ray_alignment_yaw_singularity(device):
"""Test yaw alignment handles 90 degree pitch singularity correctly.
With yaw alignment, rays should maintain their pattern regardless of
body pitch. At 90 degree pitch, the body's X-axis is vertical, making
yaw extraction ambiguous. The implementation uses Y-axis fallback to
produce a valid yaw rotation.
This test verifies that distances at 90 degree pitch match the
baseline (0 degree pitch).
"""
xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
# Use grid pattern with diagonal direction - has X component to
# expose singularity. Direction [1, 0, -1] points forward and down
# at 45 degrees.
raycast_cfg = RayCastSensorCfg(
name="yaw_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1, direction=(1.0, 0.0, -1.0)),
ray_alignment="yaw",
max_distance=10.0,
)
sim_cfg = SimulationCfg(mujoco=MujocoCfg(gravity=(0, 0, 0)), njmax=20)
scene, sim = make_scene_and_sim(device, xml, sensors=(raycast_cfg,), sim_cfg=sim_cfg)
sensor = scene["yaw_scan"]
# Baseline: no rotation. Ray at 45 degrees from height 2m hits floor
# at x=2, z=0.
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
baseline_hit_pos = sensor.data.hit_pos_w.clone()
# Ray goes diagonally +X and -Z, starting from (0,0,2), should hit
# floor at (2, 0, 0).
assert torch.allclose(
baseline_hit_pos[0, 0, 0],
torch.tensor(2.0, device=device),
atol=0.1,
), f"Baseline X hit should be ~2, got {baseline_hit_pos[0, 0, 0]}"
assert torch.allclose(
baseline_hit_pos[0, 0, 2],
torch.tensor(0.0, device=device),
atol=0.1,
), f"Baseline Z hit should be ~0, got {baseline_hit_pos[0, 0, 2]}"
# Pitch 90 degrees around Y-axis. Body X-axis now points straight
# down (singularity).
angle = math.pi / 2
quat = [math.cos(angle / 2), 0, math.sin(angle / 2), 0] # w, x, y, z
sim.data.qpos[0, 3:7] = torch.tensor(quat, device=device)
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
singularity_hit_pos = sensor.data.hit_pos_w
# With yaw alignment, hit position should match baseline regardless
# of pitch. The ray should still go diagonally and hit at (2, 0, 0).
assert torch.allclose(singularity_hit_pos, baseline_hit_pos, atol=0.1), (
f"Yaw alignment failed at 90 degree pitch singularity.\n"
f"Baseline hit_pos: {baseline_hit_pos}\n"
f"Singularity hit_pos: {singularity_hit_pos}"
)
class _FakeEnv:
"""Minimal env-like object for testing observation functions."""
def __init__(self, scene):
self.scene = scene
def test_height_scan_hits(robot_with_floor_xml, device):
"""height_scan returns correct heights for hits."""
raycast_cfg = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(
size=(0.3, 0.3),
resolution=0.15,
direction=(0.0, 0.0, -1.0),
),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(
device, robot_with_floor_xml, sensors=(raycast_cfg,), num_envs=2
)
sim.step()
sim.sense()
env = _FakeEnv(scene)
heights = height_scan(env, "terrain_scan") # type: ignore[invalid-argument-type]
sensor = scene["terrain_scan"]
# Shape: [num_envs, num_rays].
assert heights.shape == (2, sensor.num_rays)
# Body at z=2, floor at z=0 → height ≈ 2.0.
assert torch.allclose(heights, torch.full_like(heights, 2.0), atol=0.1)
def test_height_scan_misses(device):
"""height_scan reports max_distance for rays that miss (no ground)."""
no_floor_xml = """
<mujoco>
<worldbody>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="box" size="0.2 0.2 0.1" mass="5.0"/>
</body>
</worldbody>
</mujoco>
"""
raycast_cfg = RayCastSensorCfg(
name="terrain_scan",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=GridPatternCfg(
size=(0.2, 0.2),
resolution=0.1,
direction=(0.0, 0.0, -1.0),
),
max_distance=10.0,
exclude_parent_body=True,
)
scene, sim = make_scene_and_sim(device, no_floor_xml, sensors=(raycast_cfg,))
sim.step()
sim.sense()
env = _FakeEnv(scene)
heights = height_scan(env, "terrain_scan") # type: ignore[invalid-argument-type]
# Misses default to sensor max_distance.
assert torch.allclose(
heights, torch.full_like(heights, raycast_cfg.max_distance), atol=1e-5
)
# ============================================================================
# Multi-Frame Tests
# ============================================================================
MULTI_SITE_XML = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="base" pos="0 0 3">
<freejoint name="free_joint"/>
<geom name="base_geom" type="box" size="0.2 0.2 0.1" mass="5.0"/>
<site name="site_top" pos="0 0 0.5"/>
<site name="site_mid" pos="0 0 0"/>
<site name="site_bot" pos="0 0 -0.5"/>
</body>
</worldbody>
</mujoco>
"""
def test_multi_frame_shapes(device):
"""Multi-frame sensor produces correct output shapes."""
cfg = RayCastSensorCfg(
name="multi",
frame=(
ObjRef(type="site", name="site_top", entity="robot"),
ObjRef(type="site", name="site_mid", entity="robot"),
ObjRef(type="site", name="site_bot", entity="robot"),
),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, MULTI_SITE_XML, (cfg,), num_envs=2)
sim.step()
sim.sense()
sensor = scene["multi"]
data = sensor.data
assert sensor.num_frames == 3
assert sensor.num_rays_per_frame == 1
assert sensor.num_rays == 3
assert data.distances.shape == (2, 3)
assert data.frame_pos_w.shape == (2, 3, 3)
assert data.frame_quat_w.shape == (2, 3, 4)
# Backward compat: pos_w/quat_w equal first frame.
assert data.pos_w.shape == (2, 3)
assert data.quat_w.shape == (2, 4)
assert torch.allclose(data.pos_w, data.frame_pos_w[:, 0])
assert torch.allclose(data.quat_w, data.frame_quat_w[:, 0])
def test_multi_frame_heights(device):
"""Sites at different Z offsets produce proportional distances."""
cfg = RayCastSensorCfg(
name="multi",
frame=(
ObjRef(type="site", name="site_top", entity="robot"),
ObjRef(type="site", name="site_mid", entity="robot"),
ObjRef(type="site", name="site_bot", entity="robot"),
),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, MULTI_SITE_XML, (cfg,))
sim.step()
sim.sense()
distances = scene["multi"].data.distances[0]
# site_top at z=3.5, site_mid at z=3, site_bot at z=2.5.
assert distances[0].item() == pytest.approx(3.5, abs=0.1)
assert distances[1].item() == pytest.approx(3.0, abs=0.1)
assert distances[2].item() == pytest.approx(2.5, abs=0.1)
def test_multi_frame_body_exclusion(device):
"""Each frame excludes only its own parent body, not others.
body_a has a platform geom directly below site_b. Frame B's rays
should skip body_b's own geom but HIT body_a's platform. Frame A's
rays should skip body_a and hit the floor.
"""
body_a_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1" pos="0 0 0"/>
<body name="body_a" pos="0 0 1">
<freejoint name="free_a"/>
<geom name="geom_a" type="box" size="2 2 0.1" mass="5.0"/>
<site name="site_a" pos="0 0 0"/>
</body>
</worldbody>
</mujoco>
"""
body_b_xml = """
<mujoco>
<worldbody>
<body name="body_b" pos="0 0 3">
<freejoint name="free_b"/>
<geom name="geom_b" type="box" size="0.5 0.5 0.5" mass="5.0"/>
<site name="site_b" pos="0 0 0"/>
</body>
</worldbody>
</mujoco>
"""
cfg = RayCastSensorCfg(
name="multi",
frame=(
ObjRef(type="site", name="site_a", entity="body_a"),
ObjRef(type="site", name="site_b", entity="body_b"),
),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1),
max_distance=10.0,
exclude_parent_body=True,
)
scene, sim = make_scene_and_sim(
device, {"body_a": body_a_xml, "body_b": body_b_xml}, (cfg,)
)
sim.step()
sim.sense()
distances = scene["multi"].data.distances[0]
# Frame A (site_a at z=1): excludes body_a, hits floor at z=0 -> dist ~1.0.
assert distances[0].item() == pytest.approx(1.0, abs=0.15)
# Frame B (site_b at z=3): excludes body_b, hits body_a's platform
# at z=1.1 -> dist ~1.9.
assert distances[1].item() == pytest.approx(1.9, abs=0.15)
def test_multi_frame_height_scan(device):
"""height_scan works correctly with multi-frame sensors."""
cfg = RayCastSensorCfg(
name="multi",
frame=(
ObjRef(type="site", name="site_top", entity="robot"),
ObjRef(type="site", name="site_mid", entity="robot"),
ObjRef(type="site", name="site_bot", entity="robot"),
),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, MULTI_SITE_XML, (cfg,), num_envs=2)
sim.step()
sim.sense()
env = _FakeEnv(scene)
heights = height_scan(env, "multi") # type: ignore[invalid-argument-type]
assert heights.shape == (2, 3)
# Each frame's height = frame_z - hit_z.
# site_top at z=3.5, site_mid at z=3, site_bot at z=2.5; floor at z=0.
assert heights[0, 0].item() == pytest.approx(3.5, abs=0.1)
assert heights[0, 1].item() == pytest.approx(3.0, abs=0.1)
assert heights[0, 2].item() == pytest.approx(2.5, abs=0.1)
# ============================================================================
# Ring Pattern Tests
# ============================================================================
def test_ring_pattern_num_rays(device):
"""Ring with 8 samples + center = 9 rays."""
cfg = RayCastSensorCfg(
name="ring",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=RingPatternCfg.single_ring(radius=0.1, num_samples=8, include_center=True),
)
simple_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1"/>
<body name="base" pos="0 0 1">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1"/>
</body>
</worldbody>
</mujoco>
"""
scene, sim = make_scene_and_sim(device, simple_xml, (cfg,))
sensor = scene["ring"]
assert sensor.num_rays == 9
assert sensor.num_rays_per_frame == 9
def test_ring_pattern_concentric(device):
"""Concentric rings: 1 center + 4 + 6 + 8 = 19 rays."""
cfg = RayCastSensorCfg(
name="ring",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=RingPatternCfg(
rings=(
RingPatternCfg.Ring(radius=0.05, num_samples=4),
RingPatternCfg.Ring(radius=0.10, num_samples=6),
RingPatternCfg.Ring(radius=0.20, num_samples=8),
),
include_center=True,
),
)
simple_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1"/>
<body name="base" pos="0 0 1">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1"/>
</body>
</worldbody>
</mujoco>
"""
scene, sim = make_scene_and_sim(device, simple_xml, (cfg,))
sensor = scene["ring"]
assert sensor.num_rays == 19
def test_ring_pattern_hits_floor(device):
"""Ring pattern hits floor and ring offsets lie on a circle."""
radius = 0.1
cfg = RayCastSensorCfg(
name="ring",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=RingPatternCfg.single_ring(
radius=radius, num_samples=4, include_center=True
),
max_distance=10.0,
)
simple_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1"/>
<body name="base" pos="0 0 2">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.05"/>
</body>
</worldbody>
</mujoco>
"""
scene, sim = make_scene_and_sim(device, simple_xml, (cfg,))
sim.step()
sim.sense()
data = scene["ring"].data
# All 5 rays (1 center + 4 ring) should hit the floor.
assert torch.all(data.distances >= 0)
assert torch.allclose(data.distances, torch.full_like(data.distances, 2.0), atol=0.1)
# Verify ring geometry: non-center hits should lie on a circle of
# the given radius around the body's XY position.
hit_xy = data.hit_pos_w[0, :, :2] # [5, 2]
body_xy = data.pos_w[0, :2] # [2]
dists_from_center = (hit_xy - body_xy).norm(dim=1)
# Center ray (index 0) should be at ~0 offset.
assert dists_from_center[0].item() == pytest.approx(0.0, abs=0.02)
# Ring rays (indices 1-4) should be at ~radius offset.
for i in range(1, 5):
assert dists_from_center[i].item() == pytest.approx(radius, abs=0.02)
def test_ring_pattern_no_center(device):
"""Ring with include_center=False omits the center ray."""
cfg = RayCastSensorCfg(
name="ring",
frame=ObjRef(type="body", name="base", entity="robot"),
pattern=RingPatternCfg.single_ring(radius=0.1, num_samples=4, include_center=False),
)
simple_xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="10 10 0.1"/>
<body name="base" pos="0 0 1">
<freejoint name="free_joint"/>
<geom name="base_geom" type="sphere" size="0.1"/>
</body>
</worldbody>
</mujoco>
"""
scene, sim = make_scene_and_sim(device, simple_xml, (cfg,))
assert scene["ring"].num_rays == 4
def test_multi_frame_multiple_rays_per_frame(device):
"""Multi-frame with grid pattern: verifies reshape ordering."""
cfg = RayCastSensorCfg(
name="multi",
frame=(
ObjRef(type="site", name="site_top", entity="robot"),
ObjRef(type="site", name="site_bot", entity="robot"),
),
pattern=GridPatternCfg(size=(0.2, 0.2), resolution=0.1),
max_distance=10.0,
)
scene, sim = make_scene_and_sim(device, MULTI_SITE_XML, (cfg,))
sim.step()
sim.sense()
sensor = scene["multi"]
N = sensor.num_rays_per_frame
assert N == 9 # 3x3 grid
assert sensor.num_rays == 18 # 2 frames * 9
data = sensor.data
# First frame (site_top, z=3.5) rays should all be ~3.5.
top_dists = data.distances[0, :N]
assert torch.allclose(top_dists, torch.full_like(top_dists, 3.5), atol=0.1)
# Second frame (site_bot, z=2.5) rays should all be ~2.5.
bot_dists = data.distances[0, N:]
assert torch.allclose(bot_dists, torch.full_like(bot_dists, 2.5), atol=0.1)
# height_scan should also produce correct per-frame heights.
env = _FakeEnv(scene)
heights = height_scan(env, "multi") # type: ignore[invalid-argument-type]
assert heights.shape == (1, 18)
top_heights = heights[0, :N]
bot_heights = heights[0, N:]
assert torch.allclose(top_heights, torch.full_like(top_heights, 3.5), atol=0.1)
assert torch.allclose(bot_heights, torch.full_like(bot_heights, 2.5), atol=0.1)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Likely bug on CPU MjWarp")
def test_site_origin_is_physical_with_world_alignment(device):
"""ray_alignment only controls direction, not origin position.
When a calf body pitches, the foot site swings to its physical
position (site_xpos). Rays still point -Z (world-aligned), giving
the correct distance from the actual foot to the ground.
"""
xml = """
<mujoco>
<worldbody>
<geom name="floor" type="plane" size="50 50 0.1" pos="0 0 0"/>
<body name="calf" pos="0 0 1">
<freejoint name="free_joint"/>
<geom name="calf_geom" type="sphere" size="0.05" mass="1.0"/>
<site name="foot" pos="0 0 -0.5"/>
</body>
</worldbody>
</mujoco>
"""
cfg = RayCastSensorCfg(
name="scan",
frame=ObjRef(type="site", name="foot", entity="robot"),
pattern=GridPatternCfg(size=(0.0, 0.0), resolution=0.1),
ray_alignment="world",
max_distance=5.0,
)
sim_cfg = SimulationCfg(mujoco=MujocoCfg(gravity=(0, 0, 0)), njmax=20)
scene, sim = make_scene_and_sim(device, xml, (cfg,), sim_cfg=sim_cfg)
# Baseline: body upright, site at z=0.5.
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data = scene["scan"].data
assert data.pos_w[0, 2].item() == pytest.approx(0.5, abs=0.05)
assert data.distances[0, 0].item() == pytest.approx(0.5, abs=0.05)
# Pitch calf 90 degrees. Site swings to (0, -0.5, 1.0).
# Ray points -Z (world), distance to floor = 1.0.
angle = math.pi / 2
quat = [math.cos(angle / 2), math.sin(angle / 2), 0, 0]
sim.data.qpos[0, 3:7] = torch.tensor(quat, device=device)
sim.step()
scene.update(dt=sim.cfg.mujoco.timestep)
sim.sense()
data = scene["scan"].data
assert data.pos_w[0, 2].item() == pytest.approx(1.0, abs=0.1)
assert data.distances[0, 0].item() == pytest.approx(1.0, abs=0.1)
@pytest.mark.parametrize("groups", [(6,), (-1,), (0, 7)])
def test_include_geom_groups_out_of_range_raises(groups):
"""Out-of-range geom groups should raise rather than silently exclude."""
with pytest.raises(ValueError, match="include_geom_groups must be in"):
_geom_groups_to_vec6(groups)