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.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""评价内核:不依赖 isaaclab / isaacsim / torch / GPU,只依赖 numpy 与标准库。
|
||||
|
||||
这里的模块可以在任何机器上 import 和测试,包括没有仿真环境的机器。
|
||||
"""
|
||||
|
||||
from eval_kit.core.aggregation import aggregate_runs, build_upper_limit_matrix
|
||||
from eval_kit.core.metrics import compute_metrics
|
||||
from eval_kit.core.naming import NameResolution, resolve_matching_indices
|
||||
from eval_kit.core.schema import EvalProtocol, RobotProfile, Trace
|
||||
from eval_kit.core.terrain_spec import TERRAIN_SPECS, get_terrain_spec
|
||||
|
||||
__all__ = [
|
||||
"TERRAIN_SPECS",
|
||||
"EvalProtocol",
|
||||
"NameResolution",
|
||||
"RobotProfile",
|
||||
"Trace",
|
||||
"aggregate_runs",
|
||||
"build_upper_limit_matrix",
|
||||
"compute_metrics",
|
||||
"get_terrain_spec",
|
||||
"resolve_matching_indices",
|
||||
]
|
||||
@@ -0,0 +1,212 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""上限评估的网格汇总:把逐环境结果折叠成「难度等级 × 地形类型」的成功率矩阵。
|
||||
|
||||
纯数据变换,只依赖 numpy,与仿真框架无关。
|
||||
|
||||
``score`` 的公式与 ``criterion.py`` / 旧 ``eval.py`` 完全一致(三者代数等价):
|
||||
对每个地形类型,先算逐级累积成功率 ``acc[i] = Π_{j<=i} rate[j]``,
|
||||
再算 ``score = Σ_i acc[i] * (i+1) * (1 - rate[i+1])``,最后一级的 ``(1 - rate)`` 取 1。
|
||||
含义是「在爬到第 i+1 级之前失败」的累积期望,值越大说明越能爬到高难度。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from eval_kit.core.terrain_spec import TerrainSpec
|
||||
|
||||
|
||||
def accumulated_success(rates: Sequence[float]) -> list[float]:
|
||||
"""逐级累积成功率。"""
|
||||
accumulated: list[float] = []
|
||||
running = 1.0
|
||||
for rate in rates:
|
||||
running *= float(rate)
|
||||
accumulated.append(running)
|
||||
return accumulated
|
||||
|
||||
|
||||
def upper_limit_score(rates: Sequence[float]) -> float:
|
||||
"""由某一地形类型各难度等级的成功率算 score。"""
|
||||
rates = [float(rate) for rate in rates]
|
||||
accumulated = accumulated_success(rates)
|
||||
score = 0.0
|
||||
for index, acc_rate in enumerate(accumulated):
|
||||
next_failure = 1.0 - rates[index + 1] if index + 1 < len(rates) else 1.0
|
||||
score += acc_rate * (index + 1) * next_failure
|
||||
return score
|
||||
|
||||
|
||||
def aggregate_runs(runs: Sequence[tuple[int, dict[str, Any]]]) -> dict[str, Any]:
|
||||
"""把多次(不同随机种子)的指标聚合成 mean/std。
|
||||
|
||||
单次 rollout 的噪声足以淹没两个策略之间的真实差异,所以做对比时应该跑多个
|
||||
种子并看分布,而不是看单个数。
|
||||
|
||||
Args:
|
||||
runs: ``(seed, metrics)`` 列表,``metrics`` 来自
|
||||
:func:`eval_kit.core.metrics.compute_metrics`。
|
||||
"""
|
||||
if not runs:
|
||||
raise ValueError("没有任何一次 run 可以聚合。")
|
||||
|
||||
# 逐环境的私有字段不参与聚合
|
||||
metric_keys: list[str] = []
|
||||
for _, metrics in runs:
|
||||
for key, value in metrics.items():
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
if isinstance(value, (int, float)) and key not in metric_keys:
|
||||
metric_keys.append(key)
|
||||
|
||||
summary: dict[str, Any] = {}
|
||||
for key in metric_keys:
|
||||
values = np.array([float(metrics.get(key, float("nan"))) for _, metrics in runs], dtype=np.float64)
|
||||
finite = values[np.isfinite(values)]
|
||||
if finite.size == 0:
|
||||
summary[key] = {
|
||||
"mean": float("nan"),
|
||||
"std": float("nan"),
|
||||
"min": float("nan"),
|
||||
"max": float("nan"),
|
||||
"values": values.tolist(),
|
||||
"finite_runs": 0,
|
||||
}
|
||||
continue
|
||||
summary[key] = {
|
||||
"mean": float(np.mean(finite)),
|
||||
"std": float(np.std(finite, ddof=1)) if finite.size > 1 else 0.0,
|
||||
"min": float(np.min(finite)),
|
||||
"max": float(np.max(finite)),
|
||||
"values": values.tolist(),
|
||||
"finite_runs": int(finite.size),
|
||||
}
|
||||
|
||||
consistent: dict[str, Any] = {}
|
||||
for key in runs[0][1]:
|
||||
if not key.startswith("_") and isinstance(runs[0][1][key], str):
|
||||
values = {metrics.get(key) for _, metrics in runs}
|
||||
consistent[key] = runs[0][1][key]
|
||||
if len(values) > 1:
|
||||
consistent[f"{key}_INCONSISTENT"] = sorted(str(value) for value in values)
|
||||
|
||||
return {
|
||||
"num_runs": len(runs),
|
||||
"seeds": [int(seed) for seed, _ in runs],
|
||||
"summary": summary,
|
||||
"constant": consistent,
|
||||
"per_run": [{"seed": int(seed), "metrics": metrics} for seed, metrics in runs],
|
||||
}
|
||||
|
||||
|
||||
def build_upper_limit_matrix(
|
||||
per_env: dict[str, list[float]],
|
||||
level_ids: np.ndarray,
|
||||
type_ids: np.ndarray,
|
||||
terrain_types: Sequence[str],
|
||||
spec: TerrainSpec,
|
||||
num_levels: int,
|
||||
) -> dict[str, Any]:
|
||||
"""把逐环境结果汇总成网格。
|
||||
|
||||
Args:
|
||||
per_env: 含 ``success`` / ``max_progress`` / ``max_tilt`` 的逐环境列表,
|
||||
由 :func:`eval_kit.core.metrics.compute_metrics` 在
|
||||
``include_per_env=True`` 时给出。
|
||||
level_ids: ``(N,)`` 每个环境所属的难度等级(0 起)。
|
||||
type_ids: ``(N,)`` 每个环境所属的地形类型序号(对应 ``terrain_types``)。
|
||||
terrain_types: 地形类型名,同时决定矩阵列顺序。
|
||||
spec: 地形规格,用于取每级的难度值。
|
||||
num_levels: 难度等级数(矩阵行数)。
|
||||
"""
|
||||
success = np.asarray(per_env["success"], dtype=np.float64)
|
||||
max_progress = np.asarray(per_env["max_progress"], dtype=np.float64)
|
||||
max_tilt = np.asarray(per_env["max_tilt"], dtype=np.float64)
|
||||
level_ids = np.asarray(level_ids, dtype=np.int64)
|
||||
type_ids = np.asarray(type_ids, dtype=np.int64)
|
||||
|
||||
if not (success.shape == max_progress.shape == max_tilt.shape == level_ids.shape == type_ids.shape):
|
||||
raise ValueError("逐环境数组与网格编号数组长度不一致。")
|
||||
|
||||
per_type: dict[str, Any] = {}
|
||||
for type_index, terrain_type in enumerate(terrain_types):
|
||||
level_values = spec.levels_for(terrain_type, num_levels)
|
||||
rates: list[float] = []
|
||||
level_metrics: list[dict[str, Any]] = []
|
||||
for level_index, level_value in enumerate(level_values):
|
||||
mask = (type_ids == type_index) & (level_ids == level_index)
|
||||
total = int(np.count_nonzero(mask))
|
||||
if total == 0:
|
||||
rates.append(float("nan"))
|
||||
level_metrics.append(
|
||||
{
|
||||
"level": level_index + 1,
|
||||
"difficulty": float(level_value),
|
||||
"success_rate": float("nan"),
|
||||
"success_count": 0.0,
|
||||
"num_envs": 0,
|
||||
"avg_progress_m": float("nan"),
|
||||
"avg_max_tilt_rad": float("nan"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
count = float(np.count_nonzero(success[mask] > 0.5))
|
||||
rates.append(count / total)
|
||||
level_metrics.append(
|
||||
{
|
||||
"level": level_index + 1,
|
||||
"difficulty": float(level_value),
|
||||
"success_rate": count / total,
|
||||
"success_count": count,
|
||||
"num_envs": total,
|
||||
"avg_progress_m": float(np.mean(max_progress[mask])),
|
||||
"avg_max_tilt_rad": float(np.mean(max_tilt[mask])),
|
||||
}
|
||||
)
|
||||
|
||||
per_type[terrain_type] = {
|
||||
"levels": level_metrics,
|
||||
"success_rate": rates,
|
||||
"success_rate_accumulated": accumulated_success(rates),
|
||||
"score": upper_limit_score(rates),
|
||||
"max_level": len(level_values),
|
||||
"difficulty_unit": spec.unit.get(terrain_type, ""),
|
||||
}
|
||||
|
||||
# 打成便于打印与比较的矩阵:行 = 难度等级,列 = 地形类型
|
||||
success_matrix: list[list[float]] = []
|
||||
accumulated_matrix: list[list[float]] = []
|
||||
difficulty_matrix: list[list[float]] = []
|
||||
for level_index in range(num_levels):
|
||||
success_row: list[float] = []
|
||||
accumulated_row: list[float] = []
|
||||
difficulty_row: list[float] = []
|
||||
for terrain_type in terrain_types:
|
||||
entry = per_type[terrain_type]
|
||||
rates = entry["success_rate"]
|
||||
accumulated = entry["success_rate_accumulated"]
|
||||
levels = entry["levels"]
|
||||
success_row.append(rates[level_index] if level_index < len(rates) else float("nan"))
|
||||
accumulated_row.append(accumulated[level_index] if level_index < len(accumulated) else float("nan"))
|
||||
difficulty_row.append(levels[level_index]["difficulty"] if level_index < len(levels) else float("nan"))
|
||||
success_matrix.append(success_row)
|
||||
accumulated_matrix.append(accumulated_row)
|
||||
difficulty_matrix.append(difficulty_row)
|
||||
|
||||
return {
|
||||
"benchmark": spec.name,
|
||||
"terrain_types": list(terrain_types),
|
||||
"num_levels": num_levels,
|
||||
"difficulty_unit": {terrain_type: spec.unit.get(terrain_type, "") for terrain_type in terrain_types},
|
||||
"per_type": per_type,
|
||||
"scores": {terrain_type: per_type[terrain_type]["score"] for terrain_type in terrain_types},
|
||||
"matrix": {
|
||||
"success_rate": success_matrix,
|
||||
"success_rate_accumulated": accumulated_matrix,
|
||||
"difficulty": difficulty_matrix,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""指标计算:纯函数,只依赖 numpy。
|
||||
|
||||
输入是 :class:`~eval_kit.core.schema.Trace`、:class:`~eval_kit.core.schema.RobotProfile`
|
||||
和 :class:`~eval_kit.core.schema.EvalProtocol`,输出是一个扁平的指标字典。
|
||||
|
||||
指标口径与 ``scripts/reinforcement_learning/rsl_rl/eval.py`` 保持一致(同样的取点、
|
||||
同样的公式),因此默认协议下两套评估的数值可以直接对照;差别只在三处,且都是
|
||||
有意为之:
|
||||
|
||||
1. **质量**不再写死在命令行默认值里,而是从 ``Trace.mass_kg`` 取(适配层从资产读),
|
||||
传入的 ``profile.mass_kg`` 只作兜底。
|
||||
2. **加速度阈值**以重力倍数表达,换算成 m/s² 后参与计算,使阈值可跨量级机器人复用。
|
||||
3. **缺失的形态**返回 ``nan`` 而不是 0。旧实现里没有足端的机器人会得到
|
||||
「支撑裕度 = 0.00」,看起来像测出来的值,实际是没测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from eval_kit.core.schema import EvalProtocol, RobotProfile, Trace
|
||||
|
||||
#: 全部帧,用于成功判定与最大进度(含起始帧,与旧实现取点一致)。
|
||||
_ALL_FRAMES = slice(None)
|
||||
#: 动作执行之后的帧,用于逐帧累积类指标。
|
||||
_STEP_FRAMES = slice(1, None)
|
||||
|
||||
_GRAVITY = 9.81
|
||||
|
||||
|
||||
def tilt_from_projected_gravity(projected_gravity_b: np.ndarray) -> np.ndarray:
|
||||
"""由机体系投影重力算倾角 [rad]。
|
||||
|
||||
``projected_gravity_b`` 为 ``(T, N, 3)``,返回 ``(T, N)``。
|
||||
"""
|
||||
z_axis = np.clip(-projected_gravity_b[..., 2], -1.0, 1.0)
|
||||
return np.arccos(z_axis)
|
||||
|
||||
|
||||
def support_margin_xy(root_xy: np.ndarray, foot_xy: np.ndarray) -> np.ndarray:
|
||||
"""机身投影点到足端轴对齐包围盒的距离 [m],在盒内为正、盒外为负。
|
||||
|
||||
``root_xy`` 为 ``(N, 2)``,``foot_xy`` 为 ``(N, F, 2)``,返回 ``(N,)``。
|
||||
|
||||
这是当前实现采用的口径(与旧 ``eval.py`` 相同),记录在
|
||||
``EvalProtocol.stability_margin_method`` 里。它比凸包/ZMP 版本粗糙,
|
||||
但不会在退化多边形上失败,也不需要 scipy。
|
||||
"""
|
||||
min_xy = np.min(foot_xy, axis=1)
|
||||
max_xy = np.max(foot_xy, axis=1)
|
||||
|
||||
inside_x = (root_xy[:, 0] >= min_xy[:, 0]) & (root_xy[:, 0] <= max_xy[:, 0])
|
||||
inside_y = (root_xy[:, 1] >= min_xy[:, 1]) & (root_xy[:, 1] <= max_xy[:, 1])
|
||||
inside = inside_x & inside_y
|
||||
|
||||
inside_dist = np.minimum(
|
||||
np.minimum(root_xy[:, 0] - min_xy[:, 0], max_xy[:, 0] - root_xy[:, 0]),
|
||||
np.minimum(root_xy[:, 1] - min_xy[:, 1], max_xy[:, 1] - root_xy[:, 1]),
|
||||
)
|
||||
outside_dx = np.clip(np.maximum(min_xy[:, 0] - root_xy[:, 0], root_xy[:, 0] - max_xy[:, 0]), 0.0, None)
|
||||
outside_dy = np.clip(np.maximum(min_xy[:, 1] - root_xy[:, 1], root_xy[:, 1] - max_xy[:, 1]), 0.0, None)
|
||||
outside_dist = np.sqrt(outside_dx**2 + outside_dy**2)
|
||||
|
||||
return np.where(inside, inside_dist, -outside_dist)
|
||||
|
||||
|
||||
def _nanmean_or_nan(values: np.ndarray) -> float:
|
||||
"""均值;空数组返回 nan(而不是 0,也不是 numpy 的警告)。"""
|
||||
if values.size == 0:
|
||||
return float("nan")
|
||||
return float(np.mean(values))
|
||||
|
||||
|
||||
def compute_metrics(
|
||||
trace: Trace,
|
||||
profile: RobotProfile,
|
||||
protocol: EvalProtocol,
|
||||
*,
|
||||
include_per_env: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""由一次 rollout 的轨迹算出全部指标。
|
||||
|
||||
指标名的命名与旧 ``eval.py`` 完全一致,便于新旧结果直接对照。
|
||||
"""
|
||||
num_steps = trace.num_steps
|
||||
num_envs = trace.num_envs
|
||||
dt = float(trace.dt)
|
||||
|
||||
if abs(protocol.dt - dt) > 0.01 * dt:
|
||||
raise ValueError(
|
||||
f"协议 dt={protocol.dt} 与轨迹 dt={dt} 不一致。"
|
||||
"通常意味着协议里的步长与任务的 decimation 不匹配。"
|
||||
)
|
||||
|
||||
command = np.asarray(trace.command, dtype=np.float64)
|
||||
command_xy = command[:2]
|
||||
command_yaw = float(command[2])
|
||||
|
||||
command_dir = command_xy.copy()
|
||||
norm = float(np.linalg.norm(command_dir))
|
||||
command_dir = command_dir / norm if norm > 1e-6 else np.array([1.0, 0.0])
|
||||
|
||||
# ------------------------------------------------------------------ 成功判定
|
||||
base_xy = np.asarray(trace.base_pos_w, dtype=np.float64)[..., :2]
|
||||
delta_xy = base_xy - base_xy[0][None, :, :]
|
||||
progress = np.einsum("tnc,c->tn", delta_xy[_ALL_FRAMES], command_dir)
|
||||
max_progress = progress.max(axis=0)
|
||||
reached = max_progress >= protocol.success_distance_m
|
||||
|
||||
tilt = tilt_from_projected_gravity(np.asarray(trace.projected_gravity_b, dtype=np.float64))
|
||||
tilt_steps = tilt[_STEP_FRAMES]
|
||||
max_tilt = tilt_steps.max(axis=0)
|
||||
ever_fallen = max_tilt > protocol.tilt_threshold_rad
|
||||
|
||||
success = reached & ~ever_fallen
|
||||
success_count = int(np.count_nonzero(success))
|
||||
|
||||
# 首次达成成功的时刻:第 i 帧(i>=1)达成则记 i*dt。
|
||||
crossed = progress[_STEP_FRAMES] >= protocol.success_distance_m
|
||||
complete_time = np.full((num_envs,), np.nan, dtype=np.float64)
|
||||
for env_index in range(num_envs):
|
||||
hit = np.flatnonzero(crossed[:, env_index])
|
||||
if hit.size:
|
||||
complete_time[env_index] = float(hit[0] + 1) * dt
|
||||
|
||||
successful_time = complete_time[success & np.isfinite(complete_time)]
|
||||
avg_time = _nanmean_or_nan(successful_time)
|
||||
|
||||
# ------------------------------------------------------------- 速度跟踪误差
|
||||
lin_vel_b = np.asarray(trace.base_lin_vel_b, dtype=np.float64)[_STEP_FRAMES]
|
||||
ang_vel_b = np.asarray(trace.base_ang_vel_b, dtype=np.float64)[_STEP_FRAMES]
|
||||
lin_error = np.linalg.norm(lin_vel_b[..., :2] - command_xy[None, None, :], axis=-1)
|
||||
yaw_error = np.abs(ang_vel_b[..., 2] - command_yaw)
|
||||
|
||||
# -------------------------------------------------------------- 能耗与路程
|
||||
joint_torque = np.asarray(trace.joint_torque, dtype=np.float64)[_STEP_FRAMES]
|
||||
joint_vel = np.asarray(trace.joint_vel, dtype=np.float64)[_STEP_FRAMES]
|
||||
mechanical_power = joint_torque * joint_vel
|
||||
if protocol.energy_convention == "absolute":
|
||||
integrand = np.abs(mechanical_power)
|
||||
elif protocol.energy_convention == "signed":
|
||||
integrand = mechanical_power
|
||||
else:
|
||||
raise ValueError(
|
||||
f"未知能耗约定 {protocol.energy_convention!r};可选 'absolute' 或 'signed'。"
|
||||
)
|
||||
energy = integrand.sum(axis=(0, 2)) * dt
|
||||
|
||||
lin_vel_w = np.asarray(trace.base_lin_vel_w, dtype=np.float64)
|
||||
route = np.linalg.norm(lin_vel_w[_STEP_FRAMES][..., :2], axis=-1).sum(axis=0) * dt
|
||||
|
||||
# ------------------------------------------------------------ 运动不稳定性
|
||||
accel = np.linalg.norm(np.diff(lin_vel_w, axis=0), axis=-1) / dt
|
||||
instability_count = (accel > protocol.acc_threshold_mps2).sum(axis=0)
|
||||
|
||||
stable_time = (tilt_steps < protocol.tilt_threshold_rad).sum(axis=0) * dt
|
||||
|
||||
# ------------------------------------------------------------- 支撑多边形
|
||||
feet_pos_w = np.asarray(trace.feet_pos_w, dtype=np.float64)[_STEP_FRAMES]
|
||||
num_feet = int(feet_pos_w.shape[2])
|
||||
if num_feet > 0:
|
||||
margins = support_margin_xy(base_xy[_STEP_FRAMES][:, :, :].reshape(-1, 2), feet_pos_w.reshape(-1, num_feet, 2))
|
||||
margins = margins.reshape(feet_pos_w.shape[0], num_envs)
|
||||
avg_margin = _nanmean_or_nan(margins)
|
||||
margin_method = protocol.stability_margin_method
|
||||
else:
|
||||
avg_margin = float("nan")
|
||||
margin_method = "unavailable"
|
||||
|
||||
# --------------------------------------------------------------- 支撑接触
|
||||
support_force = np.asarray(trace.support_force_n, dtype=np.float64)
|
||||
support_contact_time = np.asarray(trace.support_contact_time_s, dtype=np.float64)
|
||||
num_support_bodies = int(support_force.shape[2])
|
||||
support_threshold = (
|
||||
profile.support_force_threshold_n
|
||||
if protocol.support_force_threshold_n is None
|
||||
else protocol.support_force_threshold_n
|
||||
)
|
||||
if num_support_bodies > 0:
|
||||
active = np.any(support_force[_STEP_FRAMES] > support_threshold, axis=2) # (T-1, N)
|
||||
support_steps = active.sum(axis=0)
|
||||
# 起始帧之前视为「未接触」,因此首帧接触也算一次事件(与旧实现一致)。
|
||||
active_padded = np.concatenate([np.zeros((1, num_envs), dtype=bool), active], axis=0)
|
||||
support_events = (active_padded[1:] & ~active_padded[:-1]).sum(axis=0)
|
||||
max_support_time = support_contact_time[_STEP_FRAMES].max(axis=(0, 2))
|
||||
support_contact_ratio = _nanmean_or_nan(support_steps / max(num_steps, 1))
|
||||
avg_support_events = float(np.mean(support_events))
|
||||
avg_max_support_time = float(np.mean(max_support_time))
|
||||
if success_count > 0:
|
||||
success_support_ratio = float(np.mean((support_events[success] > 0).astype(np.float64)))
|
||||
else:
|
||||
success_support_ratio = float("nan")
|
||||
else:
|
||||
support_contact_ratio = float("nan")
|
||||
avg_support_events = float("nan")
|
||||
avg_max_support_time = float("nan")
|
||||
success_support_ratio = float("nan")
|
||||
|
||||
# --------------------------------------------------------- 质量与 COT
|
||||
mass = trace.mass_kg if trace.mass_kg is not None else profile.mass_kg
|
||||
if mass is None or mass <= 0.0:
|
||||
cost_of_transport = np.full((num_envs,), np.nan)
|
||||
mass_used = float("nan")
|
||||
else:
|
||||
cost_of_transport = energy / ((route + 1e-6) * mass * _GRAVITY)
|
||||
mass_used = float(mass)
|
||||
|
||||
metrics: dict[str, Any] = {
|
||||
"profile": profile.name,
|
||||
"protocol_fingerprint": protocol.fingerprint(),
|
||||
"num_envs": float(num_envs),
|
||||
"num_steps": float(num_steps),
|
||||
"episode_length_s": float(num_steps * dt),
|
||||
"terrain_size_m": float(protocol.terrain_size_m),
|
||||
"success_distance_m": float(protocol.success_distance_m),
|
||||
"robot_mass_kg": mass_used,
|
||||
"energy_convention": protocol.energy_convention,
|
||||
"stability_margin_method": margin_method,
|
||||
"tilt_threshold_rad": float(protocol.tilt_threshold_rad),
|
||||
"acc_threshold_mps2": float(protocol.acc_threshold_mps2),
|
||||
"support_force_threshold_n": float(support_threshold) if num_support_bodies > 0 else float("nan"),
|
||||
# --- 成功率与进度
|
||||
"success_count": float(success_count),
|
||||
"success_rate": float(success_count / max(num_envs, 1)),
|
||||
"avg_final_progress_m": float(np.mean(max_progress)),
|
||||
# --- 时间
|
||||
"avg_time_consumption_s": avg_time,
|
||||
"time_efficiency": float(
|
||||
avg_time / (protocol.success_distance_m / max(abs(float(command[0])), 1e-6))
|
||||
)
|
||||
if np.isfinite(avg_time)
|
||||
else float("nan"),
|
||||
# --- 跟踪误差
|
||||
"mean_lin_vel_error": float(np.mean(lin_error)),
|
||||
"mean_yaw_vel_error": float(np.mean(yaw_error)),
|
||||
# --- 能耗
|
||||
"avg_energy_j": float(np.mean(energy)),
|
||||
"avg_cost_of_transport": _nanmean_or_nan(cost_of_transport),
|
||||
# --- 稳定性
|
||||
"unstability_rate": float(np.mean(instability_count / max(num_steps, 1))),
|
||||
"avg_stability_margin_m": avg_margin,
|
||||
"avg_max_tilt_rad": float(np.mean(max_tilt)),
|
||||
"avg_stable_time_s": float(np.mean(stable_time)),
|
||||
# --- 支撑接触
|
||||
"avg_support_contact_ratio": support_contact_ratio,
|
||||
"avg_support_events": avg_support_events,
|
||||
"avg_max_support_contact_time_s": avg_max_support_time,
|
||||
"successful_runs_with_support_ratio": success_support_ratio,
|
||||
}
|
||||
|
||||
if include_per_env:
|
||||
metrics["_per_env"] = {
|
||||
"success": success.astype(float).tolist(),
|
||||
"max_progress": max_progress.tolist(),
|
||||
"max_tilt": max_tilt.tolist(),
|
||||
}
|
||||
return metrics
|
||||
@@ -0,0 +1,94 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""按正则列表解析名字,与 IsaacLab 的 ``resolve_matching_names`` 语义一致,
|
||||
但**匹配不到不抛异常**。
|
||||
|
||||
IsaacLab 的实现用 ``re.fullmatch`` 逐个比对,并且:某个名字同时命中多个正则会报错,
|
||||
某个正则一个都没命中也报错。对环境配置来说这种严格是有用的(写错了应该立刻发现),
|
||||
但评价代码不该因为「这个机器人没有轮子」就崩掉——那时正确行为是跳过依赖轮子的指标。
|
||||
|
||||
这个模块是纯逻辑,不依赖 isaaclab,因此可以单独测试。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
class NameResolution:
|
||||
"""一次名字解析的结果。"""
|
||||
|
||||
__slots__ = ("indices", "names", "unmatched_patterns", "ambiguous_names")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
indices: list[int],
|
||||
names: list[str],
|
||||
unmatched_patterns: list[str],
|
||||
ambiguous_names: list[tuple[str, list[str]]],
|
||||
) -> None:
|
||||
self.indices = indices
|
||||
self.names = names
|
||||
#: 一个都没命中的正则。
|
||||
self.unmatched_patterns = unmatched_patterns
|
||||
#: 同时命中多个正则的名字,以及命中的正则。
|
||||
self.ambiguous_names = ambiguous_names
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.indices)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.indices)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"NameResolution({len(self.indices)} 项, "
|
||||
f"未命中={self.unmatched_patterns}, 多重命中={self.ambiguous_names})"
|
||||
)
|
||||
|
||||
|
||||
def resolve_matching_indices(patterns: Sequence[str], names: Sequence[str]) -> NameResolution:
|
||||
"""把正则列表解析成 ``names`` 中的下标。
|
||||
|
||||
行为与 IsaacLab 的 ``resolve_matching_names`` 一致之处:用 ``re.fullmatch``;
|
||||
返回顺序遵循 ``patterns`` 的顺序。
|
||||
不同之处:不抛异常,而是把「未命中」和「多重命中」记在返回值里,由调用方决定
|
||||
是警告还是忽略。
|
||||
|
||||
Args:
|
||||
patterns: 正则列表,例如 ``("base_link", ".*_knee")``。
|
||||
names: 候选名字,例如 ``articulation.body_names``。
|
||||
"""
|
||||
hits: dict[int, list[str]] = {}
|
||||
pattern_hits: dict[str, int] = {pattern: 0 for pattern in patterns}
|
||||
|
||||
for index, name in enumerate(names):
|
||||
for pattern in patterns:
|
||||
try:
|
||||
matched = re.fullmatch(pattern, name) is not None
|
||||
except re.error as exc: # 正则本身写错,属于配置错误,应当立刻暴露
|
||||
raise ValueError(f"机器人描述里的正则 {pattern!r} 无法编译:{exc}") from exc
|
||||
if matched:
|
||||
hits.setdefault(index, []).append(pattern)
|
||||
pattern_hits[pattern] += 1
|
||||
|
||||
indices: list[int] = []
|
||||
resolved_names: list[str] = []
|
||||
ambiguous: list[tuple[str, list[str]]] = []
|
||||
taken: set[int] = set()
|
||||
|
||||
# 按 patterns 顺序输出,跳过已被更早的正则取走的下标
|
||||
for pattern in patterns:
|
||||
for index, matched_patterns in hits.items():
|
||||
if pattern not in matched_patterns or index in taken:
|
||||
continue
|
||||
taken.add(index)
|
||||
indices.append(index)
|
||||
resolved_names.append(names[index])
|
||||
if len(matched_patterns) > 1:
|
||||
ambiguous.append((names[index], list(matched_patterns)))
|
||||
|
||||
unmatched = [pattern for pattern, count in pattern_hits.items() if count == 0]
|
||||
return NameResolution(indices, resolved_names, unmatched, ambiguous)
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""结果的格式化与筛选:纯函数,只依赖 numpy 与标准库。
|
||||
|
||||
输出格式与旧 ``eval.py`` 保持一致,便于对照。``filter_metrics`` 决定每种
|
||||
``eval_type`` 关心哪些指标。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
#: 支持的评估类型。
|
||||
EVAL_TYPES: tuple[str, ...] = (
|
||||
"all",
|
||||
"upper_limits",
|
||||
"velocity_error",
|
||||
"access_rate",
|
||||
"time_consumption",
|
||||
"energy_consumption",
|
||||
"motion_stability",
|
||||
"stability_margin",
|
||||
"robustness",
|
||||
)
|
||||
|
||||
#: 每种评估类型关心哪些指标。
|
||||
METRIC_SELECTION: dict[str, tuple[str, ...]] = {
|
||||
"velocity_error": ("mean_lin_vel_error", "mean_yaw_vel_error"),
|
||||
"access_rate": ("success_count", "success_rate", "avg_final_progress_m"),
|
||||
"time_consumption": ("success_count", "avg_time_consumption_s", "time_efficiency"),
|
||||
"energy_consumption": ("avg_energy_j", "avg_cost_of_transport"),
|
||||
"motion_stability": ("unstability_rate",),
|
||||
"stability_margin": ("avg_stability_margin_m",),
|
||||
"robustness": ("success_rate", "avg_max_tilt_rad", "avg_stable_time_s"),
|
||||
}
|
||||
|
||||
|
||||
def filter_metrics(metrics: dict[str, Any], eval_type: str) -> dict[str, Any]:
|
||||
"""按评估类型裁剪指标字典;``all`` 返回全部。"""
|
||||
if eval_type == "all":
|
||||
return metrics
|
||||
if eval_type not in METRIC_SELECTION:
|
||||
raise KeyError(f"未知评估类型 {eval_type!r};可选:{sorted(EVAL_TYPES)}")
|
||||
return {key: metrics[key] for key in METRIC_SELECTION[eval_type] if key in metrics}
|
||||
|
||||
|
||||
def _format_cell(value: float, width: int = 14) -> str:
|
||||
if value is None or not np.isfinite(value):
|
||||
return " -".rjust(width)
|
||||
return f"{value:.2f}".rjust(width)
|
||||
|
||||
|
||||
def format_matrix(matrix: Sequence[Sequence[float]], terrain_types: Sequence[str], *, title: str) -> str:
|
||||
"""把「行=等级、列=地形」的矩阵格式化成字符串。"""
|
||||
width = 14
|
||||
header = "level".ljust(8) + "".join(terrain_type[: width - 1].rjust(width) for terrain_type in terrain_types)
|
||||
lines = [title, header]
|
||||
for level_index, row in enumerate(matrix, start=1):
|
||||
cells = "".join(_format_cell(float(value), width) for value in row)
|
||||
lines.append(f"{level_index:<8}{cells}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_numpy_matrix(matrix: Sequence[Sequence[float]], *, title: str) -> str:
|
||||
"""紧凑的 numpy 风格矩阵,便于贴进报告;nan 显示成 ``-``。
|
||||
|
||||
用 ``formatter`` 而不是 ``nanstr``——后者是 ``np.set_printoptions`` 的参数,
|
||||
不是 ``np.array2string`` 的关键字(numpy 1.21 与 1.26 都会对前者报 TypeError)。
|
||||
"""
|
||||
array = np.round(np.array(matrix, dtype=np.float32), 2)
|
||||
|
||||
def _cell(value: float) -> str:
|
||||
return " -" if not np.isfinite(value) else f"{value:.2f}"
|
||||
|
||||
body = np.array2string(array, formatter={"float_kind": _cell})
|
||||
return f"{title}:\n{body}"
|
||||
|
||||
|
||||
def format_upper_limit_summary(result: dict[str, Any]) -> str:
|
||||
"""上限评估的汇总输出:成功率矩阵、累积成功率矩阵、各类型 score。"""
|
||||
terrain_types = result["terrain_types"]
|
||||
matrix = result["matrix"]
|
||||
scores = np.array([result["scores"][terrain_type] for terrain_type in terrain_types], dtype=np.float32)
|
||||
lines = [
|
||||
format_numpy_matrix(matrix["success_rate"], title="success rate"),
|
||||
format_numpy_matrix(matrix["success_rate_accumulated"], title="success rate accumulated"),
|
||||
f"score: {np.array2string(scores, precision=2, suppress_small=True)} of {result['num_levels']}",
|
||||
]
|
||||
units = {unit for unit in result.get("difficulty_unit", {}).values() if unit}
|
||||
if units:
|
||||
lines.append(f"difficulty units: {'; '.join(sorted(units))}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_rollout_summary(metrics: dict[str, Any]) -> str:
|
||||
"""混合地形汇总跑的全指标输出。"""
|
||||
|
||||
def fmt(key: str, spec: str = "{:.2f}") -> str:
|
||||
value = float(metrics.get(key, float("nan")))
|
||||
return "nan" if not np.isfinite(value) else spec.format(value)
|
||||
|
||||
success_ratio = float(metrics.get("successful_runs_with_support_ratio", float("nan")))
|
||||
success_ratio_text = "nan" if not np.isfinite(success_ratio) else f"{success_ratio * 100:.1f}%"
|
||||
support_ratio = float(metrics.get("avg_support_contact_ratio", float("nan")))
|
||||
support_ratio_text = "nan" if not np.isfinite(support_ratio) else f"{support_ratio * 100:.1f}%"
|
||||
mass = float(metrics.get("robot_mass_kg", float("nan")))
|
||||
mass_text = "nan" if not np.isfinite(mass) else f"{mass:.3f}"
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"success rate: {fmt('success_rate')}",
|
||||
f"success count: {int(round(float(metrics.get('success_count', 0.0))))} "
|
||||
f"time consumption: {fmt('avg_time_consumption_s')}",
|
||||
f"time efficiency: {fmt('time_efficiency')}",
|
||||
f"mean lin vel error: {fmt('mean_lin_vel_error')} "
|
||||
f"mean yaw vel error: {fmt('mean_yaw_vel_error')}",
|
||||
f"average energy: {fmt('avg_energy_j')} J "
|
||||
f"average cost of transport: {fmt('avg_cost_of_transport')}",
|
||||
f"unstability rate: {fmt('unstability_rate', '{:.3f}')} "
|
||||
f"average stability margin: {fmt('avg_stability_margin_m')}",
|
||||
f"average max tilt: {fmt('avg_max_tilt_rad')} rad "
|
||||
f"average stable time: {fmt('avg_stable_time_s')} s",
|
||||
f"support contact ratio: {support_ratio_text} "
|
||||
f"events/episode: {fmt('avg_support_events')} "
|
||||
f"max duration: {fmt('avg_max_support_contact_time_s')} s "
|
||||
f"successful runs using support: {success_ratio_text}",
|
||||
f"[protocol] mass={mass_text} kg energy={metrics.get('energy_convention')} "
|
||||
f"margin={metrics.get('stability_margin_method')} "
|
||||
f"tilt>={fmt('tilt_threshold_rad')} acc>={fmt('acc_threshold_mps2')} "
|
||||
f"fingerprint={metrics.get('protocol_fingerprint')}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def format_aggregate_summary(aggregate: dict[str, Any], keys: Sequence[str] | None = None) -> str:
|
||||
"""多次试验的 ``mean ± std`` 汇总。
|
||||
|
||||
做机器人对比时应看这个而不是单次数值。
|
||||
"""
|
||||
summary = aggregate.get("summary", {})
|
||||
selected = list(keys) if keys is not None else list(summary.keys())
|
||||
lines = [f"runs = {aggregate.get('num_runs')} seeds = {aggregate.get('seeds')}"]
|
||||
for key in selected:
|
||||
entry = summary.get(key)
|
||||
if entry is None:
|
||||
continue
|
||||
mean = float(entry["mean"])
|
||||
std = float(entry["std"])
|
||||
if not np.isfinite(mean):
|
||||
lines.append(f" {key:<38} nan({entry.get('finite_runs', 0)}/{aggregate.get('num_runs')} 次有效)")
|
||||
else:
|
||||
lines.append(f" {key:<38} {mean:>10.4f} ± {std:<8.4f} [{entry['min']:.4f}, {entry['max']:.4f}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_metric_result(eval_type: str, metrics: dict[str, Any]) -> str:
|
||||
"""单一评估类型的精简输出。"""
|
||||
|
||||
def fmt(key: str) -> str:
|
||||
value = float(metrics.get(key, float("nan")))
|
||||
return "nan" if not np.isfinite(value) else f"{value:.2f}"
|
||||
|
||||
if eval_type == "velocity_error":
|
||||
return f"mean lin vel error: {fmt('mean_lin_vel_error')}\nmean yaw vel error: {fmt('mean_yaw_vel_error')}"
|
||||
if eval_type == "access_rate":
|
||||
return f"success rate: {fmt('success_rate')}\navg final progress: {fmt('avg_final_progress_m')} m"
|
||||
if eval_type == "time_consumption":
|
||||
return (
|
||||
f"success count: {int(round(float(metrics.get('success_count', 0.0))))} "
|
||||
f"time consumption: {fmt('avg_time_consumption_s')}\n"
|
||||
f"time efficiency: {fmt('time_efficiency')}"
|
||||
)
|
||||
if eval_type == "energy_consumption":
|
||||
return f"average energy: {fmt('avg_energy_j')} J\naverage cost of transport: {fmt('avg_cost_of_transport')}"
|
||||
if eval_type == "motion_stability":
|
||||
return f"unstability rate: {fmt('unstability_rate')}"
|
||||
if eval_type == "stability_margin":
|
||||
return (
|
||||
f"average stability margin: {fmt('avg_stability_margin_m')} m "
|
||||
f"(method: {metrics.get('stability_margin_method')})"
|
||||
)
|
||||
if eval_type == "robustness":
|
||||
return (
|
||||
f"success rate: {fmt('success_rate')}\n"
|
||||
f"average max tilt: {fmt('avg_max_tilt_rad')} rad\n"
|
||||
f"average stable time: {fmt('avg_stable_time_s')} s"
|
||||
)
|
||||
return format_rollout_summary(metrics)
|
||||
@@ -0,0 +1,392 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""评价套件的数据契约。
|
||||
|
||||
三个对象构成整条流水线:
|
||||
|
||||
``RobotProfile`` 机器人形态(谁是足端、谁是支撑体、质量多少)
|
||||
``EvalProtocol`` 评估协议(指令、时长、各种阈值、能耗约定)
|
||||
``Trace`` 一次 rollout 的标准化逐帧记录
|
||||
|
||||
上游(任何框架的适配器)负责产出 ``Trace``,下游(:mod:`eval_kit.core.metrics`)
|
||||
只从 ``Trace`` 计算指标。指标代码因此不接触任何仿真对象。
|
||||
|
||||
这个模块只依赖 numpy 与标准库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
#: 帧索引约定:``Trace`` 的第 0 帧是执行动作之前的起始状态,
|
||||
#: 第 ``i`` 帧(``i >= 1``)是第 ``i`` 步动作之后的状态。
|
||||
#: 因此一次 ``steps`` 步的 rollout 会产生 ``steps + 1`` 帧。
|
||||
START_FRAME = 0
|
||||
|
||||
#: 成功判定与最大进度使用全部帧(含起始帧),与 legged_gym 侧
|
||||
#: ``criterion.py`` 及现有 ``eval.py`` 的取点一致。
|
||||
PROGRESS_FRAMES = slice(None)
|
||||
|
||||
#: 逐帧累积类指标只使用动作之后的帧。
|
||||
STEP_FRAMES = slice(1, None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RobotProfile:
|
||||
"""机器人形态描述。
|
||||
|
||||
评价代码里不应出现具体的 link/joint 名字或质量常数,全部集中在这里。
|
||||
新增一个机器人 = 新增一条 ``RobotProfile`` 记录,不改评价代码。
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""机器人标识,同时也是输出文件名的一部分。"""
|
||||
|
||||
mass_kg: float | None
|
||||
"""整机质量 [kg];``None`` 表示运行时从仿真资产读取(推荐)。"""
|
||||
|
||||
feet_body_patterns: tuple[str, ...] = ()
|
||||
"""支撑多边形的接触点,例如火蚁的四个轮。空元组表示该机器人不计算支撑裕度。"""
|
||||
|
||||
support_body_patterns: tuple[str, ...] = ()
|
||||
"""允许作为「短暂支撑」的机体,例如火蚁的机身与膝部。空元组表示不做支撑统计。"""
|
||||
|
||||
actuated_joint_patterns: tuple[str, ...] = ()
|
||||
"""参与能耗统计的关节。"""
|
||||
|
||||
support_force_threshold_n: float = 15.0
|
||||
"""判定「支撑接触」的法向力阈值 [N]。"""
|
||||
|
||||
body_length_m: float = 0.0
|
||||
"""机身长度 [m],仅用于校验地形瓦片尺寸是否够大。"""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvalProtocol:
|
||||
"""评估协议:一次评估的全部「口径」。
|
||||
|
||||
这些字段决定数值大小,因此都会被写进输出 JSON 的 ``fingerprint``,
|
||||
避免不同设置下的结果被悄悄拿来比较。
|
||||
|
||||
默认值与现有 ``scripts/reinforcement_learning/rsl_rl/eval.py`` 的默认
|
||||
命令行参数一致(能耗用绝对值口径、倾角阈值 0.70 rad、加速度阈值 2.0 m/s²
|
||||
折算成重力倍数),因此默认协议下的新结果可以直接和旧评估结果对照。
|
||||
"""
|
||||
|
||||
steps: int = 500
|
||||
"""每个 episode 的控制步数。dt=0.02 时 500 步 = 10 s。"""
|
||||
|
||||
dt: float = 0.02
|
||||
"""控制步长 [s],即 ``decimation * sim.dt``。"""
|
||||
|
||||
command: tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
"""固定速度指令 ``(lin_vel_x, lin_vel_y, ang_vel_z)``。"""
|
||||
|
||||
start_pose: tuple[float, float, float] = (-0.9, 0.0, 0.0)
|
||||
"""起始位姿 ``(x, y, yaw)``,相对地形瓦片中心。"""
|
||||
|
||||
start_z_offset_m: float = 0.0
|
||||
"""起始高度相对地形瓦片中心高度的额外抬升 [m]。
|
||||
|
||||
默认 0.0 表示把机器人直接放在 ``env_origin.z`` 上,与旧 ``eval.py`` 的写法一致。
|
||||
但那样只对「瓦片中心是最高点」的地形成立:像倒金字塔台阶(``pyramid_stairs_inv``,
|
||||
中心是最低点)这类地形,机器人在 ``|x| > platform`` 处会被直接生成到地形几何体
|
||||
内部,卡住无法移动,导致该地形在所有难度上都得到 0 成功率。
|
||||
|
||||
给一个正值(例如 0.3 m)让机器人从地形表面上方落下,可以适配任意地形形状。
|
||||
改这个值会改变 ``protocol_fingerprint``。
|
||||
"""
|
||||
|
||||
success_distance_m: float = 2.0
|
||||
"""沿指令方向前进多少米算成功。"""
|
||||
|
||||
tilt_threshold_rad: float = 0.70
|
||||
"""倾角超过该值 [rad] 视为摔倒。0.70 rad ≈ 40°。"""
|
||||
|
||||
acc_threshold_g: float = 2.0 / 9.81
|
||||
"""机身加速度超过该值(单位 g)计为一次不稳定。用重力倍数而非绝对 m/s²,
|
||||
使该阈值对不同量级的机器人保持可比。默认值 2.0 m/s² 折算而来。"""
|
||||
|
||||
energy_convention: str = "absolute"
|
||||
"""能耗积分约定,``"absolute"`` 表示 ``Σ|τ·q̇|·dt``,
|
||||
``"signed"`` 表示 ``Σ(τ·q̇)·dt``。两者数值差异可达一倍以上,
|
||||
全局必须一致;该值会写进输出 JSON。"""
|
||||
|
||||
stability_margin_method: str = "foot_bbox"
|
||||
"""支撑裕度算法。``"foot_bbox"`` = 机体到足端轴对齐包围盒的距离(当前实现)。"""
|
||||
|
||||
support_force_threshold_n: float | None = 15.0
|
||||
"""判定支撑接触的法向力阈值 [N];``None`` 时改用机器人描述里的值。"""
|
||||
|
||||
enable_interval_pushes: bool = False
|
||||
"""是否启用周期性外力扰动(``robustness`` 评估用)。"""
|
||||
|
||||
enforce_terrain_bounds: bool = True
|
||||
"""是否保留 IsaacLab 的 ``terrain_out_of_bounds`` 终止项。
|
||||
|
||||
**默认 True 是为了与旧 ``eval.py`` 的结果可比,但已知它有系统性偏差**:
|
||||
该终止项比较的是机器人的世界坐标与整张地图的范围,而不是它自己那块地砖。
|
||||
在地砖 2 m、网格 5x5 或 8xN 的评估配置下,最外侧的格子会被误判为越界,
|
||||
机器人从第 0 步起每步都被重置,永远没机会尝试地形;而 ``|y|`` 恰好等于阈值
|
||||
的那两列则处于浮点边界上。实测:8x2 网格下首步就有 6/32 个环境被误判。
|
||||
|
||||
三种处置方式(建议前两种,按顺序):
|
||||
|
||||
1. 把 :attr:`terrain_size_m` 提到 **≥ 3.8**,即可让所有起始位置落进阈值内,
|
||||
同时保留这个终止项;
|
||||
2. 置为 ``False``,评估时去掉这个终止项(成功判定本来就是按前进距离算的,
|
||||
走出地砖不构成失败);
|
||||
3. 保持 ``True`` 且用小地砖 —— 数值会偏低,且被影响的难度等级因行序打乱而
|
||||
每次不同,不建议用来做对比。
|
||||
"""
|
||||
|
||||
push_interval_s: tuple[float, float] = (2.0, 2.01)
|
||||
"""扰动间隔 [s],取一个极窄区间让扰动近似等周期但可比较。"""
|
||||
|
||||
push_velocity_range: tuple[float, float] = (-0.5, 0.5)
|
||||
"""每个水平方向上的扰动速度范围 [m/s]。"""
|
||||
|
||||
terrain_spec: str = "standard_v1"
|
||||
"""地形等级规格名,见 :mod:`eval_kit.core.terrain_spec`。"""
|
||||
|
||||
terrain_size_m: float = 2.0
|
||||
"""单块地形瓦片边长 [m]。"""
|
||||
|
||||
num_levels: int = 8
|
||||
"""上限评估的难度等级数(网格行数)。"""
|
||||
|
||||
envs_per_cell: int = 1
|
||||
"""上限评估每个「地形类型 × 难度」格子里放几个机器人。
|
||||
|
||||
默认 1。**注意:由于起始位姿被钉死,同一格的多个机器人起始坐标完全相同**,
|
||||
它们不是独立样本,差异只来自物理求解器的非确定性。所以靠「一格放多个」来提高
|
||||
样本量是假精度;正确做法是 ``envs_per_cell=1`` + 多个 :attr:`seeds`
|
||||
(不同种子会生成不同的地形实例,因此跨种子才是独立样本)。
|
||||
|
||||
代价:``envs_per_cell=1`` 时单次评估里每格成功率只能是 0 或 1,
|
||||
必须配合足够多的种子才能得到有意义的分辨率。经验值:种子数 ≥ 8 时
|
||||
每格分辨率达到 1/8 ≈ 12.5%。
|
||||
"""
|
||||
|
||||
seeds: tuple[int, ...] = (0,)
|
||||
"""重复试验的随机种子。单次 rollout 噪声很大,比较机器人时应跑多个种子。
|
||||
|
||||
每个种子会重新生成地形实例(``terrain_generator.seed = seed``),所以跨种子
|
||||
的样本是独立的;这是提高统计可靠性的正确途径,而不是加大 ``envs_per_cell``。
|
||||
"""
|
||||
|
||||
@property
|
||||
def acc_threshold_mps2(self) -> float:
|
||||
"""加速度阈值的 m/s² 形式,供指标计算使用。"""
|
||||
return self.acc_threshold_g * 9.81
|
||||
|
||||
def minimum_terrain_size_for_bounds(self, *, border_width: float = 2.0, distance_buffer: float = 3.0) -> float:
|
||||
"""保留 ``terrain_out_of_bounds`` 终止项且不误判所需的最小地砖边长 [m]。
|
||||
|
||||
这个终止项判定 ``|origin + start| <= 0.5 * 地图边长 - distance_buffer``,
|
||||
而最外侧格子中心在 ``(rows-1)/2 * tile``,地图边长 ``= rows * tile + 2 * border``。
|
||||
两边的 ``rows`` 项相消,于是结论与网格行数无关:
|
||||
|
||||
``tile >= 2 * (|start_x| + distance_buffer - border_width)``
|
||||
|
||||
默认的 ``border_width=2.0`` / ``distance_buffer=3.0`` 与
|
||||
:func:`eval_kit.isaaclab.env_setup.apply_eval_terrain_baseline` 及 IsaacLab 的
|
||||
默认值一致;取 ``start_x = -0.9`` 得 ``tile >= 3.8 m``。
|
||||
|
||||
放在内核里(而不是适配层)是为了让编排进程也能用——它不启动仿真,
|
||||
无法 import 任何依赖 isaaclab 的模块。
|
||||
"""
|
||||
return max(0.0, 2.0 * (abs(self.start_pose[0]) + distance_buffer - border_width))
|
||||
|
||||
def fingerprint(self) -> str:
|
||||
"""协议指纹:决定数值的字段的稳定哈希,写进输出 JSON。"""
|
||||
payload = {
|
||||
"steps": self.steps,
|
||||
"dt": self.dt,
|
||||
"command": list(self.command),
|
||||
"start_pose": list(self.start_pose),
|
||||
"start_z_offset_m": self.start_z_offset_m,
|
||||
"success_distance_m": self.success_distance_m,
|
||||
"tilt_threshold_rad": self.tilt_threshold_rad,
|
||||
"acc_threshold_g": self.acc_threshold_g,
|
||||
"energy_convention": self.energy_convention,
|
||||
"stability_margin_method": self.stability_margin_method,
|
||||
"support_force_threshold_n": self.support_force_threshold_n,
|
||||
"enable_interval_pushes": self.enable_interval_pushes,
|
||||
"enforce_terrain_bounds": self.enforce_terrain_bounds,
|
||||
"push_interval_s": list(self.push_interval_s),
|
||||
"push_velocity_range": list(self.push_velocity_range),
|
||||
"terrain_spec": self.terrain_spec,
|
||||
"terrain_size_m": self.terrain_size_m,
|
||||
"num_levels": self.num_levels,
|
||||
"envs_per_cell": self.envs_per_cell,
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True).encode("utf-8")
|
||||
return hashlib.sha256(blob).hexdigest()[:12]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["acc_threshold_mps2"] = self.acc_threshold_mps2
|
||||
data["fingerprint"] = self.fingerprint()
|
||||
return data
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace:
|
||||
"""一次 rollout 的标准化逐帧记录。
|
||||
|
||||
所有数组的第一维都是帧、第二维都是环境。第 0 帧是动作执行之前的起始状态。
|
||||
|
||||
``feet_pos_w`` / ``support_force_n`` / ``support_contact_time_s`` 的第二维
|
||||
可能是 0(该机器人没有足端或没有支撑体语义),指标层会跳过对应指标。
|
||||
"""
|
||||
|
||||
dt: float
|
||||
command: np.ndarray
|
||||
"""``(3,)`` 本次 rollout 使用的固定指令。"""
|
||||
|
||||
base_pos_w: np.ndarray
|
||||
"""``(T, N, 3)`` 机身世界位置。"""
|
||||
|
||||
base_lin_vel_w: np.ndarray
|
||||
"""``(T, N, 3)`` 机身世界系线速度,用于路程与加速度(不稳定性)。"""
|
||||
|
||||
base_lin_vel_b: np.ndarray
|
||||
"""``(T, N, 3)`` 机体系线速度,用于速度跟踪误差。"""
|
||||
|
||||
base_ang_vel_b: np.ndarray
|
||||
"""``(T, N, 3)`` 机体系角速度,用第 2 个分量做 yaw 跟踪误差。"""
|
||||
|
||||
projected_gravity_b: np.ndarray
|
||||
"""``(T, N, 3)`` 机体系投影重力,用于倾角。"""
|
||||
|
||||
joint_torque: np.ndarray
|
||||
"""``(T, N, J)`` 实际施加的关节力矩。"""
|
||||
|
||||
joint_vel: np.ndarray
|
||||
"""``(T, N, J)`` 关节速度。"""
|
||||
|
||||
feet_pos_w: np.ndarray
|
||||
"""``(T, N, F, 2)`` 足端/轮心的世界 xy,用于支撑多边形。"""
|
||||
|
||||
support_force_n: np.ndarray
|
||||
"""``(T, N, S)`` 支撑体的法向合力。"""
|
||||
|
||||
support_contact_time_s: np.ndarray
|
||||
"""``(T, N, S)`` 支撑体的连续接触时长。"""
|
||||
|
||||
done: np.ndarray
|
||||
"""``(T, N)`` 每步之后的终止标志(1.0 表示该步结束后被重置)。"""
|
||||
|
||||
mass_kg: float | None = None
|
||||
"""实际使用的整机质量 [kg]。``None`` 表示上游未能从资产读到。"""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.validate()
|
||||
|
||||
@property
|
||||
def num_frames(self) -> int:
|
||||
return int(self.base_pos_w.shape[0])
|
||||
|
||||
@property
|
||||
def num_steps(self) -> int:
|
||||
"""实际执行的控制步数(帧数减一)。"""
|
||||
return self.num_frames - 1
|
||||
|
||||
@property
|
||||
def num_envs(self) -> int:
|
||||
return int(self.base_pos_w.shape[1])
|
||||
|
||||
@property
|
||||
def num_joints(self) -> int:
|
||||
return int(self.joint_torque.shape[2])
|
||||
|
||||
def validate(self) -> None:
|
||||
"""形状一致性检查。早失败好过算出一堆形状广播出来的错数字。"""
|
||||
frames, envs = self.num_frames, self.num_envs
|
||||
if frames < 2:
|
||||
raise ValueError(f"Trace 至少需要 2 帧(起始帧 + 至少一步),收到 {frames} 帧。")
|
||||
|
||||
expected: dict[str, tuple[int, ...]] = {
|
||||
"base_pos_w": (frames, envs, 3),
|
||||
"base_lin_vel_w": (frames, envs, 3),
|
||||
"base_lin_vel_b": (frames, envs, 3),
|
||||
"base_ang_vel_b": (frames, envs, 3),
|
||||
"projected_gravity_b": (frames, envs, 3),
|
||||
"done": (frames, envs),
|
||||
}
|
||||
for name, shape in expected.items():
|
||||
array = np.asarray(getattr(self, name))
|
||||
if array.shape != shape:
|
||||
raise ValueError(f"{name} 形状应为 {shape},实际 {array.shape}。")
|
||||
|
||||
joints = self.num_joints
|
||||
for name in ("joint_torque", "joint_vel"):
|
||||
if np.asarray(getattr(self, name)).shape != (frames, envs, joints):
|
||||
raise ValueError(
|
||||
f"{name} 形状应为 {(frames, envs, joints)},实际 {np.asarray(getattr(self, name)).shape}。"
|
||||
)
|
||||
|
||||
feet = np.asarray(self.feet_pos_w)
|
||||
if feet.shape[:2] != (frames, envs) or feet.ndim != 4 or feet.shape[3] != 2:
|
||||
raise ValueError(f"feet_pos_w 形状应为 {(frames, envs, feet.shape[2] if feet.ndim > 2 else 0, 2)},"
|
||||
f"实际 {feet.shape}。")
|
||||
|
||||
for name in ("support_force_n", "support_contact_time_s"):
|
||||
array = np.asarray(getattr(self, name))
|
||||
if array.ndim != 3 or array.shape[:2] != (frames, envs):
|
||||
raise ValueError(f"{name} 形状应为 {(frames, envs, array.shape[2] if array.ndim > 2 else 0)},"
|
||||
f"实际 {array.shape}。")
|
||||
|
||||
if np.asarray(self.command).shape != (3,):
|
||||
raise ValueError(f"command 应为 (3,),实际 {np.asarray(self.command).shape}。")
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
"""把轨迹存成 ``.npz``,便于事后用新指标重新分析,无需重跑仿真。"""
|
||||
np.savez_compressed(
|
||||
path,
|
||||
dt=np.asarray(self.dt),
|
||||
command=self.command,
|
||||
base_pos_w=self.base_pos_w,
|
||||
base_lin_vel_w=self.base_lin_vel_w,
|
||||
base_lin_vel_b=self.base_lin_vel_b,
|
||||
base_ang_vel_b=self.base_ang_vel_b,
|
||||
projected_gravity_b=self.projected_gravity_b,
|
||||
joint_torque=self.joint_torque,
|
||||
joint_vel=self.joint_vel,
|
||||
feet_pos_w=self.feet_pos_w,
|
||||
support_force_n=self.support_force_n,
|
||||
support_contact_time_s=self.support_contact_time_s,
|
||||
done=self.done,
|
||||
mass_kg=np.asarray(-1.0 if self.mass_kg is None else self.mass_kg),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> Trace:
|
||||
with np.load(path) as data:
|
||||
mass = float(data["mass_kg"])
|
||||
return cls(
|
||||
dt=float(data["dt"]),
|
||||
command=data["command"],
|
||||
base_pos_w=data["base_pos_w"],
|
||||
base_lin_vel_w=data["base_lin_vel_w"],
|
||||
base_lin_vel_b=data["base_lin_vel_b"],
|
||||
base_ang_vel_b=data["base_ang_vel_b"],
|
||||
projected_gravity_b=data["projected_gravity_b"],
|
||||
joint_torque=data["joint_torque"],
|
||||
joint_vel=data["joint_vel"],
|
||||
feet_pos_w=data["feet_pos_w"],
|
||||
support_force_n=data["support_force_n"],
|
||||
support_contact_time_s=data["support_contact_time_s"],
|
||||
done=data["done"],
|
||||
mass_kg=None if mass < 0.0 else mass,
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""地形等级规格:纯数据,与仿真框架无关。
|
||||
|
||||
规格描述「评估用哪几种地形、每种地形分几级、每级多高」,用绝对高度表示,
|
||||
因为机器人越障能力本来就是由绝对高度决定的。
|
||||
|
||||
规格本身不含任何框架信息;「把规格翻译成本框架的地形生成器参数」这件事
|
||||
由各框架的 builder 负责(当前是 :mod:`eval_kit.isaaclab.env_setup`)。
|
||||
换框架时改 builder,不改这里的数值。
|
||||
|
||||
数值与 ``scripts/reinforcement_learning/rsl_rl/eval.py`` 的 ``TERRAIN_LEVELS``
|
||||
保持一致,因此两套评估所用地形严格相同。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Mapping
|
||||
|
||||
#: 无量纲坡度:高 / 水平距离。0.40 相当于 21.8°。
|
||||
_UNIT_SLOPE = "slope (rise/run, 0.40 ≈ 21.8°)"
|
||||
#: 台阶高度,米。
|
||||
_UNIT_METER = "m"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TerrainSpec:
|
||||
"""一套地形评估规格。"""
|
||||
|
||||
name: str
|
||||
"""规格名,写进输出 JSON。"""
|
||||
|
||||
types: tuple[str, ...]
|
||||
"""参与评估的地形类型,同时也是上限矩阵的列顺序。"""
|
||||
|
||||
levels: Mapping[str, tuple[float, ...]]
|
||||
"""每种地形的难度等级值(绝对量,单位见 :attr:`unit`),由易到难。"""
|
||||
|
||||
unit: Mapping[str, str]
|
||||
"""每种地形等级值的单位说明。"""
|
||||
|
||||
isaaclab_subterrain: Mapping[str, str]
|
||||
"""地形类型 → IsaacLab ``ROUGH_TERRAINS_CFG`` 里的子地形名。"""
|
||||
|
||||
isaaclab_param: Mapping[str, str]
|
||||
"""地形类型 → 需要在子地形配置里写入的字段名。"""
|
||||
|
||||
feature_scaling: Mapping[str, tuple[float, float]] = field(default_factory=dict)
|
||||
"""瓦片尺寸缩小时的特征尺寸上限:``字段名 -> (相对瓦片的比例, 绝对上限)``。
|
||||
|
||||
例如 ``{"step_width": (0.125, 0.25)}`` 表示 ``step_width = min(0.25, tile * 0.125)``。
|
||||
这样同一套规格可以用在不同大小的地形瓦片上。
|
||||
"""
|
||||
|
||||
grid_rows: int = 5
|
||||
grid_cols: int = 5
|
||||
"""非上限评估时地形生成器的网格规模。"""
|
||||
|
||||
isaaclab_extra_params: Mapping[str, Mapping[str, float]] = field(default_factory=dict)
|
||||
"""地形类型 → 需要额外写入的固定字段,例如 ``{"rough_slope": {"noise_step": 0.02}}``。
|
||||
|
||||
与 :attr:`isaaclab_param` 的区别:后者写的是随等级变化的首尾值,这里写的是与等级
|
||||
无关的固定量。写入时会校验字段是否存在,写错会直接报错而不是静默造一个假属性。
|
||||
"""
|
||||
|
||||
def levels_for(self, terrain_type: str, num_levels: int | None = None) -> tuple[float, ...]:
|
||||
"""取某种地形的等级值,``num_levels`` 为 None 时取全部。"""
|
||||
if terrain_type not in self.levels:
|
||||
raise KeyError(f"地形规格 {self.name!r} 里没有 {terrain_type!r};可选:{sorted(self.levels)}")
|
||||
values = tuple(self.levels[terrain_type])
|
||||
if num_levels is None:
|
||||
return values
|
||||
if num_levels > len(values):
|
||||
raise ValueError(
|
||||
f"地形 {terrain_type!r} 只定义了 {len(values)} 级难度,请求了 {num_levels} 级。"
|
||||
)
|
||||
return values[:num_levels]
|
||||
|
||||
def scaling_for(self, field_name: str, tile_size_m: float) -> float | None:
|
||||
"""按瓦片尺寸算某个特征字段的上限值,未定义则返回 None。"""
|
||||
rule = self.feature_scaling.get(field_name)
|
||||
if rule is None:
|
||||
return None
|
||||
ratio, cap = rule
|
||||
return min(cap, tile_size_m * ratio)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"types": list(self.types),
|
||||
"levels": {key: list(value) for key, value in self.levels.items()},
|
||||
"unit": dict(self.unit),
|
||||
}
|
||||
|
||||
|
||||
STANDARD_V1 = TerrainSpec(
|
||||
name="standard_v1",
|
||||
types=("stairs_up", "stairs_down", "smooth_slope", "rough_slope", "boxes"),
|
||||
levels={
|
||||
"stairs_up": (0.04, 0.06, 0.08, 0.10, 0.12, 0.14, 0.16, 0.18),
|
||||
"stairs_down": (0.04, 0.06, 0.08, 0.10, 0.12, 0.14, 0.16, 0.18),
|
||||
"smooth_slope": (0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40),
|
||||
# 注意:random_rough 用的是 HfRandomUniformTerrainCfg,IsaacLab 明确写了
|
||||
# 「difficulty 参数被忽略」,所以这里只有首尾值真正起作用 —— 写成
|
||||
# noise_range=(levels[0], levels[-1]),是**绝对高度范围**而非起伏幅度。
|
||||
# 旧 eval.py 把它设成 (0.05, 0.40),在 2 cm 水平网格上等于 40 cm 高的随机尖刺,
|
||||
# 轮半径 0.096 m 的机器人无法通过,那一列几乎恒为 0(实测只从 0.12 升到 0.45)。
|
||||
# 这里回到与训练地形同量级的范围(上游训练用 noise_range=(0.02, 0.10))。
|
||||
"rough_slope": (0.02, 0.03, 0.04, 0.05, 0.06, 0.08, 0.09, 0.10),
|
||||
"boxes": (0.04, 0.06, 0.08, 0.10, 0.12, 0.14, 0.16, 0.18),
|
||||
},
|
||||
unit={
|
||||
"stairs_up": _UNIT_METER,
|
||||
"stairs_down": _UNIT_METER,
|
||||
"smooth_slope": _UNIT_SLOPE,
|
||||
"rough_slope": _UNIT_METER + "(高度范围;此地形忽略难度参数,8 行同分布,等级排名无意义)",
|
||||
"boxes": _UNIT_METER,
|
||||
},
|
||||
isaaclab_subterrain={
|
||||
"stairs_up": "pyramid_stairs",
|
||||
"stairs_down": "pyramid_stairs_inv",
|
||||
"smooth_slope": "hf_pyramid_slope",
|
||||
"rough_slope": "random_rough",
|
||||
"boxes": "boxes",
|
||||
},
|
||||
isaaclab_param={
|
||||
"stairs_up": "step_height_range",
|
||||
"stairs_down": "step_height_range",
|
||||
"smooth_slope": "slope_range",
|
||||
"rough_slope": "noise_range",
|
||||
"boxes": "grid_height_range",
|
||||
},
|
||||
feature_scaling={
|
||||
"platform_width": (0.2, 0.4),
|
||||
"border_width": (0.05, 0.1),
|
||||
"step_width": (0.125, 0.25),
|
||||
"grid_width": (0.15, 0.3),
|
||||
},
|
||||
# 上游 ROUGH_TERRAINS_CFG 用 noise_step=0.02;旧 eval.py 把它改成 0.01 后又叠加
|
||||
# noise_range 上限 0.40 m,相当于 1 cm 水平网格上放 40 cm 起伏 —— 极端尖锐的尖刺,
|
||||
# 轮半径只有 0.096 m 的机器人基本无法通过。这里回到上游的 0.02。
|
||||
isaaclab_extra_params={
|
||||
"rough_slope": {"noise_step": 0.02},
|
||||
},
|
||||
grid_rows=5,
|
||||
grid_cols=5,
|
||||
)
|
||||
|
||||
TERRAIN_SPECS: dict[str, TerrainSpec] = {STANDARD_V1.name: STANDARD_V1}
|
||||
|
||||
|
||||
def get_terrain_spec(name: str) -> TerrainSpec:
|
||||
if name not in TERRAIN_SPECS:
|
||||
raise KeyError(f"未知地形规格 {name!r};可选:{sorted(TERRAIN_SPECS)}")
|
||||
return TERRAIN_SPECS[name]
|
||||
Reference in New Issue
Block a user