Files
huoyi_training_level/eval_kit/tests/test_cli.py
T
syfffffjj 07027e8fde feat: huoyi M20 smooth/bridge training and stability docs
Current code snapshot: smooth/bridge task configs, smooth random terrain,
roll/pitch stability rewards, M20 URDF updates, eval profile and stability
training documentation.
2026-11-02 16:41:07 +08:00

545 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2025 Deep Robotics
# SPDX-License-Identifier: BSD 3-Clause
"""命令行入口的静态自检:不需要 Isaac Sim,也不需要 GPU。
用桩替换 ``isaaclab.app``,从而可以单独加载 ``eval_suite.py``,检查参数解析与
口径覆盖逻辑。仿真相关的部分(建环境、跑 rollout)不在覆盖范围内——那些必须真跑。
运行方式(在仓库根目录)::
python3 eval_kit/tests/test_cli.py
"""
from __future__ import annotations
import importlib.util
import sys
import types
from pathlib import Path
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
PROJECT_ROOT = Path(__file__).resolve().parents[2]
EVAL_SUITE_PATH = PROJECT_ROOT / "scripts" / "reinforcement_learning" / "rsl_rl" / "eval_suite.py"
#: ``AppLauncher.add_app_launcher_args()`` 会添加的参数名,抄自
#: ``IsaacLab-2.3.2/source/isaaclab/isaaclab/app/app_launcher.py``。
#: 自己的 parser 里出现任何一个都会让 argparse 抛
#: ``ArgumentError: conflicting option string``,脚本连启动都做不到。
#: (只有 device / headless / enable_cameras / experience / livestream /
#: rendering_mode / xr 会被 AppLauncher 自己的显式检查拦下,--verbose / --info /
#: --cpu 等则由 argparse 拦,所以必须把 14 个都列出来。)
APPLAUNCHER_ARG_NAMES = (
"--headless",
"--livestream",
"--enable_cameras",
"--xr",
"--device",
"--cpu",
"--verbose",
"--info",
"--experience",
"--rendering_mode",
"--kit_args",
"--anim_recording_enabled",
"--anim_recording_start_time",
"--anim_recording_stop_time",
)
def _stub_isaaclab() -> None:
"""装一个最小的 isaaclab.app 桩,只为让模块级 import 通过。
桩要复刻真实 ``AppLauncher`` 的行为,否则测试放过的正是真跑会崩的地方:
- ``add_app_launcher_args`` 会添加上面 14 个参数名
- 如果 parser 里已经有同名参数,它**拒绝启动**而不是静默覆盖
"""
if "isaaclab.app" in sys.modules:
return
class AppLauncher: # noqa: D401 - 桩
def __init__(self, args):
self.app = None
@staticmethod
def add_app_launcher_args(parser):
existing = {option for action in parser._actions for option in action.option_strings}
conflict = sorted(existing & set(APPLAUNCHER_ARG_NAMES))
if conflict:
raise ValueError(
f"argument {conflict[0]}: conflicting option string: {conflict[0]}"
)
parser.add_argument("--headless", action="store_true", default=False)
parser.add_argument("--device", type=str, default=None)
for name in APPLAUNCHER_ARG_NAMES:
dest = name.lstrip("-").replace("-", "_")
if dest in ("headless", "device"):
continue
parser.add_argument(name, dest=dest, nargs="?", const=True, default=None)
return parser
isaaclab = types.ModuleType("isaaclab")
app = types.ModuleType("isaaclab.app")
app.AppLauncher = AppLauncher # type: ignore[attr-defined]
isaaclab.app = app # type: ignore[attr-defined]
sys.modules["isaaclab"] = isaaclab
sys.modules["isaaclab.app"] = app
def _load_eval_suite():
_stub_isaaclab()
specification = importlib.util.spec_from_file_location("eval_suite_under_test", EVAL_SUITE_PATH)
if specification is None or specification.loader is None:
raise RuntimeError(f"无法加载 {EVAL_SUITE_PATH}")
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
def test_parser_defaults():
"""默认值:协议预设名、机器人描述名、评估类型都在。"""
module = _load_eval_suite()
args = module._build_parser().parse_args([])
assert args.task == "Rough-Deeprobotics-M20-v0", args.task
assert args.profile == "solenopsis"
assert args.protocol == "standard_v1"
assert args.eval_type == "all"
assert args.num_envs == 250
assert args.terrain_type == "all"
# 口径覆盖项默认必须为 None,否则会静默覆盖协议预设
for name in (
"episode_length_s", "command_x", "command_y", "command_yaw", "start_x", "start_y",
"terrain_size", "success_distance", "tilt_threshold", "acc_threshold_g",
"energy_convention", "robot_mass", "num_levels", "envs_per_cell", "seeds",
"enforce_terrain_bounds", "start_z_offset",
):
assert getattr(args, name) is None, f"{name} 默认应为 None,实际 {getattr(args, name)!r}"
return "默认值正确,口径覆盖项默认全部为 None"
def test_no_terrain_bounds_flag():
"""--no-terrain-bounds 关闭越界终止项,并改变指纹。"""
module = _load_eval_suite()
from eval_kit.protocols import get_protocol
preset = get_protocol("standard_v1")
assert preset.enforce_terrain_bounds is True, "standard_v1 应保持与 eval.py 一致(保留终止项)"
args = module._build_parser().parse_args(["--no-terrain-bounds"])
protocol = module._apply_overrides(preset, args)
assert protocol.enforce_terrain_bounds is False
assert protocol.fingerprint() != preset.fingerprint(), "关闭终止项必须改变指纹"
# 不带该开关时不覆盖
unchanged = module._apply_overrides(preset, module._build_parser().parse_args([]))
assert unchanged.enforce_terrain_bounds is True
return "开关生效且反映在指纹里"
def test_clean_protocol_uses_big_tile():
"""standard_v1_clean 通过放大地砖来消除越界误判,而不是关掉终止项。"""
from eval_kit.protocols import get_protocol
clean = get_protocol("standard_v1_clean")
parity = get_protocol("standard_v1")
no_bounds = get_protocol("standard_v1_no_bounds")
assert clean.terrain_size_m >= 3.8, f"地砖 {clean.terrain_size_m} 不足以消除误判"
assert clean.enforce_terrain_bounds is True, "clean 方案应保留终止项"
assert no_bounds.enforce_terrain_bounds is False
# 除地砖外其余口径与 parity 方案一致,数值仍可对照
assert clean.command == parity.command
assert clean.success_distance_m == parity.success_distance_m
assert clean.num_levels == parity.num_levels
return f"clean 地砖 {clean.terrain_size_m} m,保留终止项;no_bounds 关闭终止项"
def test_overrides_are_opt_in():
"""不传任何覆盖项时,协议必须与预设逐字段相同。"""
module = _load_eval_suite()
from eval_kit.protocols import get_protocol
args = module._build_parser().parse_args([])
preset = get_protocol("standard_v1")
protocol = module._apply_overrides(preset, args)
assert protocol == preset, "未传覆盖项却改变了协议"
assert protocol.fingerprint() == preset.fingerprint()
return "未传覆盖 → 协议与预设完全相同(含 fingerprint)"
def test_overrides_apply_and_update_fingerprint():
"""显式覆盖项生效,且指纹随之改变。"""
module = _load_eval_suite()
from eval_kit.protocols import get_protocol
args = module._build_parser().parse_args(
[
"--episode-length-s", "20",
"--command-x", "0.8",
"--command-yaw", "-0.3",
"--success-distance", "3.5",
"--tilt-threshold", "0.5",
"--acc-threshold-g", "0.3",
"--energy-convention", "signed",
"--terrain-size", "1.5",
"--num-levels", "4",
"--envs-per-cell", "2",
"--seeds", "7", "8",
]
)
preset = get_protocol("standard_v1")
protocol = module._apply_overrides(preset, args)
assert protocol.steps == 1000, protocol.steps # 20 s / 0.02 s
assert protocol.command == (0.8, 0.0, -0.3), protocol.command
assert protocol.success_distance_m == 3.5
assert protocol.tilt_threshold_rad == 0.5
assert abs(protocol.acc_threshold_g - 0.3) < 1e-12
assert protocol.energy_convention == "signed"
assert protocol.terrain_size_m == 1.5
assert protocol.num_levels == 4
assert protocol.envs_per_cell == 2
assert protocol.seeds == (7, 8)
assert protocol.fingerprint() != preset.fingerprint()
# 没覆盖的字段保持预设
assert protocol.start_pose == preset.start_pose
assert protocol.dt == preset.dt
return "11 项覆盖全部生效,未覆盖字段保持预设"
def test_command_overrides_compose_independently():
"""单独覆盖某一个速度分量时,另两个分量保持预设。"""
module = _load_eval_suite()
from eval_kit.protocols import get_protocol
preset = get_protocol("standard_v1") # command = (1.0, 0.0, 0.0)
only_y = module._apply_overrides(preset, module._build_parser().parse_args(["--command-y", "0.5"]))
assert only_y.command == (1.0, 0.5, 0.0), only_y.command
only_yaw = module._apply_overrides(preset, module._build_parser().parse_args(["--command-yaw", "1.2"]))
assert only_yaw.command == (1.0, 0.0, 1.2), only_yaw.command
both = module._apply_overrides(
preset, module._build_parser().parse_args(["--command-y", "0.5", "--command-yaw", "1.2"])
)
assert both.command == (1.0, 0.5, 1.2), both.command
only_x = module._apply_overrides(preset, module._build_parser().parse_args(["--command-x", "2.0"]))
assert only_x.command == (2.0, 0.0, 0.0), only_x.command
return "command 三个分量可独立覆盖"
def test_start_pose_overrides_compose():
"""起始位姿同理。"""
module = _load_eval_suite()
from eval_kit.protocols import get_protocol
preset = get_protocol("standard_v1")
protocol = module._apply_overrides(
preset, module._build_parser().parse_args(["--start-x", "-1.2", "--start-y", "0.4"])
)
assert protocol.start_pose == (-1.2, 0.4, 0.0), protocol.start_pose
return f"start_pose = {protocol.start_pose}"
def test_start_z_offset_override():
"""起始高度偏移:默认 0.0(与旧 eval.py 一致),显式给出时生效并改变指纹。"""
module = _load_eval_suite()
from eval_kit.protocols import get_protocol
preset = get_protocol("standard_v1")
assert preset.start_z_offset_m == 0.0, "默认必须为 0.0,否则与 eval.py 不可比"
parser = module._build_parser()
unchanged = module._apply_overrides(preset, parser.parse_args([]))
assert unchanged.start_z_offset_m == 0.0
assert unchanged.fingerprint() == preset.fingerprint()
lifted = module._apply_overrides(preset, parser.parse_args(["--start-z-offset", "0.3"]))
assert lifted.start_z_offset_m == 0.3
assert lifted.fingerprint() != preset.fingerprint(), "抬高起始高度必须改变指纹"
# 只动高度,不动水平位姿
assert lifted.start_pose == preset.start_pose
return "默认 0.0,--start-z-offset 生效且反映在指纹里"
def test_terrain_types_resolution():
"""地形类型:all 展开成规格里的全部类型,单个类型要校验存在性。"""
module = _load_eval_suite()
from eval_kit.core.terrain_spec import get_terrain_spec
spec = get_terrain_spec("standard_v1")
all_args = module._build_parser().parse_args([])
assert module._terrain_types_for(all_args, spec) == list(spec.types)
single_args = module._build_parser().parse_args(["--terrain-type", "boxes"])
assert module._terrain_types_for(single_args, spec) == ["boxes"]
bad_args = module._build_parser().parse_args(["--terrain-type", "nonexistent"])
try:
module._terrain_types_for(bad_args, spec)
except SystemExit as exc:
assert "nonexistent" in str(exc)
return "all / 单类型 / 非法类型校验都正确"
raise AssertionError("非法地形类型没有报错")
def test_all_profiles_and_protocols_are_loadable():
"""每个机器人描述、每个协议预设都要能被命令行选中。"""
module = _load_eval_suite()
parser = module._build_parser()
from eval_kit.profiles import PROFILES
from eval_kit.protocols import PROTOCOLS
for name in PROFILES:
args = parser.parse_args(["--profile", name])
assert args.profile == name
for name in PROTOCOLS:
args = parser.parse_args(["--protocol", name])
protocol = module._apply_overrides(PROTOCOLS[name], args)
assert protocol.steps >= 1
assert protocol.seeds, f"协议 {name} 没有种子"
return f"{len(PROFILES)} 个描述、{len(PROTOCOLS)} 个协议预设都可加载"
def test_eval_types_match_report_module():
"""命令行的评估类型必须与指标筛选表一致,避免出现选了却没指标的项。"""
module = _load_eval_suite()
from eval_kit.core.report import EVAL_TYPES, METRIC_SELECTION
args = module._build_parser().parse_args([])
choices = args.eval_type
assert choices in EVAL_TYPES
for eval_type in EVAL_TYPES:
if eval_type in ("all", "upper_limits"):
continue
assert eval_type in METRIC_SELECTION, f"{eval_type} 没有对应的指标筛选规则"
return f"{len(EVAL_TYPES)} 个评估类型,与 METRIC_SELECTION 对齐"
def test_parser_compatible_with_app_launcher():
"""自身的参数不能和 AppLauncher 冲突。
main() 的顺序是「先 _build_parser(),再 AppLauncher.add_app_launcher_args()」。
这里复刻同样的顺序:任何冲突都会在这一步抛错,也就是脚本连启动都做不到。
真跑时曾两次撞上这个坑(先是 --device、后是 --verbose),所以这个检查必须留下。
"""
module = _load_eval_suite()
parser = module._build_parser()
parser = module.AppLauncher.add_app_launcher_args(parser)
args = parser.parse_args([])
# --device / --headless 由 AppLauncher 提供,自己的 parser 里不能再定义
assert args.device is None
assert args.headless is False
assert args.task == "Rough-Deeprobotics-M20-v0"
assert args.num_envs == 250
return "与 AppLauncher 的 14 个参数名无冲突,--device/--headless 由其提供"
def test_no_option_string_collisions_with_app_launcher():
"""直接枚举检查:自身 parser 的任何 option string 都不能落在 AppLauncher 的名单里。"""
module = _load_eval_suite()
parser = module._build_parser()
own = {option for action in parser._actions for option in action.option_strings}
conflicts = sorted(own & set(APPLAUNCHER_ARG_NAMES))
assert not conflicts, (
f"与 AppLauncher 冲突: {conflicts} —— 真实启动时会抛 "
"argparse.ArgumentError: conflicting option string"
)
return f"自身 {len(own)} 个 option string 与 AppLauncher 的 {len(APPLAUNCHER_ARG_NAMES)} 个均不冲突"
def test_verbose_terrain_flag_replaces_verbose():
"""--verbose 归 AppLauncher,自己的逐格调试用 --verbose-terrain。"""
module = _load_eval_suite()
parser = module._build_parser()
args = parser.parse_args(["--verbose-terrain"])
assert args.verbose_terrain is True
plain = parser.parse_args([])
assert plain.verbose_terrain is False
return "--verbose-terrain 生效,未占用 --verbose"
def test_output_path_defaults_next_to_checkpoint():
"""默认输出落在 checkpoint 同目录的 eval_kit/ 下,不覆盖 eval.py 的 eval/。"""
module = _load_eval_suite()
import tempfile
with tempfile.TemporaryDirectory() as directory:
checkpoint = str(Path(directory) / "model_100.pt")
Path(checkpoint).write_bytes(b"")
args = module._build_parser().parse_args([])
path = module._resolve_output_path(args, checkpoint, "all")
assert "/eval_kit/" in path, path
assert path.endswith(".json")
assert "all_" in Path(path).name
return f"默认输出目录为 <checkpoint 目录>/eval_kit/"
def test_livestream_value_falls_back_to_env_var():
"""--livestream 默认 -1 表示「听 LIVESTREAM 环境变量」,不能当成 0。"""
import os
module = _load_eval_suite()
parser = module._build_parser()
args = parser.parse_args([])
args.livestream = -1
previous = os.environ.get("LIVESTREAM")
os.environ.pop("LIVESTREAM", None)
try:
assert module._livestream_value(args) == 0, "没设环境变量时应视为关闭"
os.environ["LIVESTREAM"] = "2"
assert module._livestream_value(args) == 2, "应回退读 LIVESTREAM 环境变量"
explicit = parser.parse_args([])
explicit.livestream = 1
assert module._livestream_value(explicit) == 1, "命令行显式值优先于环境变量"
finally:
if previous is None:
os.environ.pop("LIVESTREAM", None)
else:
os.environ["LIVESTREAM"] = previous
return "命令行 > LIVESTREAM 环境变量 > 关闭"
def test_viewer_env_index_covers_livestream():
"""推流也要设视口相机;真 headless(且不推流)才返回 None。
这条决定推流画面能不能看到机器人:``AppLauncher`` 在开启推流时会把
``headless`` 改写成 True,所以不能按 ``headless`` 判断「用户要不要看画面」。
"""
module = _load_eval_suite()
# --headless 由 AppLauncher 提供,所以这里要按 main() 的顺序加上
parser = module.AppLauncher.add_app_launcher_args(module._build_parser())
plain_headless = parser.parse_args(["--headless"])
plain_headless.livestream = 0
assert module._viewer_env_index(plain_headless) is None, "headless 且不推流时不应设视口相机"
streamed = parser.parse_args(["--headless", "--view-env", "7"])
streamed.livestream = 2
assert module._viewer_env_index(streamed) == 7, "推流时视口相机应生效并跟随 --view-env"
default_target = parser.parse_args([])
default_target.livestream = 2
assert module._viewer_env_index(default_target) == 0, "不传 --view-env 时默认跟随 env 0"
gui = parser.parse_args([])
gui.livestream = 0
assert module._viewer_env_index(gui) == 0, "本地 GUI(非 headless)仍然跟随 env 0"
# 自由视角:不跟随,且 --free-camera 优先于 --view-env
free = parser.parse_args(["--headless", "--free-camera"])
free.livestream = 2
assert module._viewer_env_index(free) is None, "--free-camera 时不应跟随任何环境"
both = parser.parse_args(["--headless", "--free-camera", "--view-env", "3"])
both.livestream = 2
assert module._viewer_env_index(both) is None, "--free-camera 与 --view-env 同时给出时以前者为准"
return "headless→None;推流 / 本地 GUI→跟随 --view-env(默认 0);--free-camera 优先于跟随"
def test_free_camera_only_applies_with_a_picture():
"""--free-camera 只在有画面时生效;给定 --view-env 也让位于它。"""
module = _load_eval_suite()
parser = module.AppLauncher.add_app_launcher_args(module._build_parser())
headless = parser.parse_args(["--headless", "--free-camera"])
headless.livestream = 0
assert module._display_enabled(headless) is False, "headless 且不推流时没有画面"
assert module._viewer_env_index(headless) is None
streamed = parser.parse_args(["--free-camera"])
streamed.livestream = 2
assert module._display_enabled(streamed) is True, "推流算有画面"
assert module._viewer_env_index(streamed) is None, "有画面 + --free-camera → 不跟随"
# 显式给出 --view-env 但没有 --free-camera:仍然跟随
following = parser.parse_args(["--view-env", "2"])
following.livestream = 2
assert module._viewer_env_index(following) == 2
return "无画面时 --free-camera 无效果;有画面时不跟随;未给该参数时行为不变"
def test_pause_defaults_to_connection_window_only_when_streaming():
"""--pause-s 是给推流客户端留连接时间的,默认值在 main() 里按是否推流决定。"""
module = _load_eval_suite()
# --livestream 由 AppLauncher 提供,所以这里要按 main() 的顺序加上
parser = module.AppLauncher.add_app_launcher_args(module._build_parser())
assert parser.parse_args([]).pause_s is None, "默认必须是 None,否则区分不出用户显式传的 0"
assert parser.parse_args(["--pause-s", "0"]).pause_s == 0.0
assert parser.parse_args(["--pause-s", "12.5"]).pause_s == 12.5
def resolve(arguments):
"""复刻 main() 里的取值规则。"""
parsed = parser.parse_args(arguments)
streaming = module._livestream_value(parsed) > 0
return parsed.pause_s if parsed.pause_s is not None else (30.0 if streaming else 0.0)
assert resolve([]) == 0.0, "不推流时不该停顿"
assert resolve(["--livestream", "2"]) == 30.0, "推流时默认留 30 s 连接时间"
assert resolve(["--livestream", "2", "--pause-s", "0"]) == 0.0, "显式 0 可以关掉停顿"
assert resolve(["--livestream", "1"]) == 30.0, "公网推流同样要留连接时间"
return "不推流默认 0 s;推流默认 30 s;显式值优先"
def test_local_ipv4_addresses_skips_loopback():
"""打印给 WebRTC 客户端的地址里不能出现回环地址。"""
module = _load_eval_suite()
addresses = module._local_ipv4_addresses()
assert all(not address.startswith("127.") for address in addresses), addresses
assert all(address.count(".") == 3 for address in addresses), addresses
return f"拿到 {len(addresses)} 个非回环地址:{addresses}"
def test_record_frames_defaults_off():
"""抓帧是可选功能:默认不抓,给了目录和间隔才生效。"""
module = _load_eval_suite()
parser = module._build_parser()
plain = parser.parse_args([])
assert plain.record_frames is None, "默认不应抓帧"
assert plain.record_every == 5, "默认每 5 步一张(0.1 s 仿真时间,合成 10 fps 视频)"
on = parser.parse_args(["--record-frames", "/tmp/frames", "--record-every", "1"])
assert on.record_frames == "/tmp/frames"
assert on.record_every == 1
return "默认关闭;--record-frames 指定目录、--record-every 指定间隔"
def main() -> int:
tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
failures: list[tuple[str, str]] = []
for test in tests:
name = test.__name__
try:
note = test()
except (Exception, SystemExit) as exc: # noqa: BLE001
# 也要接住 SystemExit:argparse 参数写错会直接 SystemExit(2),
# 不接的话整个测试文件当场退出,后面的测试一条都不跑、也没有汇总行。
failures.append((name, f"{type(exc).__name__}: {exc}"))
print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
else:
print(f"ok {name}" + (f" — {note}" if note else ""))
print()
if failures:
print(f"{len(failures)}/{len(tests)} 个测试失败")
return 1
print(f"全部 {len(tests)} 个测试通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())