"""Tests for observation noise functionality.""" from unittest.mock import Mock import pytest import torch from conftest import get_test_device from mjlab.managers.observation_manager import ( ObservationGroupCfg, ObservationManager, ObservationTermCfg, ) from mjlab.utils.noise.noise_cfg import ( ConstantNoiseCfg, NoiseModelWithAdditiveBiasCfg, ) @pytest.fixture def device(): """Test device fixture.""" return get_test_device() @pytest.fixture def mock_env(device): """Create a mock environment.""" env = Mock() env.num_envs = 4 env.device = device env.step_dt = 0.02 return env def test_noise_applied_when_corruption_enabled(mock_env, device): """Test that noise is applied when enable_corruption=True.""" def obs_func(env): return torch.ones((env.num_envs, 3), device=device) cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_func, params={}, noise=ConstantNoiseCfg(bias=0.5, operation="add"), ), }, enable_corruption=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # Expect 1.0 + 0.5 = 1.5 expected = torch.full((4, 3), 1.5, device=device) assert torch.allclose(policy_obs, expected) def test_noise_not_applied_when_corruption_disabled(mock_env, device): """Test that noise is NOT applied when enable_corruption=False.""" def obs_func(env): return torch.ones((env.num_envs, 3), device=device) cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_func, params={}, noise=ConstantNoiseCfg(bias=0.5, operation="add"), ), }, enable_corruption=False, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # Noise should NOT be applied, expect original value of 1.0 expected = torch.full((4, 3), 1.0, device=device) assert torch.allclose(policy_obs, expected) def test_noise_add_operation(mock_env, device): """Test noise with 'add' operation.""" def obs_func(env): return torch.full((env.num_envs, 3), 2.0, device=device) cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_func, params={}, noise=ConstantNoiseCfg(bias=0.3, operation="add"), ), }, enable_corruption=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # Expect 2.0 + 0.3 = 2.3 expected = torch.full((4, 3), 2.3, device=device) assert torch.allclose(policy_obs, expected) def test_noise_scale_operation(mock_env, device): """Test noise with 'scale' operation.""" def obs_func(env): return torch.full((env.num_envs, 3), 2.0, device=device) cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_func, params={}, noise=ConstantNoiseCfg(bias=0.5, operation="scale"), ), }, enable_corruption=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # Expect 2.0 * 0.5 = 1.0 expected = torch.full((4, 3), 1.0, device=device) assert torch.allclose(policy_obs, expected) def test_noise_abs_operation(mock_env, device): """Test noise with 'abs' operation (replaces data with bias).""" def obs_func(env): return torch.full((env.num_envs, 3), 2.0, device=device) cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_func, params={}, noise=ConstantNoiseCfg(bias=0.7, operation="abs"), ), }, enable_corruption=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # Expect data to be replaced with bias = 0.7 expected = torch.full((4, 3), 0.7, device=device) assert torch.allclose(policy_obs, expected) def test_noise_with_per_dimension_bias(mock_env, device): """Test noise with per-dimension bias (tuple).""" def obs_func(env): return torch.ones((env.num_envs, 3), device=device) cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_func, params={}, noise=ConstantNoiseCfg(bias=(0.1, 0.2, 0.3), operation="add"), ), }, enable_corruption=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # Expect 1.0 + [0.1, 0.2, 0.3] = [1.1, 1.2, 1.3] expected = torch.tensor([[1.1, 1.2, 1.3]] * 4, device=device) assert torch.allclose(policy_obs, expected) def test_noise_tensor_caching(device): """Test that tensor conversion is cached across multiple calls.""" noise = ConstantNoiseCfg(bias=0.5) data = torch.ones((4, 3), device=device) # First call should create the tensor result1 = noise.apply(data) # Verify the cache was populated and get reference to cached tensor device_key = str(result1.device) assert device_key in noise._tensor_cache assert "bias" in noise._tensor_cache[device_key] cached_tensor = noise._tensor_cache[device_key]["bias"] cached_data_ptr = cached_tensor.data_ptr() # Second call should use the same cached tensor result2 = noise.apply(data) # Verify the cached tensor is the same object (same memory address) assert noise._tensor_cache[device_key]["bias"].data_ptr() == cached_data_ptr # Both results should be correct expected = torch.full((4, 3), 1.5, device=device) assert torch.allclose(result1, expected) assert torch.allclose(result2, expected) def test_shared_term_name_noise_models_are_per_group(mock_env, device): """Each group owns its own noise model instance when they share a term name. ConstantNoiseCfg(op="add") shifts the additive-bias tensor by the configured amount on every reset, so each group's observation drifts by its own value after each manager.reset(). """ def obs_func(env): return torch.zeros((env.num_envs, 3), device=device) def group(bias: float) -> ObservationGroupCfg: return ObservationGroupCfg( terms={ "obs": ObservationTermCfg( func=obs_func, params={}, noise=NoiseModelWithAdditiveBiasCfg( noise_cfg=ConstantNoiseCfg(bias=0.0, operation="add"), bias_noise_cfg=ConstantNoiseCfg(bias=bias, operation="add"), ), ), }, enable_corruption=True, ) manager = ObservationManager({"actor": group(10.0), "critic": group(-1.0)}, mock_env) obs = manager.compute() assert isinstance(obs["actor"], torch.Tensor) assert isinstance(obs["critic"], torch.Tensor) assert torch.allclose(obs["actor"], torch.full((4, 3), 10.0, device=device)) assert torch.allclose(obs["critic"], torch.full((4, 3), -1.0, device=device)) manager.reset() obs = manager.compute() assert isinstance(obs["actor"], torch.Tensor) assert isinstance(obs["critic"], torch.Tensor) assert torch.allclose(obs["actor"], torch.full((4, 3), 20.0, device=device)) assert torch.allclose(obs["critic"], torch.full((4, 3), -2.0, device=device)) def test_multiple_terms_with_different_noise(mock_env, device): """Test multiple observation terms with different noise configs.""" def obs_func_a(env): return torch.ones((env.num_envs, 2), device=device) def obs_func_b(env): return torch.full((env.num_envs, 2), 3.0, device=device) cfg = { "actor": ObservationGroupCfg( terms={ "obs_a": ObservationTermCfg( func=obs_func_a, params={}, noise=ConstantNoiseCfg(bias=0.1, operation="add"), ), "obs_b": ObservationTermCfg( func=obs_func_b, params={}, noise=ConstantNoiseCfg(bias=2.0, operation="scale"), ), }, enable_corruption=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # obs_a: 1.0 + 0.1 = 1.1 # obs_b: 3.0 * 2.0 = 6.0 # Concatenated: [1.1, 1.1, 6.0, 6.0] expected = torch.tensor([[1.1, 1.1, 6.0, 6.0]] * 4, device=device) assert torch.allclose(policy_obs, expected)