# eval_kit —— 可搬运的机器人性能评价套件 **这份套件与 `scripts/reinforcement_learning/rsl_rl/eval.py` 并存,不导入也不修改它。** 两者的指标名完全一致、取点一致、公式一致,所以输出可以直接互相对照。 ## 为什么要另写一套 `eval.py` 是一个能跑通的评估脚本,但它把三件事混在一起:读仿真环境、算指标、格式化输出。 其中「读环境」必须知道具体机器人长什么样——它写死了 `.*_wheel` 和 `["base_link", ".*_knee"]`。 于是它只能评估火蚁;而且因为 IsaacLab 的 `find_bodies` 对匹配不到的正则会直接抛 `ValueError`,换一个没有轮子的机器人跑会在进循环之前就崩,不是给出一个错的分数。 本套件把三层切开,让不依赖框架的部分可以单独测试、单独搬运: ``` eval_kit/ ├── core/ ★ 零框架依赖(只用 numpy + 标准库),可整块拷到别的项目 │ ├── schema.py │ RobotProfile / EvalProtocol / Trace 三个数据契约 │ ├── metrics.py │ 指标纯函数(唯一算数的地方) │ ├── aggregation.py │ 上限网格、score、跨种子 mean±std │ ├── terrain_spec.py │ 地形等级规格(纯数据) │ ├── report.py │ 格式化与指标筛选 │ └── naming.py │ 容错版的名字/正则解析 ├── isaaclab/ × 依赖 isaaclab,唯一接触仿真内部的地方 │ ├── env_setup.py │ 把协议与地形规格翻译成 env_cfg 改动 │ ├── recorder.py │ 跑 rollout 并采集成 Trace │ └── policy.py │ checkpoint 解析与策略加载 ├── profiles.py ★ 机器人形态描述:加机器人 = 加一条记录 ├── protocols.py ★ 评估协议预设:改口径 = 改这里 └── tests/ ├── test_core.py ★ 合成轨迹测试指标(不需要仿真) └── test_cli.py ★ 命令行参数与口径覆盖自检(不需要仿真) ``` 打 ★ 的是「可套用」的部分:`core/`、`profiles.py`、`protocols.py` 与两个测试文件 可以在没有 Isaac Sim、没有 GPU 的机器上运行。将来要接别的框架(legged_gym、mujoco), 只需要再写一个适配层产出 `Trace`,`core/` 一行都不用改。 ## 用法 ### 1. 激活火蚁项目的虚拟环境 ```bash cd ~/syf/IsaacLab-2.3.2 source env_isaaclab_py311/bin/activate cd ~/syf/huoyi_training # 评估脚本在仓库根目录跑 ``` 验证环境是否正确:`python -c "import isaaclab"` 应当无报错,且 `isaaclab.__file__` 指向 `IsaacLab-2.3.2/source/isaaclab/isaaclab/__init__.py`(可编辑安装)。 注意有两个容易踩空的近邻环境:系统 `python3`(3.10)和 conda 的 `env_rl_training` 都**不能**跑——前者没有 isaaclab/isaacsim,后者有 isaacsim 5.1 但没有 isaaclab。只有 `env_isaaclab_py311` 是完整的。仓库根目录是必须的, 因为 `logs/` 查找用的是相对 cwd 的路径。 ### 2. 让 IsaacLab 的日志目录可写 `/tmp/isaaclab` 属于机器上的其它用户,默认会 `PermissionError`。 `configure_logging` 用的是 `tempfile.gettempdir()/isaaclab/logs`,而 `gettempdir()` 尊重 `TMPDIR`,所以设一下即可(不必去动别人的目录): ```bash export TMPDIR=/home/suyufei/tmp ``` ### 3. 跑评估 两条硬性约束,先看: - **必须传 `--checkpoint`**(绝对路径最省事)。编排进程不启动仿真,因此不加载 hydra,无法按 `--load-run` 用实验名查找权重。 - **每个种子一个子进程**。IsaacLab 在本配置下**一个进程只能创建一个环境**: 第一个环境建好后,`ROUGH_TERRAINS_CFG` 这类模块级单例会被运行时长出自引用, 之后任何 cfg 构造(新实例、开场存的 pristine 深拷贝、重新深拷贝)都会在 `cfg.validate()` 里 RecursionError —— 三种补救方式都实测失败。所以 `eval_suite.py` 的编排进程自己不建环境,而是为每个(阶段, 种子)fork 一个子进程。 代价是每个子进程要重新启动一次 Isaac Sim(约 15 s),所以 `--protocol standard_v1_multiseed`(5 个种子)大约要 5~10 分钟。 ```bash # 标准评估(默认用 solenopsis 描述 + standard_v1 协议) python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_19999.pt \ --headless # 上限评估 + 5 个随机种子 + 存轨迹 python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_19999.pt \ --protocol standard_v1_multiseed --save-trace /tmp/traces --headless # 抗扰评估(每 2.01 s 施加一次水平扰动) python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_19999.pt \ --eval-type robustness --headless # 只看某一种地形的上限 python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_19999.pt \ --eval-type upper_limits --terrain-type stairs_up --headless # 推荐用于机器人对比:放大地砖,消除越界终止项的误判(见下一节) python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_19999.pt \ --protocol standard_v1_clean --headless # 或者直接关掉那个终止项 python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_19999.pt \ --no-terrain-bounds --headless # 冒烟测试(小规模,只为确认流水线通) python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_19999.pt \ --protocol smoke --headless ``` 输出默认写到 `/eval_kit/_<时间戳>.json`——刻意与 `eval.py` 的 `/eval/` 分开,避免两套结果混在一个目录里。 快速自检(不需要仿真): ```bash python3 eval_kit/tests/test_core.py # 24 项:指标、聚合、名字解析、轨迹存取 python3 eval_kit/tests/test_cli.py # 19 项:参数解析、口径覆盖、路径规则、推流开关 ``` ### 4. 用 WebRTC 看画面(可视化) 机器上没有显示器时,用 IsaacLab 的推流模式看评估过程:加 `--livestream` 即可 (这个参数由 `AppLauncher` 提供,`eval_suite.py` 自己没有定义)。 ```bash # 局域网直连(推荐):客户端和服务器在同一网段 python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 \ --checkpoint /绝对路径/model_18200.pt \ --protocol smoke --terrain-type stairs_up \ --livestream 2 --device cuda:6 ``` - `--livestream 2`:局域网/私有网络 WebRTC,客户端直连 `<本机 IP>:49100`。 - `--livestream 1`:公网 WebRTC,走 NVIDIA NVCF 信令,需要 `PUBLIC_IP` 且能出公网。 - 两个值都会让 `AppLauncher` 自动进入 headless(推流由服务端渲染), **不要再传 `--headless`**(传了也无害,只是多余)。 客户端用 NVIDIA 的 **Isaac Sim WebRTC Streaming Client**(随 Isaac Sim 一起提供, Windows/Linux 都有),填入服务器地址即可;端口默认 49100,脚本启动时会把实际 地址打印出来: ``` [eval_kit] WebRTC 推流已启用(--livestream 2,局域网直连) [eval_kit] 客户端连接地址:10.62.25.137:49100 192.168.77.26:49100 ``` 脚本为推流额外做三件事: 1. **视口相机默认跟着被评估的机器人**。`--view-env N` 选第 N 个环境(默认 env 0), 上限评估时会打印这个编号对应哪种地形、哪个难度等级。不设的话推流画面停在 默认相机上,看不到机器人。 想自己控制镜头就加 **`--free-camera`**:改为不跟随任何物体,起始摆在俯视全场的 位置(按实际地形范围算中心与半径),之后在客户端里用鼠标/键盘自由移动 —— Isaac Lab 的 `ViewportCameraController` 只在 `origin_type="asset_root"` 时每帧 改写相机,设成 `world` 就只在开头写一次,不会把你的操作抢回去。同时给了 `--view-env` 时以 `--free-camera` 为准。 2. **开始 rollout 前留出连接时间**:`--pause-s`(秒),推流时默认 30 s, 期间**物理被冻结**、画面照常刷新,在终端按回车可提前开始。不推流时默认 0。 已验证:停顿与否跑出来的指标逐位相同(同一 seed)。 注意这一窗口里机器人是**悬空静止**的:它停在协议规定的起始位姿上,而 `standard_v1_clean` 的 `start_z_offset_m=0.5` 会把它抬高 0.5 m 再落下, 所以「离地没落下、也不动」正是正常现象;`sim.play()` 之后才开始动。 启动时会打印倒计时,别把它当成卡死。 3. **打印客户端连接地址**(从 carb 设置里读实际端口,不是写死的)。 **想看清机器人走路**:`smoke`/`standard_v1` 一类协议一个 episode 只有 2~10 s 仿真时间, 扣掉 30 s 等待后留给你看的时间很短。只为观察的话把 episode 拉长(这会改变 `protocol` 指纹,**别拿这些数字做对比**): ```bash # 只看画面:60 s 的 episode、60 个机器人,混合地形上一直走 python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 --checkpoint /绝对路径/model_18200.pt \ --protocol standard_v1_clean --eval-type velocity_error \ --episode-length-s 60 --num-envs 60 \ --livestream 2 --view-env 0 --pause-s 30 --device cuda:0 ``` **每个(阶段, 种子)是一个独立子进程**,各自开一次推流;子进程退出时画面中断, 下一个阶段会自动重新推流,客户端重连即可。所以 `--protocol standard_v1_multiseed` 这种 8 个种子的协议会看到 8 次「断线—重连」。想连续观察就用单种子的协议 (`standard_v1` / `smoke`),或者按地形单独跑。 **这块机器上跑推流要先挑一张空闲卡**。渲染 + 编码要占显存,而渲染路径(MGPU/ 流编码器)会在 GPU 0 上申请资源,GPU 0 正被别人的训练占满时会刷大量 `Out of GPU memory` / `Buffer creation failed for the device: 0`,严重时 `CUDA error: an illegal memory access` 直接把评估打断。处置: ```bash nvidia-smi --query-gpu=index,memory.used --format=csv # 挑一张空卡 --device cuda:N # 渲染与物理都钉到 N --rendering_mode performance # 可选:低画质换显存与延迟 --kit_args=--/renderer/multiGpu/maxGpuCount=1 # 可选:限制渲染只用一张卡 ``` `--device` 现在同时决定**策略网络**放在哪张卡:以前策略只认 agent 配置里的 `device`(默认 `cuda:0`),在别人占着 0 号卡时会莫名其妙 OOM。 **读数提醒(未解)**:同一 checkpoint、同一协议、同一 seed,**启动方式不同会得到不同的 数值**。实测(`--protocol smoke --eval-type velocity_error --num-envs 64`,同一个 `model_18200.pt`): | 启动方式 | mean_lin_vel_error | mean_yaw_vel_error | 中途早退 | | --- | --- | --- | --- | | `--headless --device cuda:6`(三次独立重跑) | 0.8159 | 0.1480 | 10 | | `CUDA_VISIBLE_DEVICES=6 --headless --device cuda:0` | 0.8507 | 0.3334 | 17 | | `--headless --enable_cameras`(渲染开,无窗口) | 0.8507 | 0.3334 | 17 | | `--livestream 2 --device cuda:6`(推流,暂停 0~30 s 都试过) | 0.8507 | 0.3334 | 17 | 每一组内部都是**逐位可复现**的(同组三次重跑完全一致),跨组则有稳定差异;`--pause-s` 已验证与差异无关(headless 加暂停与不加完全相同;推流下暂停 0 s 与 30 s 也相同)。 两档之间的成因尚未定位(与「是否渲染」和「`--device` 写法/可见卡数」都相关,未能归因到单一因素)。 **在这个问题查清之前:拿数字做对比时固定一种启动方式**,不要把可视化那次的数值与历史 headless 结果混在一个表里报。看画面用推流没有影响,只是它的数字属于另一档。 **画面卡住时先看终端,不要猜**。rollout 期间每 10% 打印一行进度: ``` [eval_kit] 停顿 5.0 s 结束,开始 rollout(画面从这里才动) [eval_kit] rollout 100/1000 步,用时 7.0 s [eval_kit] rollout 200/1000 步,用时 13.6 s ... [eval_kit] 结果已保存:.../upper_limits_2026-10-19_17-16-19.json ``` - **有 `rollout` 打印在涨** → 仿真在正常推进,卡的是画面(渲染/编码侧)。按优先级试: 换到空闲卡(`--device cuda:N`,N 用空闲卡的编号)、 `--rendering_mode performance`、确认客户端没断线、机器人数量少一点(`--num-envs`)。 已知一种具体成因已经默认修掉:客户端改窗口大小/全屏会发 `onCustomResizeMessage`, 服务端默认拒绝并让推流的 `shared swapchain buffer` 进入坏状态,此后画面不再更新 (仿真还在跑)。脚本现在会给推流自动追加 `--/app/livestream/allowResize=true --/app/livestream/allowDynamicResize=true`。 如果日志里仍出现 `onCustomResizeMessage ... must both be positive`,**别缩放客户端窗口**, 并把那段日志发出来。 - **`rollout` 打印不再涨** → 是仿真停了,通常是在客户端 UI 里动了时间轴/仿真控件: 视口下方的时间轴 Play/Pause/Stop 按钮、IsaacLab 面板里的 `Record Animation` (START 之后默认 10 s 结束会**直接关掉应用**)、`Reset Simulation on Stop`。 看画面时别点它们;真被误触了就 Ctrl-C 重跑。 - **进程已经结束**(终端出现 `结果已保存`,或被 Ctrl-C)→ 客户端只会把最后一帧留在屏幕上, 看起来就是「画面定住」,这既不是卡住也没有在算,重跑即可。 - 一轮跑完的标志只有两个:终端出现 `结果已保存:<路径>.json`,以及 `/eval_kit/` 下多出一个 JSON。 ### 已知问题:WebRTC 画面在客户端接上后停止更新 实测到过这个现象:仿真、渲染、相机都正常(`--record-frames` 抓出的帧显示机器人在动、 相机跟着走),**只有推流给客户端的画面不再刷新**。已排除的原因:物理冻结(终端 `rollout` 进度一直在涨)、暂停窗口(停顿结束后仍不动)、客户端改画幅被拒(已默认打开 `allowResize`/`allowDynamicResize`)、时间轴被暂停(那样进度会停)。 推流器默认把日志静音(`app.livestream.logLevel = "silence"`),所以要先让它说话: ```bash --kit_args=--/app/livestream/logLevel=debug # 另外可以试:--kit_args=--/renderer/multiGpu/maxGpuCount=1 # (IsaacLab 自己的 isaaclab.python.rendering.kit 就是这么设的,多卡机器上更保险) ``` 再不行就用 `--record-frames` 那条路径看评估过程(功能上等价,只是不能实时移动相机)。 **推流不可用时的替代路径:抓帧成视频。** `--record-frames DIR` 会在 rollout 期间按 间隔把视口画面存成 PNG,跑完打印合成命令: ```bash CUDA_VISIBLE_DEVICES=7 python scripts/reinforcement_learning/rsl_rl/eval_suite.py \ --task Rough-Deeprobotics-M20-v0 --checkpoint /绝对路径/model_18200.pt \ --protocol standard_v1_clean --eval-type velocity_error \ --episode-length-s 30 --num-envs 16 \ --livestream 2 --pause-s 0 --view-env 0 --device cuda:0 \ --record-frames /tmp/eval_frames --record-every 5 ``` 抓帧依赖视口渲染,所以**必须开着渲染**:推流(`--livestream 2`)或本地 GUI 都行, 纯 headless(`--headless` 且不推流)没有视口,抓不到帧。跑完合成 (`--record-every 5` 时每帧 0.1 s 仿真时间,10 fps 即按仿真速度播放): ```bash cd /tmp/eval_frames && ffmpeg -y -framerate 10 -pattern_type glob -i '*.png' \ -pix_fmt yuv420p -c:v libx264 -vf 'pad=ceil(iw/2)*2:ceil(ih/2)*2' /tmp/eval.mp4 ``` 已实测:600 步抓到 102 张 1280×720 PNG,合出的 mp4 能正常播放,画面里机器人从 起始高度落地并在地形上移动、相机跟着它走 —— 这条路径绕开了推流传输层, 在推流卡住时仍然能看评估过程。 ## 三个对象 - **`RobotProfile`**(`profiles.py`):这个机器人谁是足端、谁是支撑体、哪些关节耗电、多重。 评价代码里没有任何 link/joint 名字或质量常数。 - **`EvalProtocol`**(`protocols.py`):一次评估的全部口径——指令、时长、成功距离、 倾角阈值、加速度阈值、能耗约定、地形规格、种子。决定数值的字段会被哈希成 `protocol_fingerprint` 写进输出 JSON。 - **`Trace`**(`core/schema.py`):一次 rollout 的逐帧记录(机身位姿/速度、关节力矩与速度、 足端位置、支撑接触力与时长)。第 0 帧是动作前的起始状态,之后每步一帧。 流水线是单向的:适配器产 `Trace` → 内核算指标 → 报告层格式化。 `Trace` 可以存成 `.npz`,事后加新指标不用重跑仿真。 ## 加一个机器人 在 `profiles.py` 加一条记录即可,评估代码不动: ```python MY_ROBOT = RobotProfile( name="my_robot", mass_kg=None, # None = 运行时从资产读真实质量 feet_body_patterns=(".*_foot",), support_body_patterns=(), # 没有支撑体语义 → 相关指标返回 nan actuated_joint_patterns=(".*_joint",), support_force_threshold_n=15.0, body_length_m=0.5, ) ``` `test_core.py::test_naming_does_not_crash_when_morphology_is_missing` 就是在验证这件事: 用火蚁的描述去解析一个没有轮子的机器人,得到的是「未命中」记录 + 跳过对应指标,而不是崩溃。 ## 相对 `eval.py` 的三处有意差异 指标名、取点、公式全部保持一致,只有三处刻意不同: | 项 | `eval.py` | 本套件 | 原因 | |---|---|---|---| | COT 的质量 | `--robot_mass` 默认 25.482 | 默认从 `root_physx_view.get_masses()` 读(`Solenopsis.urdf` 实为 26.581 kg),命令行可覆盖 | 旧默认值是更早的 M20 简化模型名义质量,会让 COT 偏低约 4% | | 加速度阈值 | `--acc_threshold` 2.0 m/s²(绝对值) | `acc_threshold_g` 重力倍数(默认仍是 2.0/9.81) | 绝对值对不同量级的机器人不公平 | | 缺失形态的指标 | 返回 0.0 | 返回 `nan`,并在 JSON 里记录 `stability_margin_method="unavailable"` | 0.0 看起来像测出来的值,实际是没测 | 另外新增了两项检查:输出 JSON 里带 `protocol_fingerprint`(防止不同口径的结果被悄悄比较), 以及 `early_resets`(环境在 episode 中途被重置时会警告,因为那会让路程/进度类指标混入两次 rollout)。 ## 口径与 `criterion.py` 的对照(备忘) `criterion.py`(来自 legged_gym 的山猫项目)与 `eval.py` 在 7 处口径上不一致, 移植/对照时要注意。已经核对过的结论: | 项 | `criterion.py` | `eval.py` / 本套件 | |---|---|---| | 能耗积分 | `Σ(τ·q̇)·dt`(有符号) | `Σ\|τ·q̇\|·dt`(绝对值,本套件可切换但默认绝对值) | | COT 质量 | 写死 27.78822 | 自动读取(26.581 kg) | | 倾角阈值 | π/6 ≈ 0.524 rad | 0.70 rad | | 稳定裕度 | ZMP 到足端凸包距离(scipy + shapely) | 机身到足端包围盒距离 | | 成功判定 | env 维护目标区域 | 沿指令方向前进 ≥ success_distance | | 时间效率基准 | 走过半块地形的时间 | 走完 success_distance 的时间 | | 上限 score | 累积成功率加权 | **代数等价,已验算一致** ✓ | 其中「能耗积分」这一条最要紧:有符号口径下负功会抵消正功,同一段运动算出的能耗可以 差一倍以上,所以跨机器人比较时必须在协议里钉死一种。 ## ⚠️ 发现的问题:越界终止项让约 20% 的环境从第 0 步就被重置 这是实跑冒烟测试时发现的,**是 `eval.py` 评估设置里既有的问题,不是本套件引入的**。 IsaacLab 的 `terrain_out_of_bounds` 终止项(`isaaclab_tasks/.../mdp/terminations.py`) 比较的是机器人的**世界坐标**与**整张地图**的范围,而不是机器人自己那块地砖: ```python map_width = n_rows * tile + 2 * border_width x_out_of_bounds = |root_pos_w[:, 0]| > 0.5 * map_width - distance_buffer ``` 在 `eval.py` 的默认评估配置下(地砖 2.0 m、`border_width` 2.0、`distance_buffer` 3.0、 起始位姿 x = −0.9),网格最外侧格子中心在 ±(rows−1)/2 × 2.0 处,机器人起始位置因此 越过阈值。实测数据(8 难度 × 2 地形 × 每格 2 个 = 32 个环境): - 9 行格子中有 2 个整格越界(origin x = −7.0 → 起始 x = −7.9 > 阈值 7.0) - **首步就有 6/32 = 19% 的环境被判越界并重置** - 5×5 网格下同理:最左一整列(20%)从第 0 步起每步都被重置 还有第二个机制:`|y|` 恰好**等于**阈值的那些列(y = ±4.0 对阈值 4.0)处于浮点边界上, 取决于最后一个 bit 是否触发。 两个后果,都影响可比性: 1. 这些环境永远没有机会尝试地形,却全部被记成失败 → 成功率和 score 系统性偏低。 2. 地形行序是**打乱**的(哪一行落在最外侧每次都不同)→ **被清零的是哪个难度等级 跨种子都不稳定**,所以逐等级成功率和 score 连自己跟自己比都不可靠。 ### 三种处置(按推荐顺序) | 方案 | 做法 | 效果 | |---|---|---| | 1 | `--terrain-size 4.0` 或 `--protocol standard_v1_clean` | 保留终止项,消除误判。阈值由 `minimum_terrain_size_for_bounds()` 给出:`tile >= 2*(0.9 + 3.0 − 2.0) = 3.8 m` | | 2 | `--no-terrain-bounds` 或 `--protocol standard_v1_no_bounds` | 直接去掉该终止项。成功判定本就按前进距离算,走出地砖不构成失败 | | 3 | 保持默认(2.0 m 地砖 + 启用终止项) | 与旧 `eval.py` 数值可比,但带着上述偏差;只建议用于对照 | ### 因果验证 同一配置(5×5 网格、10 个环境、8 步、零动作策略),只切换该终止项: | `enforce_terrain_bounds` | 各环境中途重置次数 | 结论 | |---|---|---| | `True`(默认,与 eval.py 一致) | `[7, 1, 0, 0, 0, 0, 0, 7, 7, 0]` | 4/10 环境受污染 | | `False` | `[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]` | 无早退 | 本套件的 `early_resets` 检查就是为发现这类问题加的,输出 JSON 里一直带着它。 ## ⚠️ 发现的问题二、三:地形参数与生成位置 下面两处同样是实跑时发现的,也都继承自 ``eval.py`` 的评估设置。 ### 问题二:机器人在倒金字塔台阶地形里被生成到几何体内部 旧写法把重置高度钉在瓦片中心的 ``env_origin.z`` 上(``pose_range.z = (0, 0)``), 这只对「瓦片中心是最高点」的地形成立。 ``stairs_down`` 用的 ``MeshInvertedPyramidStairsTerrainCfg`` **中心是最低点**, 再叠加 ``platform_width`` 被从 3.0 m 压到 0.4 m(``min(0.4, tile*0.2)``), 起始点 x = −0.9 m 就落在平台之外且低于该处地表 —— 机器人被生成在几何体内部、卡住。 结果是 ``stairs_down`` **在全部 8 个难度上都是 0.00**,连只有 4 cm 台阶的 L1 也是, 看上去像「这机器人不会下台阶」,实际是 harness 的问题。 **修复**:``EvalProtocol.start_z_offset_m``(CLI ``--start-z-offset``)。 实测对照(同一 checkpoint、只跑 ``--terrain-type stairs_down``): | 起始高度 | score | 成功率 | |---|---|---| | 0.0(旧 eval.py) | 0.00 / 8 | 0.05 | | 0.3 m | 3.64 / 8 | 0.47 | | **0.5 m** | **4.53 / 8** | — | 取 0.5 m 是因为嵌入深度随难度递增(L1 约 0.11 m、L8 约 0.50 m),0.5 刚好覆盖最深一级。 ``standard_v1_clean`` 已内置该值;``standard_v1`` 保持 0.0 以维持与旧 eval.py 的可比性。 代价是机器人在平台上方落下会弹一下,``stairs_up`` 略降,但整体是净收益。 ### 问题三:粗糙地形的噪声参数被当成起伏幅度 ``rough_slope`` 用的 ``HfRandomUniformTerrainCfg`` 有两个容易误解的点,IsaacLab 源码里 写得很清楚但很容易忽略: 1. **``difficulty`` 参数被忽略**(docstring 原文:*"The difficulty parameter is ignored for this terrain"*)。所以那一列**没有难度梯度**,8 行是同一分布的重复采样 —— 这也正是它能被看出来的原因:它的成功率不单调(0.12/0.00/0.38/0.25/0.62/0.75/0.12/0.00)。 2. **``noise_range`` 是绝对高度范围,不是起伏幅度**。旧 eval.py 设成 ``(0.05, 0.40)``, 而 ``noise_step`` 又被改成 0.01 —— 在 1 cm 水平网格上放 40 cm 高的随机起伏, 等于一堵堵随机尖刺墙,轮半径只有 0.096 m 的机器人无法通过。 **修复**:``noise_step`` 回到上游的 0.02,``noise_range`` 的上限改为 0.10(与训练地形 ``noise_range=(0.02, 0.10)`` 同量级)。实测: | ``noise_range`` | score | 成功率 | |---|---|---| | (0.05, 0.40)(旧 eval.py)+ noise_step 0.01 | 0.12 / 8 | 0.05 | | (0.02, 0.10) + noise_step 0.02 | **6.38 / 8** | 0.86 | 同时给规格加了 ``isaaclab_extra_params``(写与等级无关的固定字段),并把裸 ``setattr`` 换成带 ``hasattr`` 校验的 ``_set_sub_terrain_field``:字段名写错现在会直接报错, 不会再静默造一个假属性、让地形保持默认难度(那看起来只是「很难」)。 ``rough_slope`` 的 ``unit`` 里也写明了「此地形忽略难度参数,等级排名无意义」, 所以这条限制会随输出 JSON 一起保存。 ### 三处修正的合计效果 同一 checkpoint(``model_19999.pt``、seed 0),只切换协议预设: | 地形 | 旧 eval.py 口径 | ``standard_v1_clean`` | |---|---|---| | stairs_up | 2.88 | 3.03 | | stairs_down | 0.00 | **4.21** | | smooth_slope | 6.50 | 6.50 | | rough_slope | 0.12 | **6.03** | | boxes | 4.28 | 4.60 | | **总成功率** | 0.434 | **0.659** | | **平均前进距离** | 3.59 m | **5.26 m** | | **COT** | 4.50 | **2.45** | | **score 总和** | 13.78 | **24.38** | ## ⚠️ 尚存的未解异常:所有地形的 L8 都是 0.00 修正后在完整评估里观察到:**五个地形在最高难度 L8 上的成功率全部是 0.00**。 对 ``stairs_up`` / ``boxes`` 这类台阶高度确实随难度递增的地形,L8 失败是合理的; 但 ``rough_slope`` 忽略难度参数,L8 的统计分布与 L1–L7 完全相同(那 7 行是 0.75–1.00), 却精确地得到 0.00 —— 这在统计上不可能是能力问题,指向**最后一行的地形原点或生成高度有问题** (最可能是 ``terrain_origins`` 最后一行的 z 或布局异常,使机器人被放在地形之外/之下)。 影响:每个地形的 ``score`` 末项 ``acc[-1] * num_levels`` 因 ``acc[-1] = 0`` 而丢失, score 被低估约 1 分(以 smooth_slope 为例,6.50 → 若 L8 正常约 7.40)。 **这一条尚未定位,也没有修。** 在有明确结论之前,请把矩阵最后一行的数据当作不可用, score 当作保守下界。 ## 验证状态(请如实看待) **已在真机(`IsaacLab-2.3.2/env_isaaclab_py311` + RTX 4090)上实跑验证**: - `eval_kit/isaaclab/` 三个模块已端到端跑通,三步冒烟全部 PASS: - `names` —— 形态解析在真实 body 名上命中:足端 4 个 `['fl_wheel','fr_wheel','hl_wheel','hr_wheel']`、支撑体 5 个 `['base_link','fl_knee',...,'hr_knee']`、驱动关节 8 个,全部与描述文件一致 - `plain` —— 配置 → 采集 → 指标全链路:11 帧 × 4 环境,指标齐出 (`margin=0.1667 m`、`max_tilt=0.051 rad`、`energy=8.0572 J`、`COT=2.1570`) - `upper` —— 上限网格链路:8 个环境按 2 类型 × 2 等级分格,矩阵与 score 正确折叠 - **整机质量从资产读出为 26.581 kg**,与 `docs/solenopsis_urdf_comparison.md` 的记录一致; 这确认了 `eval.py` 的 `--robot_mass` 默认值 25.482 确实偏低约 4%,本套件改为自动读取 是有必要的。 - 越界终止项的偏差已定位、量化并做过因果验证(见上一节)。 - `eval_kit/core/` 的指标用合成轨迹验证,期望值手算:22 项测试通过。 - `eval_suite.py` 的参数解析与口径覆盖用桩替换 `isaaclab.app` 后测试:11 项通过。 **仍未验证**: - 没有用**训练到收敛的** checkpoint 跑过,所以指标数值本身没有和 `eval.py` 做过逐项对照。 现有的 `model_0.pt` 是 iteration 0(基本等于随机权重),跑出来的 `success_rate` 恒为 0、 `time_consumption` 恒为 nan,拿这种退化值对照没有信息量——**这时"通过"是弱证据, "报错"才是强证据**,而报错部分已经全部排除。 - 因此**第一次拿到训练好的 checkpoint 时仍建议做等价性验证**:同一 checkpoint、 同一组参数分别跑 `eval.py` 与 `eval_suite.py --protocol standard_v1`,除「相对 eval.py 的三处有意差异」一节列出的三项外应逐位相同。注意 `eval.py` 目前**无法启动**(见下)。 - `profiles.py` 里的 `lite3` 记录没有在仿真里跑过。 ## ⚠️ `eval.py` 目前无法启动 实跑时顺带发现的:`eval.py` 自己也加了一个 `--verbose`,而 `AppLauncher.add_app_launcher_args()` 会无条件添加同名参数,argparse 因此在**构造 parser 阶段**就抛错,脚本连 `--help` 都跑不出来: ``` argparse.ArgumentError: argument --verbose: conflicting option string: --verbose ``` `--device` 和 `--verbose` 是两次踩到的坑,但只有 `device / headless / enable_cameras / experience / livestream / rendering_mode / xr` 这 7 个会被 AppLauncher 自己的显式检查 拦下并给出可读报错;`--verbose / --info / --cpu` 等其余 7 个由 argparse 拦。 本套件把 14 个名字都记在 `tests/test_cli.py::APPLAUNCHER_ARG_NAMES` 里,并在 `test_parser_compatible_with_app_launcher` 中复刻检查,所以这类冲突不必启动仿真就能发现。 修 `eval.py` 只需把那行 `--verbose` 改名(例如 `--verbose-terrain`)并同步 `args_cli.verbose` 的用法。**我没有改动 `eval.py`**——按你的要求新写一套、不碰原文件。 ## 已知限制 - **一个进程只能建一个环境**(IsaacLab 在本配置下的限制,实测确认)。因此 `eval_suite.py` 用「编排进程 + 每(阶段, 种子)一个子进程」的结构,每个子进程 重新启动一次 Isaac Sim。这不是本套件的设计偏好,是绕不过去的约束。 - **地形规格是唯一真正不可移植的部分。** 规格数值(等级 × 类型 × 绝对高度)是纯数据, 但「把规格翻译成地形生成器」的代码是 IsaacLab 专用(`isaaclab/env_setup.py`)。 换框架需要另写这个 builder。 - **稳定裕度口径较粗。** 当前是「机身到足端轴对齐包围盒的距离」,比 ZMP/凸包版本粗糙, 但不会在退化多边形上失败、也不需要 scipy。算法名记录在输出 JSON 里。 - **重复试验只覆盖随机种子。** `aggregate_runs` 给的是种子间的 mean±std, 不含 checkpoint 之间的方差(同一实验不同迭代的权重差异)——那个要靠多取几个 checkpoint 评估。 - **单次 rollout 的噪声很大。** 比较两个策略/机器人时请用 `--seeds` 给多个种子, 看 mean±std,不要看单次数值。