"""Tests for observation NaN handling 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, ) @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_nan_disabled_policy_no_check(mock_env, device): """Policy='disabled' should pass through NaNs unchanged.""" def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={})}, nan_policy="disabled", ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) assert torch.isnan(policy_obs[1, 1]) # NaN should pass through. def test_nan_sanitize_policy_silently_fixes(mock_env, device): """Policy='sanitize' should zero NaNs without warning.""" def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") obs[2, 0] = float("inf") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={})}, nan_policy="sanitize", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) assert not torch.isnan(policy_obs).any() assert not torch.isinf(policy_obs).any() assert policy_obs[1, 1] == 0.0 assert policy_obs[2, 0] == 0.0 def test_nan_warn_policy_logs_and_sanitizes(mock_env, device, capsys): """Policy='warn' should log warning and sanitize.""" def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={})}, nan_policy="warn", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() captured = capsys.readouterr() assert "ObservationManager" in captured.out assert "NaN/Inf" in captured.out assert "actor/obs1" in captured.out assert "Sanitizing" in captured.out policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) assert not torch.isnan(policy_obs).any() assert policy_obs[1, 1] == 0.0 def test_nan_error_policy_raises(mock_env, device): """Policy='error' should raise ValueError with context.""" def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={})}, nan_policy="error", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) with pytest.raises(ValueError) as excinfo: manager.compute() assert "NaN/Inf detected" in str(excinfo.value) assert "actor/obs1" in str(excinfo.value) assert "1" in str(excinfo.value) # Environment ID. def test_nan_check_per_term_identifies_source(mock_env, device, capsys): """Per-term checking should identify which term has NaN.""" def clean_obs(env): return torch.ones((env.num_envs, 3), device=device) def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={ "clean": ObservationTermCfg(func=clean_obs, params={}), "problematic": ObservationTermCfg(func=obs_with_nan, params={}), }, nan_policy="warn", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() # Check that only problematic term is logged. captured = capsys.readouterr() assert "actor/problematic" in captured.out assert "actor/clean" not in captured.out # Both terms should be sanitized in output. policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) assert not torch.isnan(policy_obs).any() def test_nan_check_final_only(mock_env, device, capsys): """Final-only checking should check concatenated result.""" def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={})}, nan_policy="warn", nan_check_per_term=False, # Check only final result. concatenate_terms=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() # Should log group name, not term name. captured = capsys.readouterr() assert "actor" in captured.out assert "actor/obs1" not in captured.out # Should not show term. policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) assert not torch.isnan(policy_obs).any() def test_nan_check_dict_output(mock_env, device): """NaN checking should work with non-concatenated dict output.""" def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={})}, nan_policy="sanitize", concatenate_terms=False, nan_check_per_term=False, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy: dict[str, torch.Tensor] = obs["actor"] # type: ignore[assignment] policy_obs = policy["obs1"] assert not torch.isnan(policy_obs).any() assert policy_obs[1, 1] == 0.0 def test_nan_before_delay_buffer(mock_env, device): """NaN should be sanitized before entering delay buffer.""" call_count = [0] def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) # Only inject NaN on first call. if call_count[0] == 0: obs[1, 1] = float("nan") call_count[0] += 1 return obs cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_with_nan, params={}, delay_min_lag=1, delay_max_lag=1, # 1-step delay. ) }, nan_policy="sanitize", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) # First compute: NaN should be sanitized before entering delay buffer. obs1 = manager.compute(update_history=True) # First output comes from buffer initialization (zeros). obs1_policy = obs1["actor"] assert isinstance(obs1_policy, torch.Tensor) assert not torch.isnan(obs1_policy).any() # Second compute: should get sanitized value from buffer. obs2 = manager.compute(update_history=True) obs2_policy = obs2["actor"] assert isinstance(obs2_policy, torch.Tensor) assert not torch.isnan(obs2_policy).any() def test_nan_before_history_buffer(mock_env, device): """NaN should be sanitized before entering history buffer.""" call_count = [0] def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) # Inject NaN on first call. if call_count[0] == 0: obs[1, 1] = float("nan") call_count[0] += 1 return obs cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg( func=obs_with_nan, params={}, history_length=3, flatten_history_dim=True ) }, nan_policy="sanitize", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) # First compute: NaN should be sanitized. obs1 = manager.compute(update_history=True) obs1_policy = obs1["actor"] assert isinstance(obs1_policy, torch.Tensor) assert not torch.isnan(obs1_policy).any() # Subsequent computes should also have no NaN from history. for _ in range(3): obs = manager.compute(update_history=True) obs_policy = obs["actor"] assert isinstance(obs_policy, torch.Tensor) assert not torch.isnan(obs_policy).any() def test_nan_handling_with_multiple_groups(mock_env, device): """NaN handling should work independently for multiple groups.""" def obs_with_nan(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("nan") return obs def clean_obs(env): return torch.ones((env.num_envs, 3), device=device) cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={})}, nan_policy="sanitize", ), "critic": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=clean_obs, params={})}, nan_policy="disabled", ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) assert not torch.isnan(policy_obs).any() critic_obs = obs["critic"] assert isinstance(critic_obs, torch.Tensor) assert not torch.isnan(critic_obs).any() def test_nan_handling_with_scaling(mock_env, device): """NaN handling should work correctly with scaling applied.""" def obs_with_nan(env): obs = torch.full((env.num_envs, 3), 2.0, device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_nan, params={}, scale=0.5)}, nan_policy="sanitize", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # NaN should be sanitized to 0, other values scaled: 2.0 * 0.5 = 1.0. assert not torch.isnan(policy_obs).any() assert policy_obs[1, 1] == 0.0 assert policy_obs[0, 0] == 1.0 # Scaled value. def test_nan_handling_with_clipping(mock_env, device): """NaN handling should work correctly with clipping applied.""" def obs_with_nan(env): obs = torch.full((env.num_envs, 3), 10.0, device=device) obs[1, 1] = float("nan") return obs cfg = { "actor": ObservationGroupCfg( terms={ "obs1": ObservationTermCfg(func=obs_with_nan, params={}, clip=(-1.0, 1.0)) }, nan_policy="sanitize", nan_check_per_term=True, ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) # NaN should be sanitized to 0, other values clipped to 1.0. assert not torch.isnan(policy_obs).any() assert policy_obs[1, 1] == 0.0 assert policy_obs[0, 0] == 1.0 # Clipped value. def test_negative_inf_handling(mock_env, device): """Test that negative infinity is also handled.""" def obs_with_neginf(env): obs = torch.ones((env.num_envs, 3), device=device) obs[1, 1] = float("-inf") return obs cfg = { "actor": ObservationGroupCfg( terms={"obs1": ObservationTermCfg(func=obs_with_neginf, params={})}, nan_policy="sanitize", ), } manager = ObservationManager(cfg, mock_env) obs = manager.compute() policy_obs = obs["actor"] assert isinstance(policy_obs, torch.Tensor) assert not torch.isinf(policy_obs).any() assert policy_obs[1, 1] == 0.0