"""Tests for contact_sensor.py.""" from __future__ import annotations import mujoco import pytest import torch from conftest import get_test_device, load_fixture_xml from mjlab.entity import EntityCfg from mjlab.scene import Scene, SceneCfg from mjlab.sensor.contact_sensor import ContactMatch, ContactSensorCfg from mjlab.sim.sim import Simulation, SimulationCfg ## # Test XML models. ## FALLING_BOX_XML = """ """ BIPED_XML = load_fixture_xml("biped") SIMPLE_ROBOT_XML = """ """ ## # Fixtures. ## @pytest.fixture(scope="module") def device(): """Test device fixture.""" return get_test_device() ## # Helper functions. ## def create_scene_with_sensor( xml: str, entity_name: str, sensor_cfg: ContactSensorCfg, device: str, num_envs: int = 2, njmax: int = 75, ) -> tuple[Scene, Simulation]: """Helper to create a complete test environment with contact sensor. Sets up a scene with the specified entity and contact sensor configuration, compiles the model, creates a simulation, and initializes everything together. Returns the scene and simulation objects for test manipulation and assertions.""" entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(xml)) scene_cfg = SceneCfg( num_envs=num_envs, env_spacing=3.0, entities={entity_name: entity_cfg}, sensors=(sensor_cfg,), ) scene = Scene(scene_cfg, device) model = scene.compile() sim_cfg = SimulationCfg(njmax=njmax) sim = Simulation(num_envs=num_envs, cfg=sim_cfg, model=model, device=device) scene.initialize(sim.mj_model, sim.model, sim.data) return scene, sim def step_and_settle(sim: Simulation, num_steps: int = 30): """Run simulation steps to allow physics to stabilize and contacts to form. Useful after placing objects to let them fall under gravity and establish stable contact with ground or other objects before testing contact detection.""" for _ in range(num_steps): sim.step() ## # Basic contact detection tests. ## def test_basic_contact_detection(device): """Verify that contact sensors detect collisions between a falling box and ground. Tests that when a box is placed just above ground and simulation steps, the contact sensor correctly reports contact forces and found flags.""" contact_sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("found", "force"), ) scene, sim = create_scene_with_sensor( FALLING_BOX_XML, "box", contact_sensor_cfg, device ) sensor = scene["box_contact"] box_entity = scene["box"] # Place box on ground and let it settle. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.11 # Just above ground root_state[:, 3] = 1.0 box_entity.write_root_state_to_sim(root_state) step_and_settle(sim) data = sensor.data # Basic field presence and shape checks. assert data.found is not None assert data.force is not None assert data.found.shape == (2, 1) # 2 envs, 1 slot assert data.force.shape[-1] == 3 # Contact should be detected. assert torch.any(data.found > 0) # Force should be non-zero when contact is detected. if torch.any(data.found > 0): contact_forces = data.force[data.found > 0] assert torch.any(torch.abs(contact_forces) > 0) def test_contact_fields(device): """Verify all contact sensor output fields have correct shapes and values. Tests that force, torque, dist, pos, and normal fields are properly populated with appropriate dimensionality (3D vectors for force/torque/pos/normal, scalar for dist).""" contact_sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("found", "force", "torque", "dist", "pos", "normal"), ) scene, sim = create_scene_with_sensor( FALLING_BOX_XML, "box", contact_sensor_cfg, device ) sensor = scene["box_contact"] box_entity = scene["box"] root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.105 root_state[:, 3] = 1.0 box_entity.write_root_state_to_sim(root_state) step_and_settle(sim, num_steps=10) data = sensor.data # Verify all fields are present with correct shapes. assert data.found is not None assert data.force is not None assert data.torque is not None assert data.dist is not None assert data.pos is not None assert data.normal is not None assert data.force.shape[-1] == 3 assert data.torque.shape[-1] == 3 assert data.pos.shape[-1] == 3 assert data.normal.shape[-1] == 3 assert len(data.dist.shape) == 2 ## # Pattern matching and multi-slot tests. ## def test_multi_slot_pattern_matching(device): """Verify pattern lists create separate tracking slots for each matched geom. When passing a list of patterns like ["left_foot_geom", "right_foot_geom"], the sensor should create independent contact tracking for each foot, allowing simultaneous monitoring of multiple contact points.""" feet_sensor_cfg = ContactSensorCfg( name="feet_contact", primary=ContactMatch( mode="geom", pattern=("left_foot_geom", "right_foot_geom"), entity="biped", ), secondary=None, fields=("found", "force"), track_air_time=True, ) scene, sim = create_scene_with_sensor(BIPED_XML, "biped", feet_sensor_cfg, device) sensor = scene["feet_contact"] biped_entity = scene["biped"] # Place biped on ground. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.25 root_state[:, 3] = 1.0 biped_entity.write_root_state_to_sim(root_state) step_and_settle(sim, num_steps=20) data = sensor.data # Should have 2 slots (one per foot). assert data.found.shape == (2, 2) assert data.force.shape == (2, 2, 3) # Air time should be tracked. assert hasattr(data, "current_air_time") assert data.current_air_time.shape == (2, 2) def test_regex_pattern_matching(device): """Verify regex patterns correctly match multiple geoms with similar names. Tests that a pattern like ".*foot_geom$" matches all geoms ending with "foot_geom", enabling efficient batch configuration of similar contact points. Also verifies regex patterns work correctly for actual contact detection.""" # Match all foot geoms using regex. regex_sensor_cfg = ContactSensorCfg( name="all_feet_contact", primary=ContactMatch( mode="geom", pattern=r".*foot_geom$", entity="biped", ), secondary=None, fields=("found", "force"), ) scene, sim = create_scene_with_sensor(BIPED_XML, "biped", regex_sensor_cfg, device) sensor = scene["all_feet_contact"] biped_entity = scene["biped"] # Should match both left_foot_geom and right_foot_geom. assert sensor.data.found.shape == (2, 2) # Place biped on ground to verify regex-matched geoms detect contacts. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.24 # Low enough for feet to touch ground root_state[:, 3] = 1.0 biped_entity.write_root_state_to_sim(root_state) # Run simulation and update scene to invalidate cache. for _ in range(20): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data = sensor.data # Both feet should detect ground contact. assert torch.any(data.found > 0) # Force field should be present (may have small values). assert data.force is not None assert data.force.shape == (2, 2, 3) ## # Reduction mode tests. ## @pytest.mark.parametrize( "reduce_mode", ["none", "mindist", "maxforce", "netforce"], ) def test_reduce_modes(device, reduce_mode): """Verify reduction modes correctly aggregate multiple simultaneous contacts. Tests "none" (no filtering), "mindist" (closest contact), "maxforce" (strongest), and "netforce" (sum all forces) modes for selecting/combining contact data when multiple contacts occur on the same geom.""" sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("force",), reduce=reduce_mode, num_slots=1, ) scene, _ = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] data = sensor.data # All reduction modes return 3D shape for force field. assert len(data.force.shape) == 3 assert data.force.shape[-1] == 3 # Force is always a 3D vector def test_reduce_modes_multiple_contacts(device): """Test reduction modes with multiple simultaneous contacts.""" feet_sensor_cfg = ContactSensorCfg( name="feet_contact", primary=ContactMatch( mode="geom", pattern=("left_foot_geom", "right_foot_geom"), entity="biped", ), secondary=None, fields=("found", "force", "dist"), reduce="mindist", num_slots=1, ) scene, sim = create_scene_with_sensor(BIPED_XML, "biped", feet_sensor_cfg, device) sensor = scene["feet_contact"] biped_entity = scene["biped"] # Place biped on ground. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.25 root_state[:, 3] = 1.0 biped_entity.write_root_state_to_sim(root_state) step_and_settle(sim, num_steps=20) data = sensor.data # With reduce="mindist" and num_slots=1, should have 2 slots (2 primaries × 1 slot). assert data.found.shape == (2, 2) assert data.force.shape == (2, 2, 3) ## # Exclude pattern tests. ## def test_exclude_exact_names(device): """Verify exact name exclusion removes specific geoms from contact detection. Tests the ergonomic feature where passing exact geom names like ("leg1_foot_collision", "leg2_foot_collision") excludes only those specific geoms without needing complex regex patterns.""" # Sensor that excludes foot collisions by exact names. nonfoot_sensor_cfg = ContactSensorCfg( name="nonfoot_contact", primary=ContactMatch( mode="geom", pattern=r".*_collision\d*$", # Match all collision geoms entity="robot", exclude=("leg1_foot_collision", "leg2_foot_collision"), # Exact names ), secondary=None, fields=("found",), ) scene, _ = create_scene_with_sensor( SIMPLE_ROBOT_XML, "robot", nonfoot_sensor_cfg, device ) sensor = scene["nonfoot_contact"] # Should detect 6 geoms: trunk, head, 2x leg1_thigh, 2x leg2_thigh. # Foot collisions should be excluded. assert sensor.data.found.shape == (2, 6) def test_exclude_regex_pattern(device): """Verify regex exclusion patterns filter out groups of similarly-named geoms. Tests that patterns like ".*thigh_collision\\d+" can exclude all thigh collision geoms while preserving other collision geoms for contact detection.""" # Sensor that excludes all thigh collisions using regex. no_thigh_sensor_cfg = ContactSensorCfg( name="no_thigh_contact", primary=ContactMatch( mode="geom", pattern=r".*_collision\d*$", entity="robot", exclude=(r".*thigh_collision\d+",), # Regex pattern ), secondary=None, fields=("found",), ) scene, _ = create_scene_with_sensor( SIMPLE_ROBOT_XML, "robot", no_thigh_sensor_cfg, device ) sensor = scene["no_thigh_contact"] # Should detect 4 geoms: trunk, head, 2x foot (thighs excluded by regex). assert sensor.data.found.shape == (2, 4) def test_exclude_mixed_patterns(device): """Verify exact names and regex patterns can be mixed in exclude lists. Tests that exclude tuples can contain both exact names ("trunk_collision") and regex patterns (".*foot_collision") simultaneously, with automatic detection of which exclusion method to use for each entry.""" mixed_exclude_cfg = ContactSensorCfg( name="mixed_exclude", primary=ContactMatch( mode="geom", pattern=r".*_collision\d*$", entity="robot", exclude=( "trunk_collision", # Exact name r".*foot_collision", # Regex pattern ), ), secondary=None, fields=("found",), ) scene, _ = create_scene_with_sensor( SIMPLE_ROBOT_XML, "robot", mixed_exclude_cfg, device ) sensor = scene["mixed_exclude"] # Should detect 5 geoms: head, 4x thigh (trunk and feet excluded). assert sensor.data.found.shape == (2, 5) ## # Body and subtree mode tests. ## def test_body_mode_contacts(device): """Test contact detection with body mode.""" body_sensor_cfg = ContactSensorCfg( name="body_contact", primary=ContactMatch(mode="body", pattern="base", entity="biped"), secondary=None, fields=("found",), ) scene, _ = create_scene_with_sensor(BIPED_XML, "biped", body_sensor_cfg, device) sensor = scene["body_contact"] data = sensor.data # Should match the base body. assert data.found.shape[1] == 1 def test_subtree_mode_contacts(device): """Test contact detection with subtree mode.""" subtree_sensor_cfg = ContactSensorCfg( name="subtree_contact", primary=ContactMatch(mode="subtree", pattern="base", entity="biped"), secondary=None, fields=("found",), ) scene, sim = create_scene_with_sensor(BIPED_XML, "biped", subtree_sensor_cfg, device) sensor = scene["subtree_contact"] biped_entity = scene["biped"] # Place biped on ground. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.2 # Low enough for feet to touch root_state[:, 3] = 1.0 biped_entity.write_root_state_to_sim(root_state) step_and_settle(sim, num_steps=30) data = sensor.data # Subtree includes base and all children (feet), so contacts should be detected. assert torch.any(data.found > 0) ## # Air time tracking tests. ## def test_air_time_tracking(device): """Verify contact sensors track time spent in/out of contact when enabled. Tests the track_air_time feature which monitors how long each contact point has been in the air (no contact) or on ground (in contact), useful for gait analysis and landing detection in legged robots. """ feet_sensor_cfg = ContactSensorCfg( name="feet_contact", primary=ContactMatch( mode="geom", pattern=("left_foot_geom", "right_foot_geom"), entity="biped", ), secondary=None, fields=("found",), track_air_time=True, ) scene, sim = create_scene_with_sensor(BIPED_XML, "biped", feet_sensor_cfg, device) sensor = scene["feet_contact"] biped_entity = scene["biped"] # Start on ground. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.24 # Low enough for contact root_state[:, 3] = 1.0 biped_entity.write_root_state_to_sim(root_state) # Let it settle and establish ground contact. for _ in range(30): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data1 = sensor.data # Check that we have ground contact initially. assert torch.any(data1.found > 0) # Jump up (lift biped off ground). root_state[:, 2] = 1.0 biped_entity.write_root_state_to_sim(root_state) # Simulate being in air. for _ in range(20): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data2 = sensor.data # Should have no ground contact while in air. assert torch.all(data2.found == 0) # When using track_air_time, we should have timing information. assert hasattr(data2, "current_air_time") assert hasattr(data2, "last_air_time") # Land back on ground. root_state[:, 2] = 0.24 biped_entity.write_root_state_to_sim(root_state) for _ in range(30): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data3 = sensor.data # Should have ground contact again. assert torch.any(data3.found > 0) def test_air_time_exact_at_large_sim_clock(device): """Air-time accumulation is independent of the sim-clock magnitude. Regression for issue #1101: `_update_air_time_tracking` used to accumulate differences of the float32 sim clock (`data.time`), so the tracked values inherited the clock's quantization error (ULP ~= time * 1.2e-7). That error grows without bound as `data.time` grows (it is never reset on env reset) and eventually exceeds the abs_tol in compute_first_contact, silently missing first-substep touchdowns. The fix accumulates the exact float64 substep dt instead, so `data.time` is never read. We advance the clock to a large value and confirm the first contact substep still reads exactly dt and first-contact fires. Against the old code this asserts hard: the tracked time picks up the clock magnitude. """ cfg = ContactSensorCfg( name="feet_contact", primary=ContactMatch( mode="geom", pattern=("left_foot_geom", "right_foot_geom"), entity="biped", ), secondary=None, fields=("found",), track_air_time=True, ) scene, sim = create_scene_with_sensor(BIPED_XML, "biped", cfg, device) sensor = scene["feet_contact"] dt = sim.cfg.mujoco.timestep ground_state = torch.zeros((2, 13), device=sim.device) ground_state[:, 2] = 0.25 ground_state[:, 3] = 1.0 air_state = ground_state.clone() air_state[:, 2] = 1.0 # Settle the biped onto the ground so both feet are in stable contact, then # lift it clear so the feet are airborne (current_air_time > 0, found == 0). scene["biped"].write_root_state_to_sim(ground_state) for _ in range(20): sim.step() scene.update(dt=dt) scene["biped"].write_root_state_to_sim(air_state) for _ in range(15): sim.step() scene.update(dt=dt) assert torch.all(sensor.data.found == 0), "expected feet airborne before landing" # Advance the sim clock far past the regime where float32 quantization exceeds # the default abs_tol. The old code differenced this clock, so the first # contact substep would inherit its magnitude; the fix ignores `data.time`. sim.data.time[:] = 20_000.0 # Land: a single update in contact should read exactly one dt of contact time. scene["biped"].write_root_state_to_sim(ground_state) sim.step() scene.update(dt=dt) data = sensor.data assert data.current_contact_time is not None feet_landed = data.found.view(2, len(sensor.primary_names), -1).any(dim=-1) assert torch.any(feet_landed), "expected at least one foot to land" # The first contact substep reads exactly dt, independent of the huge clock. assert torch.all(torch.abs(data.current_contact_time[feet_landed] - dt) < 1e-6), ( data.current_contact_time ) # And first-contact detection at dt=step_dt fires for the feet that landed. first_contact = sensor.compute_first_contact(dt=dt) assert torch.all(first_contact[feet_landed]) ## # Multi-sensor integration tests. ## def test_multiple_sensors(device): """Test multiple contact sensors in the same scene.""" left_sensor_cfg = ContactSensorCfg( name="left_foot_contact", primary=ContactMatch(mode="geom", pattern="left_foot_geom", entity="biped"), secondary=None, fields=("found", "force"), ) right_sensor_cfg = ContactSensorCfg( name="right_foot_contact", primary=ContactMatch(mode="geom", pattern="right_foot_geom", entity="biped"), secondary=None, fields=("found", "force"), ) entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(BIPED_XML)) scene_cfg = SceneCfg( num_envs=2, env_spacing=3.0, entities={"biped": entity_cfg}, sensors=(left_sensor_cfg, right_sensor_cfg), ) scene = Scene(scene_cfg, device) model = scene.compile() sim_cfg = SimulationCfg(njmax=40) sim = Simulation(num_envs=2, cfg=sim_cfg, model=model, device=device) scene.initialize(sim.mj_model, sim.model, sim.data) left_sensor = scene["left_foot_contact"] right_sensor = scene["right_foot_contact"] # Both sensors should work independently. assert left_sensor.data.found.shape == (2, 1) assert right_sensor.data.found.shape == (2, 1) ## # Performance and edge case tests. ## def test_no_contacts(device): """Test sensor behavior when no contacts occur.""" sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("found", "force"), ) scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] box_entity = scene["box"] # Place box high above ground (no contact). root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 5.0 # Far above ground root_state[:, 3] = 1.0 box_entity.write_root_state_to_sim(root_state) sim.step() data = sensor.data # No contacts should be detected. assert torch.all(data.found == 0) # Forces should be zero. assert torch.all(data.force == 0) def test_num_slots_greater_than_one(device): """Test behavior with num_slots > 1.""" sensor_cfg_1 = ContactSensorCfg( name="feet_contact_single", primary=ContactMatch( mode="geom", pattern=("left_foot_geom", "right_foot_geom"), entity="biped", ), secondary=None, fields=("found", "force", "normal"), num_slots=1, ) sensor_cfg_3 = ContactSensorCfg( name="feet_contact_triple", primary=ContactMatch( mode="geom", pattern=("left_foot_geom", "right_foot_geom"), entity="biped", ), secondary=None, fields=("found", "force", "normal"), num_slots=3, ) entity_cfg = EntityCfg(spec_fn=lambda: mujoco.MjSpec.from_string(BIPED_XML)) scene_cfg = SceneCfg( num_envs=2, env_spacing=3.0, entities={"biped": entity_cfg}, sensors=(sensor_cfg_1, sensor_cfg_3), ) scene = Scene(scene_cfg, device) model = scene.compile() sim_cfg = SimulationCfg(njmax=40) sim = Simulation(num_envs=2, cfg=sim_cfg, model=model, device=device) scene.initialize(sim.mj_model, sim.model, sim.data) sensor_1 = scene["feet_contact_single"] sensor_3 = scene["feet_contact_triple"] biped_entity = scene["biped"] # Place biped on ground. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.25 root_state[:, 3] = 1.0 biped_entity.write_root_state_to_sim(root_state) step_and_settle(sim, num_steps=20) # 2 primaries × 1 slot = 2 total slots. data_1 = sensor_1.data assert data_1.found is not None assert data_1.force is not None assert data_1.normal is not None assert data_1.found.shape == (2, 2) assert data_1.force.shape == (2, 2, 3) assert data_1.normal.shape == (2, 2, 3) # 2 primaries × 3 slots = 6 total slots. data_3 = sensor_3.data assert data_3.found is not None assert data_3.force is not None assert data_3.normal is not None assert data_3.found.shape == (2, 6) assert data_3.force.shape == (2, 6, 3) assert data_3.normal.shape == (2, 6, 3) def test_multi_slot_air_time_and_primary_names(device): """Multi-slot air-time stays per-primary, primaries are exposed by name. Regression for issue #914: previously `num_slots > 1` with `track_air_time` crashed in `_update_air_time_tracking` because air-time state was [B, P] while `found` was [B, P * num_slots]. The fix reduces `found` across slots. This test pins both the regression and the new `primary_names` API. """ cfg = ContactSensorCfg( name="feet_contact", primary=ContactMatch( mode="geom", pattern=("left_foot_geom", "right_foot_geom"), entity="biped", ), secondary=None, fields=("found",), num_slots=3, track_air_time=True, ) scene, sim = create_scene_with_sensor(BIPED_XML, "biped", cfg, device) sensor = scene["feet_contact"] # `primary_names` reflects pattern order and indexes the per-primary axis. assert sensor.primary_names == ["left_foot_geom", "right_foot_geom"] # Settle the biped on the ground so feet are in stable contact. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.25 root_state[:, 3] = 1.0 scene["biped"].write_root_state_to_sim(root_state) for _ in range(20): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data = sensor.data assert data.found is not None assert data.current_contact_time is not None # Per-contact axis is P * num_slots; per-primary axis is P. assert data.found.shape == (2, 2 * 3) assert data.current_contact_time.shape == (2, len(sensor.primary_names)) # Air-time update actually ran and accumulated time for primaries in # contact (not just "didn't crash"). Each foot reports a contact in some # slot, so its per-primary contact time should grow above zero. any_contact_per_primary = (data.found > 0).view(2, 2, 3).any(dim=-1) assert torch.all(any_contact_per_primary), "expected both feet in contact" assert torch.all(data.current_contact_time > 0) ## # History tests. ## def test_history_shape(device): """Verify history tensors have correct shape [B, N, H, 3].""" history_len = 5 sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("found", "force", "torque", "dist"), history_length=history_len, ) scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] box_entity = scene["box"] # Place box on ground. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.11 root_state[:, 3] = 1.0 box_entity.write_root_state_to_sim(root_state) # Step a few times to populate history. for _ in range(10): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data = sensor.data # Verify history shapes: [B, N, H, ...]. assert data.force_history is not None assert data.torque_history is not None assert data.dist_history is not None assert data.force_history.shape == (2, 1, history_len, 3) assert data.torque_history.shape == (2, 1, history_len, 3) assert data.dist_history.shape == (2, 1, history_len) def test_history_ordering(device): """Verify index 0 is most recent data in history buffer.""" history_len = 3 sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("force",), history_length=history_len, ) scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] box_entity = scene["box"] # Place box on ground to get contact. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.11 root_state[:, 3] = 1.0 box_entity.write_root_state_to_sim(root_state) # Step and capture history at each step. forces_over_time = [] for _ in range(5): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) # Clone to avoid tensor aliasing. forces_over_time.append(sensor.data.force.clone()) data = sensor.data # Index 0 should be most recent (last force we captured). assert data.force_history is not None torch.testing.assert_close(data.force_history[:, :, 0, :], forces_over_time[-1]) # Index 1 should be second most recent. torch.testing.assert_close(data.force_history[:, :, 1, :], forces_over_time[-2]) # Index 2 should be third most recent. torch.testing.assert_close(data.force_history[:, :, 2, :], forces_over_time[-3]) def test_history_reset(device): """Verify reset clears history for specified environments.""" history_len = 5 sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("force",), history_length=history_len, ) scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] box_entity = scene["box"] # Drop box from height to ensure impact forces. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.5 # Drop from 0.5m root_state[:, 3] = 1.0 box_entity.write_root_state_to_sim(root_state) # Let box fall and impact ground. for _ in range(50): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data_before = sensor.data assert data_before.force_history is not None # Manually set history to known non-zero values to test reset behavior. sensor._history_state["force"][:] = 1.0 # Reset only env 0. sensor.reset(torch.tensor([0], device=device)) data_after = sensor.data # Env 0 history should be zeroed. assert torch.all(data_after.force_history[0] == 0) # Env 1 history should still have our test value. assert torch.all(data_after.force_history[1] == 1.0) def test_history_disabled_by_default(device): """Verify history is None when history_length=0 (default).""" sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("force",), # history_length defaults to 0 ) scene, _ = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] data = sensor.data # History should be None when disabled. assert data.force_history is None assert data.torque_history is None assert data.dist_history is None def test_history_captures_physically_correct_forces(device): """Verify history captures forces that match expected physics (F = mg). This test validates that the history buffer stores actual physics values, not just that the buffer mechanics work correctly. A 1kg box at rest on ground should experience a net contact force of approximately 9.81 N. """ history_len = 10 sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("force",), history_length=history_len, reduce="netforce", # Sum all contact forces (already in global frame). ) scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] box_entity = scene["box"] # Place box just above ground and let it settle. root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.11 # Just above ground (box half-height is 0.1). root_state[:, 3] = 1.0 # Unit quaternion. box_entity.write_root_state_to_sim(root_state) # Let the box settle to steady state. for _ in range(100): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data = sensor.data # Box mass is 1.0 kg, gravity is ~9.81 m/s². # Expected normal force magnitude ≈ 9.81 N in z direction. expected_force_magnitude = 9.81 tolerance = 1.0 # Allow 1 N tolerance for numerical settling. # Check that the most recent force in history matches expected physics. assert data.force_history is not None force_z = data.force_history[ :, :, 0, 2 ] # [B, N, H, 3] -> z-component of most recent. # Force magnitude should match mg (sign depends on contact frame convention). assert torch.allclose( force_z.abs(), torch.full_like(force_z, expected_force_magnitude), atol=tolerance ), f"Expected |force_z| ~{expected_force_magnitude} N, got {force_z}" # Verify forces are consistent across recent history (steady state). # In steady state, all history entries should have similar force magnitudes. force_magnitudes = torch.norm(data.force_history, dim=-1) # [B, N, H] mean_force = force_magnitudes.mean(dim=2, keepdim=True) max_deviation = (force_magnitudes - mean_force).abs().max() assert max_deviation < 1.0, f"Forces should be steady, max deviation: {max_deviation}" def test_history_captures_impact_forces(device): """Verify history captures transient impact forces during a drop. This is the primary use case for the history feature: catching peak forces that occur during impact but might be missed if only sampling at policy rate. When a box drops and impacts the ground, the peak force should exceed the steady-state force (mg) due to the impulse from deceleration. """ history_len = 20 # Capture enough substeps to see the impact transient. sensor_cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), secondary=None, fields=("force",), history_length=history_len, reduce="netforce", # Sum all contact forces. ) scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", sensor_cfg, device) sensor = scene["box_contact"] box_entity = scene["box"] # Drop box from a height to create impact. drop_height = 0.5 # 0.5m above ground (box half-height is 0.1). root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = drop_height root_state[:, 3] = 1.0 # Unit quaternion. box_entity.write_root_state_to_sim(root_state) # Step until we detect contact and capture the impact. max_force_seen = torch.zeros(2, device=sim.device) contact_detected = False for _ in range(200): # Enough steps for box to fall and settle. sim.step() scene.update(dt=sim.cfg.mujoco.timestep) data = sensor.data if data.force_history is not None: # Track the maximum force magnitude seen in history. force_magnitudes = torch.norm(data.force_history, dim=-1) # [B, N, H] current_max = force_magnitudes.max(dim=-1).values.squeeze(-1) # [B] max_force_seen = torch.maximum(max_force_seen, current_max) # Check if we have contact. if torch.any(force_magnitudes > 0): contact_detected = True assert contact_detected, "Box should have made contact with ground" # Steady state force is mg ≈ 9.81 N for 1 kg box. steady_state_force = 9.81 # Peak impact force should exceed steady state due to impulse. # For a drop from 0.5m, v = sqrt(2gh) ≈ 3.1 m/s at impact. # The peak force depends on contact stiffness, but should be > mg. assert torch.all(max_force_seen > steady_state_force), ( f"Peak impact force ({max_force_seen}) should exceed steady state ({steady_state_force})" ) # Verify the peak force was significantly above steady state, demonstrating # that the history captured the transient impact spike. assert torch.all(max_force_seen > steady_state_force * 1.5), ( f"Peak force {max_force_seen} should be significantly above mg={steady_state_force}" ) def test_global_frame_maxforce_rotation(device): """A box at rest on a plane has its contact normals all vertical.""" cfg = ContactSensorCfg( name="box_contact", primary=ContactMatch(mode="geom", pattern="box_geom", entity="box"), fields=("found", "force", "normal", "tangent"), reduce="maxforce", global_frame=True, ) scene, sim = create_scene_with_sensor(FALLING_BOX_XML, "box", cfg, device) root_state = torch.zeros((2, 13), device=sim.device) root_state[:, 2] = 0.11 root_state[:, 3] = 1.0 scene["box"].write_root_state_to_sim(root_state) for _ in range(150): sim.step() scene.update(dt=sim.cfg.mujoco.timestep) sensor_force = scene["box_contact"].data.force[:, 0, :] # On a flat plane the contact normal is vertical, so a correctly rotated # global-frame force should have its magnitude entirely on the z axis. assert torch.all(sensor_force[:, 0].abs() < 0.05), ( f"sensor_force x-component should be ~0, got {sensor_force[:, 0].tolist()}" ) assert torch.all(sensor_force[:, 1].abs() < 0.05), ( f"sensor_force y-component should be ~0, got {sensor_force[:, 1].tolist()}" ) assert torch.all(sensor_force[:, 2].abs() > 1.0), ( f"sensor_force z-component should be non-trivial, got {sensor_force[:, 2].tolist()}" )