276 lines
7.3 KiB
Python
276 lines
7.3 KiB
Python
"""Tests for reward_curriculum and termination_curriculum."""
|
|||
|
|
|
||
|
|
from unittest.mock import Mock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import torch
|
||
|
|
|
||
|
|
from mjlab.envs.mdp.curriculums import reward_curriculum, termination_curriculum
|
||
|
|
from mjlab.managers.curriculum_manager import CurriculumTermCfg
|
||
|
|
from mjlab.managers.reward_manager import RewardTermCfg
|
||
|
|
from mjlab.managers.termination_manager import TerminationTermCfg
|
||
|
|
|
||
|
|
|
||
|
|
def _reward_func(env):
|
||
|
|
return torch.ones(env.num_envs)
|
||
|
|
|
||
|
|
|
||
|
|
def _termination_func(env):
|
||
|
|
return torch.zeros(env.num_envs, dtype=torch.bool)
|
||
|
|
|
||
|
|
|
||
|
|
def _make_reward_cfg(
|
||
|
|
weight: float = 1.0,
|
||
|
|
params: dict | None = None,
|
||
|
|
) -> RewardTermCfg:
|
||
|
|
return RewardTermCfg(
|
||
|
|
func=_reward_func,
|
||
|
|
weight=weight,
|
||
|
|
params=params if params is not None else {"std": 0.5, "scale": 1.0},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _make_termination_cfg(
|
||
|
|
params: dict | None = None,
|
||
|
|
) -> TerminationTermCfg:
|
||
|
|
return TerminationTermCfg(
|
||
|
|
func=_termination_func,
|
||
|
|
params=params if params is not None else {"threshold": float("inf")},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _build_reward(env, reward_name, stages):
|
||
|
|
params = {"reward_name": reward_name, "stages": stages}
|
||
|
|
cfg = CurriculumTermCfg(func=reward_curriculum, params=params)
|
||
|
|
instance = reward_curriculum(cfg, env)
|
||
|
|
return instance(env, env_ids=torch.tensor([0, 1]), **params)
|
||
|
|
|
||
|
|
|
||
|
|
def _build_termination(env, termination_name, stages):
|
||
|
|
params = {"termination_name": termination_name, "stages": stages}
|
||
|
|
cfg = CurriculumTermCfg(func=termination_curriculum, params=params)
|
||
|
|
instance = termination_curriculum(cfg, env)
|
||
|
|
return instance(env, env_ids=torch.tensor([0, 1]), **params)
|
||
|
|
|
||
|
|
|
||
|
|
def _make_reward_env(step_counter, reward_cfg):
|
||
|
|
env = Mock()
|
||
|
|
env.common_step_counter = step_counter
|
||
|
|
env.reward_manager.get_term_cfg.return_value = reward_cfg
|
||
|
|
return env
|
||
|
|
|
||
|
|
|
||
|
|
def _make_termination_env(step_counter, term_cfg):
|
||
|
|
env = Mock()
|
||
|
|
env.common_step_counter = step_counter
|
||
|
|
env.termination_manager.get_term_cfg.return_value = term_cfg
|
||
|
|
return env
|
||
|
|
|
||
|
|
|
||
|
|
# Reward: weight
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_weight_unchanged_before_threshold():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(0, rc)
|
||
|
|
_build_reward(env, "r", [{"step": 100, "weight": 2.0}])
|
||
|
|
assert rc.weight == pytest.approx(1.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_weight_applied_at_threshold():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(100, rc)
|
||
|
|
_build_reward(env, "r", [{"step": 100, "weight": 2.0}])
|
||
|
|
assert rc.weight == pytest.approx(2.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_weight_later_stage_wins():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(500, rc)
|
||
|
|
_build_reward(
|
||
|
|
env,
|
||
|
|
"r",
|
||
|
|
[
|
||
|
|
{"step": 0, "weight": 0.5},
|
||
|
|
{"step": 100, "weight": 1.5},
|
||
|
|
{"step": 400, "weight": 3.0},
|
||
|
|
],
|
||
|
|
)
|
||
|
|
assert rc.weight == pytest.approx(3.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_weight_partial_application():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(150, rc)
|
||
|
|
_build_reward(
|
||
|
|
env,
|
||
|
|
"r",
|
||
|
|
[
|
||
|
|
{"step": 100, "weight": 2.0},
|
||
|
|
{"step": 200, "weight": 4.0},
|
||
|
|
],
|
||
|
|
)
|
||
|
|
assert rc.weight == pytest.approx(2.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_step_zero_applies_immediately():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(0, rc)
|
||
|
|
_build_reward(env, "r", [{"step": 0, "weight": 9.0}])
|
||
|
|
assert rc.weight == pytest.approx(9.0)
|
||
|
|
|
||
|
|
|
||
|
|
# Reward: params
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_params_updated():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(200, rc)
|
||
|
|
_build_reward(env, "r", [{"step": 100, "params": {"std": 0.2}}])
|
||
|
|
assert rc.params["std"] == 0.2
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_params_unchanged_before_threshold():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(0, rc)
|
||
|
|
_build_reward(env, "r", [{"step": 100, "params": {"std": 0.2}}])
|
||
|
|
assert rc.params["std"] == 0.5
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_multiple_params_updated():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(200, rc)
|
||
|
|
_build_reward(env, "r", [{"step": 100, "params": {"std": 0.2, "scale": 2.0}}])
|
||
|
|
assert rc.params["std"] == 0.2
|
||
|
|
assert rc.params["scale"] == 2.0
|
||
|
|
|
||
|
|
|
||
|
|
# Reward: combined weight + params
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_weight_and_params_in_same_stage():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(200, rc)
|
||
|
|
_build_reward(env, "r", [{"step": 100, "weight": 5.0, "params": {"std": 0.1}}])
|
||
|
|
assert rc.weight == pytest.approx(5.0)
|
||
|
|
assert rc.params["std"] == 0.1
|
||
|
|
|
||
|
|
|
||
|
|
# Termination: params
|
||
|
|
|
||
|
|
|
||
|
|
def test_termination_params_updated():
|
||
|
|
tc = _make_termination_cfg()
|
||
|
|
env = _make_termination_env(200, tc)
|
||
|
|
_build_termination(env, "energy", [{"step": 100, "params": {"threshold": 500.0}}])
|
||
|
|
assert tc.params["threshold"] == 500.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_termination_params_unchanged_before_threshold():
|
||
|
|
tc = _make_termination_cfg()
|
||
|
|
env = _make_termination_env(0, tc)
|
||
|
|
_build_termination(env, "energy", [{"step": 100, "params": {"threshold": 500.0}}])
|
||
|
|
assert tc.params["threshold"] == float("inf")
|
||
|
|
|
||
|
|
|
||
|
|
def test_termination_later_stage_wins():
|
||
|
|
tc = _make_termination_cfg()
|
||
|
|
env = _make_termination_env(500, tc)
|
||
|
|
_build_termination(
|
||
|
|
env,
|
||
|
|
"energy",
|
||
|
|
[
|
||
|
|
{"step": 0, "params": {"threshold": 1000.0}},
|
||
|
|
{"step": 100, "params": {"threshold": 700.0}},
|
||
|
|
{"step": 400, "params": {"threshold": 400.0}},
|
||
|
|
],
|
||
|
|
)
|
||
|
|
assert tc.params["threshold"] == 400.0
|
||
|
|
|
||
|
|
|
||
|
|
# Validation: shared engine
|
||
|
|
|
||
|
|
|
||
|
|
def test_unknown_reward_param_raises():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(0, rc)
|
||
|
|
params = {"reward_name": "r", "stages": [{"step": 0, "params": {"stdd": 0.2}}]}
|
||
|
|
cfg = CurriculumTermCfg(func=reward_curriculum, params=params)
|
||
|
|
with pytest.raises(KeyError, match="unknown param"):
|
||
|
|
reward_curriculum(cfg, env)
|
||
|
|
|
||
|
|
|
||
|
|
def test_unknown_termination_param_raises():
|
||
|
|
tc = _make_termination_cfg()
|
||
|
|
env = _make_termination_env(0, tc)
|
||
|
|
params = {
|
||
|
|
"termination_name": "energy",
|
||
|
|
"stages": [{"step": 0, "params": {"thresholddd": 1.0}}],
|
||
|
|
}
|
||
|
|
cfg = CurriculumTermCfg(func=termination_curriculum, params=params)
|
||
|
|
with pytest.raises(KeyError, match="unknown param"):
|
||
|
|
termination_curriculum(cfg, env)
|
||
|
|
|
||
|
|
|
||
|
|
def test_unsorted_stages_raise():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(0, rc)
|
||
|
|
params = {
|
||
|
|
"reward_name": "r",
|
||
|
|
"stages": [
|
||
|
|
{"step": 200, "weight": 1.0},
|
||
|
|
{"step": 100, "weight": 2.0},
|
||
|
|
],
|
||
|
|
}
|
||
|
|
cfg = CurriculumTermCfg(func=reward_curriculum, params=params)
|
||
|
|
with pytest.raises(ValueError, match="nondecreasing"):
|
||
|
|
reward_curriculum(cfg, env)
|
||
|
|
|
||
|
|
|
||
|
|
def test_duplicate_steps_allowed():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(200, rc)
|
||
|
|
_build_reward(
|
||
|
|
env,
|
||
|
|
"r",
|
||
|
|
[
|
||
|
|
{"step": 100, "weight": 2.0},
|
||
|
|
{"step": 100, "params": {"std": 0.1}},
|
||
|
|
],
|
||
|
|
)
|
||
|
|
assert rc.weight == pytest.approx(2.0)
|
||
|
|
assert rc.params["std"] == 0.1
|
||
|
|
|
||
|
|
|
||
|
|
# Logging keys
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_logs_only_staged_keys():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(200, rc)
|
||
|
|
result = _build_reward(
|
||
|
|
env, "r", [{"step": 100, "weight": 5.0, "params": {"std": 0.2}}]
|
||
|
|
)
|
||
|
|
assert result["weight"].item() == pytest.approx(5.0)
|
||
|
|
assert result["std"].item() == pytest.approx(0.2)
|
||
|
|
assert "scale" not in result # Not in any stage.
|
||
|
|
|
||
|
|
|
||
|
|
def test_reward_omits_weight_when_not_staged():
|
||
|
|
rc = _make_reward_cfg()
|
||
|
|
env = _make_reward_env(200, rc)
|
||
|
|
result = _build_reward(env, "r", [{"step": 100, "params": {"std": 0.2}}])
|
||
|
|
assert "weight" not in result
|
||
|
|
assert "std" in result
|
||
|
|
|
||
|
|
|
||
|
|
def test_termination_log_keys():
|
||
|
|
tc = _make_termination_cfg()
|
||
|
|
env = _make_termination_env(200, tc)
|
||
|
|
result = _build_termination(
|
||
|
|
env, "energy", [{"step": 100, "params": {"threshold": 500.0}}]
|
||
|
|
)
|
||
|
|
assert "threshold" in result
|
||
|
|
assert result["threshold"].item() == pytest.approx(500.0)
|
||
|
|
assert "weight" not in result # No weight for termination.
|