Add eval_kit evaluation suite with WebRTC visualization

- eval_kit/core: 零框架依赖的指标内核(Trace/metrics/aggregation/terrain_spec/report/naming)
- eval_kit/isaaclab: 适配层(env_cfg 改造、rollout 采集、checkpoint 加载)
- eval_kit/profiles.py / protocols.py: 机器人形态描述与评估口径预设
- eval_kit/tests: 24 项内核自检 + 21 项命令行自检(都不需要仿真)
- eval_suite.py: 编排入口,每个(阶段, 种子)一个子进程;与 eval.py 指标口径一致
- WebRTC 可视化:--livestream 透传、视口相机跟随/自由(--view-env / --free-camera)、
  连接前停顿(--pause-s)、自动放开客户端改画幅、打印连接地址
- 诊断与替代路径:rollout 进度打印、--record-frames 抓帧
This commit is contained in:
syfffffjj
2026-10-20 10:40:13 +08:00
parent 1aeeb34653
commit 7d290ca6ff
18 changed files with 4823 additions and 0 deletions
+544
View File
@@ -0,0 +1,544 @@
# 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())
+645
View File
@@ -0,0 +1,645 @@
# Copyright (c) 2025 Deep Robotics
# SPDX-License-Identifier: BSD 3-Clause
"""评价内核的单元测试:用合成轨迹验证指标,不需要仿真环境。
运行方式(在仓库根目录)::
python3 eval_kit/tests/test_core.py
合成轨迹的价值在于:指标的期望值可以手算,因此能证明指标实现是对的,
而不是「跑起来没报错」。这也是把指标做成纯函数的主要理由。
"""
from __future__ import annotations
import math
import sys
from dataclasses import replace
from pathlib import Path
import numpy as np
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from eval_kit.core.aggregation import (
accumulated_success,
aggregate_runs,
build_upper_limit_matrix,
upper_limit_score,
)
from eval_kit.core.metrics import compute_metrics, support_margin_xy, tilt_from_projected_gravity
from eval_kit.core.naming import resolve_matching_indices
from eval_kit.core.report import (
filter_metrics,
format_aggregate_summary,
format_matrix,
format_metric_result,
format_numpy_matrix,
format_rollout_summary,
format_upper_limit_summary,
)
from eval_kit.core.schema import EvalProtocol, RobotProfile, Trace
from eval_kit.core.terrain_spec import get_terrain_spec
from eval_kit.profiles import get_profile
from eval_kit.protocols import get_protocol
G = 9.81
# --------------------------------------------------------------------------- 构造器
def make_trace(
*,
num_envs: int = 3,
steps: int = 10,
dt: float = 0.02,
speed: float = 1.0,
tilt_rad: float = 0.0,
torque: float = 1.0,
joint_vel: float = 1.0,
num_joints: int = 2,
num_feet: int = 4,
num_support: int = 2,
support_force: float = 0.0,
support_contact_time: float = 0.0,
mass: float | None = 26.581,
start_x: float = -0.9,
command: tuple[float, float, float] = (1.0, 0.0, 0.0),
accel_spike_at: int | None = None,
accel_spike: float = 0.1,
) -> Trace:
"""造一条可控的合成轨迹。
机器人沿 +x 匀速前进;``accel_spike_at`` 指定在第几帧把速度永久抬高
``accel_spike``,用于制造一次可预期的不稳定计数。
"""
frames = steps + 1
time = np.arange(frames, dtype=np.float64) * dt
speed_profile = np.full(frames, float(speed), dtype=np.float64)
if accel_spike_at is not None:
speed_profile[accel_spike_at:] += accel_spike
x = start_x + np.concatenate([[0.0], np.cumsum(speed_profile[1:] * dt)])
base_pos_w = np.zeros((frames, num_envs, 3), dtype=np.float64)
base_pos_w[:, :, 0] = x[:, None]
base_lin_vel_w = np.zeros((frames, num_envs, 3), dtype=np.float64)
base_lin_vel_w[:, :, 0] = speed_profile[:, None]
projected_gravity_b = np.zeros((frames, num_envs, 3), dtype=np.float64)
projected_gravity_b[:, :, 0] = -math.sin(tilt_rad)
projected_gravity_b[:, :, 2] = -math.cos(tilt_rad)
joint_torque = np.full((frames, num_envs, num_joints), float(torque), dtype=np.float64)
joint_vel_arr = np.full((frames, num_envs, num_joints), float(joint_vel), dtype=np.float64)
feet_pos_w = np.zeros((frames, num_envs, num_feet, 2), dtype=np.float64)
if num_feet:
offsets = np.linspace(-0.3, 0.3, num_feet)
for foot_index, offset_x in enumerate(offsets):
feet_pos_w[:, :, foot_index, 0] = x[:, None] + offset_x
feet_pos_w[:, :, foot_index, 1] = 0.1 if foot_index % 2 else -0.1
support_force_n = np.full((frames, num_envs, num_support), float(support_force), dtype=np.float64)
support_contact_time_s = np.full(
(frames, num_envs, num_support), float(support_contact_time), dtype=np.float64
)
return Trace(
dt=dt,
command=np.asarray(command, dtype=np.float64),
base_pos_w=base_pos_w,
base_lin_vel_w=base_lin_vel_w,
base_lin_vel_b=base_lin_vel_w.copy(),
base_ang_vel_b=np.zeros((frames, num_envs, 3), dtype=np.float64),
projected_gravity_b=projected_gravity_b,
joint_torque=joint_torque,
joint_vel=joint_vel_arr,
feet_pos_w=feet_pos_w,
support_force_n=support_force_n,
support_contact_time_s=support_contact_time_s,
done=np.zeros((frames, num_envs), dtype=np.float64),
mass_kg=mass,
)
def short_protocol(**overrides) -> EvalProtocol:
"""时间很短、成功距离很小的协议,便于手算期望值。"""
kwargs: dict[str, object] = {"steps": 10}
kwargs.update(overrides)
return replace(get_protocol("standard_v1"), **kwargs) # type: ignore[arg-type]
# ------------------------------------------------------------------------------- 测试
def test_perfect_tracker_succeeds():
"""匀速前进、姿态水平:成功率 1,跟踪误差 0。"""
protocol = short_protocol(success_distance_m=0.09)
metrics = compute_metrics(make_trace(), get_profile("solenopsis"), protocol)
assert metrics["success_rate"] == 1.0, metrics["success_rate"]
assert metrics["success_count"] == 3.0
assert abs(metrics["mean_lin_vel_error"]) < 1e-9, metrics["mean_lin_vel_error"]
assert abs(metrics["mean_yaw_vel_error"]) < 1e-9
assert abs(metrics["avg_max_tilt_rad"]) < 1e-9
assert metrics["unstability_rate"] == 0.0
# speed=1 m/s 从 start_x=-0.9 出发:第 4 帧进度 0.08 < 0.09,第 5 帧 0.10 >= 0.09,
# 因此首次越线在第 5 帧,耗时 5*0.02 = 0.10 s。
assert abs(metrics["avg_time_consumption_s"] - 0.10) < 1e-9, metrics["avg_time_consumption_s"]
return "success_rate=1.00, t=0.10 s, 跟踪误差 0"
def test_stationary_robot_fails():
"""原地不动:成功率 0,最终进度 0。"""
protocol = short_protocol(success_distance_m=0.1)
metrics = compute_metrics(make_trace(speed=0.0), get_profile("solenopsis"), protocol)
assert metrics["success_rate"] == 0.0
assert abs(metrics["avg_final_progress_m"]) < 1e-9
assert math.isnan(metrics["avg_time_consumption_s"]), metrics["avg_time_consumption_s"]
assert math.isnan(metrics["time_efficiency"])
return "success_rate=0.00, 进度 0, 耗时 nan"
def test_faller_is_not_success():
"""走够了距离但倾角超阈值:不算成功。"""
protocol = short_protocol(success_distance_m=0.1, tilt_threshold_rad=0.70)
# 倾角 1.0 rad > 0.70 rad
metrics = compute_metrics(make_trace(tilt_rad=1.0), get_profile("solenopsis"), protocol)
assert abs(metrics["avg_final_progress_m"] - 0.2) < 1e-9
assert metrics["success_rate"] == 0.0, "倾角超限却算成功了"
assert abs(metrics["avg_max_tilt_rad"] - 1.0) < 1e-9
assert metrics["avg_stable_time_s"] == 0.0
return "进度达成但 success_rate=0.00,max_tilt=1.00 rad"
def test_energy_and_cot_match_hand_calculation():
"""能耗与 COT 与手算值一致。"""
steps, dt, joints = 10, 0.02, 2
mass = 26.581
protocol = short_protocol(steps=steps)
trace = make_trace(steps=steps, dt=dt, num_joints=joints, mass=mass)
metrics = compute_metrics(trace, get_profile("solenopsis"), protocol)
expected_energy = 1.0 * 1.0 * joints * steps * dt # |tau*vel| * 关节数 * 步数 * dt
expected_route = 1.0 * steps * dt
# 分母带 1e-6 保护项,与旧 eval.py 的 `route + 1e-6` 一致
expected_cot = expected_energy / ((expected_route + 1e-6) * mass * G)
assert abs(metrics["avg_energy_j"] - expected_energy) < 1e-12, (metrics["avg_energy_j"], expected_energy)
assert abs(metrics["avg_cost_of_transport"] - expected_cot) < 1e-12, (
metrics["avg_cost_of_transport"],
expected_cot,
)
assert abs(metrics["robot_mass_kg"] - mass) < 1e-12
return f"energy={expected_energy:.4f} J, COT={expected_cot:.5f}, mass={mass} kg(自动取自 Trace)"
def test_signed_energy_convention_differs():
"""有符号口径在存在负功时给出更小的能耗;两种约定都被记录。"""
steps, dt = 10, 0.02
trace = make_trace(steps=steps, dt=dt, num_joints=2, joint_vel=1.0)
# 让后一半控制步的力矩反向,制造负功:帧 1..5 为 +1,帧 6..10 为 -1。
half = steps // 2
trace.joint_torque[1 + half :] = -1.0
absolute = compute_metrics(trace, get_profile("solenopsis"), short_protocol(energy_convention="absolute"))
signed = compute_metrics(trace, get_profile("solenopsis"), short_protocol(energy_convention="signed"))
assert absolute["avg_energy_j"] > signed["avg_energy_j"], (
absolute["avg_energy_j"],
signed["avg_energy_j"],
)
assert abs(signed["avg_energy_j"]) < 1e-12, "正负功抵消后应为 0"
assert absolute["energy_convention"] == "absolute"
assert signed["energy_convention"] == "signed"
return f"absolute={absolute['avg_energy_j']:.4f} J, signed={signed['avg_energy_j']:.4f} J"
def test_instability_counts_acceleration_spikes():
"""一次速度跳变恰好计一次不稳定。"""
steps, dt = 10, 0.02
protocol = replace(
short_protocol(steps=steps), acc_threshold_g=2.0 / G
)
# 第 5 帧速度永久 +0.1 m/s → 该处加速度 0.1/0.02 = 5 m/s² > 2 m/s²
metrics = compute_metrics(
make_trace(steps=steps, dt=dt, accel_spike_at=5, accel_spike=0.1),
get_profile("solenopsis"),
protocol,
)
assert abs(metrics["unstability_rate"] - 1.0 / steps) < 1e-12, metrics["unstability_rate"]
return f"unstability_rate={metrics['unstability_rate']:.3f} (= 1/{steps})"
def test_cost_of_transport_uses_trace_mass_over_profile():
"""Trace 里的质量优先于 profile 兜底值。"""
profile = replace(get_profile("solenopsis"), mass_kg=999.0)
metrics = compute_metrics(make_trace(mass=26.581), profile, short_protocol())
assert abs(metrics["robot_mass_kg"] - 26.581) < 1e-12
trace_no_mass = make_trace(mass=None)
metrics_fallback = compute_metrics(trace_no_mass, profile, short_protocol())
assert abs(metrics_fallback["robot_mass_kg"] - 999.0) < 1e-12
return "Trace 优先,profile 兜底"
def test_report_formatters_run_on_real_shapes():
"""报告层三个格式化函数都要能真跑——它们之前在没有任何测试覆盖的情况下
把一个不存在的 numpy 参数留在里面,直到实跑才炸。"""
spec = get_terrain_spec("standard_v1")
terrain_types = ["stairs_up", "boxes"]
level_ids = np.array([0, 0, 1, 1, 0, 0, 1, 1])
type_ids = np.array([0, 0, 0, 0, 1, 1, 1, 1])
per_env = {
"success": [1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0],
"max_progress": [3.0, 3.0, 1.0, 1.0, 2.5, 1.5, 3.0, 3.0],
"max_tilt": [0.1] * 8,
}
matrix = build_upper_limit_matrix(per_env, level_ids, type_ids, terrain_types, spec, 2)
# 普通数值
text = format_numpy_matrix(matrix["matrix"]["success_rate"], title="success rate")
assert "success rate" in text and "1.00" in text, text
# 含 nan:把 title 里的 "nan" 排除在外,只检查矩阵主体
empty = build_upper_limit_matrix(
per_env, level_ids, type_ids, terrain_types, spec, 4
)
body_only = format_numpy_matrix(empty["matrix"]["success_rate"], title="unassigned levels").split("\n", 1)[1]
assert "-" in body_only, f"nan 没有被替换成 -:{body_only}"
assert "nan" not in body_only, f"主体里仍出现 nan:{body_only}"
# 上限汇总:走 build_upper_limit_matrix 的真实输出结构
summary = format_upper_limit_summary(matrix)
assert "success rate" in summary and "score" in summary, summary
assert "difficulty units" in summary
# 逐等级矩阵(format_matrix)
table = format_matrix(matrix["matrix"]["success_rate"], terrain_types, title="table")
assert "level" in table and "stairs_up" in table, table
# 单指标与汇总
metrics = compute_metrics(make_trace(), get_profile("solenopsis"), short_protocol(success_distance_m=0.05))
assert "success rate" in format_metric_result("access_rate", metrics)
assert "protocol" in format_rollout_summary(metrics)
return "四个格式化函数在真实结构上都能跑,nan 正确显示为 -"
def test_minimum_terrain_size_for_bounds():
"""地砖下限公式:与网格行数无关,取默认值时是 3.8 m。
这个函数必须在纯内核里——编排进程不启动仿真,无法 import 任何依赖 isaaclab
的模块(会因缺 pxr 失败)。之前它放在适配层,父进程一调用就崩。
"""
protocol = get_protocol("standard_v1")
# start_x = -0.9, buffer = 3.0, border = 2.0 → 2*(0.9+3.0-2.0) = 3.8
assert abs(protocol.minimum_terrain_size_for_bounds() - 3.8) < 1e-12
# clean 预设的地砖应当不小于该下限
clean = get_protocol("standard_v1_clean")
assert clean.terrain_size_m >= clean.minimum_terrain_size_for_bounds()
# 与网格行数无关
for levels in (2, 5, 8, 16):
assert abs(replace(protocol, num_levels=levels).minimum_terrain_size_for_bounds() - 3.8) < 1e-12
# border 与 buffer 可调,且不会返回负数
assert abs(protocol.minimum_terrain_size_for_bounds(border_width=1.0, distance_buffer=1.0) - 1.8) < 1e-12
assert protocol.minimum_terrain_size_for_bounds(border_width=10.0, distance_buffer=0.0) == 0.0
# 起始位置越靠外,要求越大
far = replace(protocol, start_pose=(-1.5, 0.0, 0.0))
assert far.minimum_terrain_size_for_bounds() > protocol.minimum_terrain_size_for_bounds()
return "默认 3.8 m,与行数无关,参数可调且非负"
def test_missing_morphology_returns_nan_not_zero():
"""没有足端 / 没有支撑体的机器人应返回 nan,而不是看起来像测出来的 0。"""
profile = RobotProfile(
name="no_morphology",
mass_kg=10.0,
feet_body_patterns=(),
support_body_patterns=(),
actuated_joint_patterns=(".*",),
)
metrics = compute_metrics(make_trace(num_feet=0, num_support=0), profile, short_protocol())
assert math.isnan(metrics["avg_stability_margin_m"]), metrics["avg_stability_margin_m"]
assert math.isnan(metrics["avg_support_contact_ratio"])
assert math.isnan(metrics["successful_runs_with_support_ratio"])
assert metrics["stability_margin_method"] == "unavailable"
return "缺失形态 → nan(旧实现给 0.00)"
def test_support_contact_events_and_ratio():
"""支撑接触比例与事件计数可手算。"""
steps, dt = 10, 0.02
trace = make_trace(steps=steps, dt=dt, support_force=0.0)
# 连续 3 帧有超过阈值的支撑接触 → 1 次事件,占 3/10 步
trace.support_force_n[2:5] = 100.0
trace.support_contact_time_s[2:5] = 0.05
protocol = replace(short_protocol(steps=steps, success_distance_m=0.1))
metrics = compute_metrics(trace, get_profile("solenopsis"), protocol)
assert abs(metrics["avg_support_contact_ratio"] - 3.0 / steps) < 1e-12, metrics["avg_support_contact_ratio"]
assert abs(metrics["avg_support_events"] - 1.0) < 1e-12, metrics["avg_support_events"]
assert abs(metrics["successful_runs_with_support_ratio"] - 1.0) < 1e-12
return "contact_ratio=0.300, events=1.0, 成功率内用支撑=100%"
def test_support_margin_sign_and_tilt_formula():
"""支撑裕度在包围盒内外符号正确;倾角公式反解一致。"""
root_inside = np.array([[0.0, 0.0]])
feet = np.array([[[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]]], dtype=np.float64)
inside = support_margin_xy(root_inside, feet)
assert abs(inside[0] - 1.0) < 1e-12, inside
root_outside = np.array([[2.0, 0.0]])
outside = support_margin_xy(root_outside, feet)
assert abs(outside[0] - (-1.0)) < 1e-12, outside
theta = 0.5
gravity_b = np.array([[[-math.sin(theta), 0.0, -math.cos(theta)]]], dtype=np.float64)
tilt = tilt_from_projected_gravity(gravity_b)
assert abs(tilt[0, 0] - theta) < 1e-9
return "盒内 +1.000,盒外 -1.000,倾角反解 0.500 rad"
def test_accumulated_and_score_match_legged_gym_formula():
"""累积成功率与 score 与 legged_gym criterion.py 的公式一致。"""
rates = [1.0, 0.8, 0.5, 0.0]
accumulated = accumulated_success(rates)
assert np.allclose(accumulated, [1.0, 0.8, 0.4, 0.0]), accumulated
# 手算:i=0 → 1.0*1*(1-0.8)=0.2;i=1 → 0.8*2*(1-0.5)=0.8;
# i=2 → 0.4*3*(1-0.0)=1.2;i=3 → 0.0*4*1=0.0
expected = 0.2 + 0.8 + 1.2 + 0.0
assert abs(upper_limit_score(rates) - expected) < 1e-12, (upper_limit_score(rates), expected)
# 全通过与全失败是两个极端
assert abs(upper_limit_score([1.0] * 4) - 4.0) < 1e-12
assert abs(upper_limit_score([0.0] * 4)) < 1e-12
return f"score={expected:.2f}(手算),全通过=4.00,全失败=0.00"
def test_upper_limit_matrix_cells():
"""上限矩阵按「等级 × 地形」正确分格。"""
spec = get_terrain_spec("standard_v1")
terrain_types = ["stairs_up", "boxes"]
num_levels = 2
envs_per_cell = 2
level_ids = np.array([0, 0, 1, 1, 0, 0, 1, 1])
type_ids = np.array([0, 0, 0, 0, 1, 1, 1, 1])
# stairs_up: 等级0 两个都成功,等级1 两个都失败
# boxes: 等级0 一个成功,等级1 两个都成功
per_env = {
"success": [1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0],
"max_progress": [3.0, 3.0, 1.0, 1.0, 2.5, 1.5, 3.0, 3.0],
"max_tilt": [0.1] * 8,
}
result = build_upper_limit_matrix(
per_env, level_ids, type_ids, terrain_types, spec, num_levels
)
stairs = result["per_type"]["stairs_up"]
boxes = result["per_type"]["boxes"]
assert stairs["success_rate"] == [1.0, 0.0], stairs["success_rate"]
assert boxes["success_rate"] == [0.5, 1.0], boxes["success_rate"]
assert np.allclose(stairs["success_rate_accumulated"], [1.0, 0.0])
assert np.allclose(boxes["success_rate_accumulated"], [0.5, 0.5])
# 行=等级、列=地形
assert result["matrix"]["success_rate"] == [[1.0, 0.5], [0.0, 1.0]]
# 难度值取自规格
assert result["matrix"]["difficulty"][0] == [0.04, 0.04]
assert result["matrix"]["difficulty"][1] == [0.06, 0.06]
assert result["num_levels"] == num_levels
assert result["scores"]["stairs_up"] == upper_limit_score([1.0, 0.0])
return "矩阵 [[1.00,0.50],[0.00,1.00]],难度 [[0.04,0.04],[0.06,0.06]]"
def test_fingerprint_tracks_protocol_changes():
"""改变口径会改变指纹;改变种子不会。"""
base = get_protocol("standard_v1")
same = get_protocol("standard_v1")
assert base.fingerprint() == same.fingerprint()
changed_energy = replace(base, energy_convention="signed")
changed_tilt = replace(base, tilt_threshold_rad=0.5)
pushed = replace(base, enable_interval_pushes=True)
reseeded = replace(base, seeds=(1, 2, 3))
assert base.fingerprint() != changed_energy.fingerprint()
assert base.fingerprint() != changed_tilt.fingerprint()
assert base.fingerprint() != pushed.fingerprint()
assert base.fingerprint() == reseeded.fingerprint(), "种子不影响单次 rollout 的口径"
return "能耗约定/倾角阈值/推力开关都改变指纹,种子不改变"
def test_trace_validation_rejects_mismatched_shapes():
"""形状不一致要早失败。"""
trace = make_trace(num_envs=2, steps=5, num_joints=3)
trace.joint_vel = np.zeros((6, 3, 3)) # 环境数写成 3,应为 2
try:
trace.validate()
except ValueError as exc:
assert "joint_vel" in str(exc), str(exc)
return "捕获到形状错误"
raise AssertionError("形状不一致却没有报错")
def test_trace_roundtrip(tmp_path: Path | None = None):
"""轨迹存盘再读回,指标不变——这是「事后重新分析」的前提。"""
import tempfile
trace = make_trace(steps=8)
protocol = short_protocol(steps=8, success_distance_m=0.05)
profile = get_profile("solenopsis")
before = compute_metrics(trace, profile, protocol)
with tempfile.TemporaryDirectory() as directory:
path = str(Path(directory) / "trace.npz")
trace.save(path)
reloaded = Trace.load(path)
after = compute_metrics(reloaded, profile, protocol)
for key in before:
if key.startswith("_"):
continue
left, right = before[key], after[key]
if isinstance(left, float) and math.isnan(left):
assert math.isnan(right), key
else:
assert left == right, (key, left, right)
return f"存盘往返后 {len([k for k in before if not k.startswith('_')])} 个指标完全一致"
def test_report_filters_and_formats():
"""输出层按评估类型裁剪,且 nan 不炸格式串。"""
metrics = compute_metrics(make_trace(num_feet=0, num_support=0), get_profile("solenopsis"), short_protocol())
selected = filter_metrics(metrics, "stability_margin")
assert list(selected.keys()) == ["avg_stability_margin_m"]
text = format_metric_result("stability_margin", metrics)
assert "nan" in text, text
text = format_metric_result("access_rate", metrics)
assert "success rate" in text
return "filter_metrics 裁剪正确,nan 格式化安全"
def test_profile_registry():
"""加机器人只改数据:火蚁有轮与支撑体,四足没有支撑体语义。"""
solenopsis = get_profile("solenopsis")
assert solenopsis.feet_body_patterns == (".*_wheel",)
assert solenopsis.support_body_patterns == ("base_link", ".*_knee")
lite3 = get_profile("lite3")
assert lite3.support_body_patterns == ()
try:
get_profile("nonexistent")
except KeyError:
return "solenopsis / lite3 描述可用,未知名字报错"
raise AssertionError("未知机器人没有报错")
def test_naming_resolves_fire_ant_bodies():
"""火蚁的 link/joint 名字能被描述里的正则解析出来。"""
body_names = [
"base_link",
"fl_knee", "fr_knee", "hl_knee", "hr_knee",
"fl_wheel", "fr_wheel", "hl_wheel", "hr_wheel",
]
resolution = resolve_matching_indices(get_profile("solenopsis").feet_body_patterns, body_names)
assert resolution.names == ["fl_wheel", "fr_wheel", "hl_wheel", "hr_wheel"], resolution.names
assert resolution.indices == [5, 6, 7, 8]
assert not resolution.unmatched_patterns
support = resolve_matching_indices(get_profile("solenopsis").support_body_patterns, body_names)
assert support.names == ["base_link", "fl_knee", "fr_knee", "hl_knee", "hr_knee"], support.names
joint_names = [
"fl_knee_joint", "fr_knee_joint", "hl_knee_joint", "hr_knee_joint",
"fl_wheel_joint", "fr_wheel_joint", "hl_wheel_joint", "hr_wheel_joint",
]
joints = resolve_matching_indices(get_profile("solenopsis").actuated_joint_patterns, joint_names)
assert len(joints) == 8, joints.names
return f"足端 4 个、支撑体 5 个、驱动关节 {len(joints)} 个"
def test_naming_does_not_crash_when_morphology_is_missing():
"""换一个没有轮子的机器人:未命中的正则被记录下来,而不是抛异常。"""
quadruped_bodies = ["base", "FL_hip", "FL_thigh", "FL_shank", "FL_FOOT"]
resolution = resolve_matching_indices(get_profile("solenopsis").feet_body_patterns, quadruped_bodies)
assert len(resolution) == 0
assert resolution.unmatched_patterns == [".*_wheel"]
assert not resolution
# 四足自己的描述能解析
lite3 = resolve_matching_indices(get_profile("lite3").feet_body_patterns, quadruped_bodies)
assert lite3.names == ["FL_FOOT"]
# 空描述合法,得到空结果
empty = resolve_matching_indices(get_profile("lite3").support_body_patterns, quadruped_bodies)
assert len(empty) == 0 and not empty.unmatched_patterns
return "无轮机器人:未命中被记录,不崩溃"
def test_naming_reports_ambiguous_and_bad_regex():
"""一个名字命中多个正则会被记录;正则写错要立刻报错。"""
resolution = resolve_matching_indices((".*_FOOT", "FL_.*"), ["FL_FOOT", "FR_FOOT"])
assert resolution.names == ["FL_FOOT", "FR_FOOT"]
assert resolution.ambiguous_names == [("FL_FOOT", [".*_FOOT", "FL_.*"])], resolution.ambiguous_names
try:
resolve_matching_indices(("[unclosed",), ["anything"])
except ValueError as exc:
assert "无法编译" in str(exc)
return "多重命中被记录,坏正则报错"
raise AssertionError("坏正则没有报错")
def test_aggregate_runs_mean_std_and_nan_handling():
"""多次试验聚合:mean/std 正确,nan 不污染结果,非数值字段单独记录。"""
runs = [
(0, {"success_rate": 0.5, "avg_energy_j": 100.0, "profile": "solenopsis", "avg_cost_of_transport": float("nan")}),
(1, {"success_rate": 1.0, "avg_energy_j": 200.0, "profile": "solenopsis", "avg_cost_of_transport": 0.4}),
(2, {"success_rate": 0.75, "avg_energy_j": 150.0, "profile": "solenopsis", "avg_cost_of_transport": float("nan")}),
]
aggregate = aggregate_runs(runs)
assert aggregate["num_runs"] == 3
assert aggregate["seeds"] == [0, 1, 2]
success = aggregate["summary"]["success_rate"]
assert abs(success["mean"] - 0.75) < 1e-12, success
assert abs(success["std"] - 0.25) < 1e-12, success
assert success["min"] == 0.5 and success["max"] == 1.0
assert success["values"] == [0.5, 1.0, 0.75]
# nan 被剔除,只剩 1 个有效值 → std 记 0
cot = aggregate["summary"]["avg_cost_of_transport"]
assert abs(cot["mean"] - 0.4) < 1e-12, cot
assert cot["std"] == 0.0
assert cot["finite_runs"] == 1
# 非数值字段单独放在 constant 里
assert aggregate["constant"]["profile"] == "solenopsis"
assert "profile_INCONSISTENT" not in aggregate["constant"]
# 全部无效 → mean 是 nan,且记录了有效次数
all_nan = aggregate_runs([(0, {"x": float("nan")}), (1, {"x": float("nan")})])
assert math.isnan(all_nan["summary"]["x"]["mean"])
assert all_nan["summary"]["x"]["finite_runs"] == 0
text = format_aggregate_summary(aggregate)
assert "success_rate" in text and "±" in text
return "mean/std 正确,nan 被剔除,非数值字段单独登记"
def test_aggregate_runs_flags_inconsistent_metadata():
"""两次 run 的口径不一致时要显式暴露,而不是悄悄取第一个。"""
aggregate = aggregate_runs(
[
(0, {"success_rate": 1.0, "energy_convention": "absolute"}),
(1, {"success_rate": 1.0, "energy_convention": "signed"}),
]
)
assert "energy_convention_INCONSISTENT" in aggregate["constant"], aggregate["constant"]
return "口径不一致会显式标记"
# ------------------------------------------------------------------------------- 运行器
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 as exc: # noqa: BLE001 - 测试运行器需要捕获全部异常
failures.append((name, f"{type(exc).__name__}: {exc}"))
print(f"FAIL {name}\n {type(exc).__name__}: {exc}")
else:
suffix = f" — {note}" if note else ""
print(f"ok {name}{suffix}")
print()
if failures:
print(f"{len(failures)}/{len(tests)} 个测试失败")
return 1
print(f"全部 {len(tests)} 个测试通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())