[real] 整理 Python Sim2Real v2

This commit is contained in:
2026-07-27 16:45:09 +08:00
parent 55094bf682
commit 3bf6eb0c4c
84 changed files with 13361 additions and 5 deletions
@@ -0,0 +1,244 @@
"""Offline deployment alignment check for the current 53-D rough policy."""
import argparse
from pathlib import Path
import sys
import numpy as np
import torch
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from interface.motor_mapping import MotorMapping # noqa: E402
from policy.policy_runner import PolicyRunner # noqa: E402
def _load_manifest(manifest_path: Path) -> dict:
with open(manifest_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def _load_config() -> dict:
config_path = Path(__file__).resolve().parents[1] / "config.yaml"
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
def check(policy_path: Path, manifest_path: Path | None = None) -> int:
issues: list[tuple[str, str]] = []
manifest = _load_manifest(manifest_path) if manifest_path is not None else None
config = _load_config()
expected = (
("fl", "hip_abduction"), ("fl", "hip_pitch"), ("fl", "knee"),
("fr", "hip_abduction"), ("fr", "hip_pitch"), ("fr", "knee"),
("rl", "hip_abduction"), ("rl", "hip_pitch"), ("rl", "knee"),
("rr", "hip_abduction"), ("rr", "hip_pitch"), ("rr", "knee"),
("fl", "wheel"), ("fr", "wheel"), ("rl", "wheel"), ("rr", "wheel"),
)
if MotorMapping.SIM_JOINT_ORDER != expected:
issues.append(("joint_order", "MotorMapping.SIM_JOINT_ORDER mismatch"))
else:
print("[Check] joint order: PASS")
expected_joint_names = [f"{leg}_{joint}" for leg, joint in expected]
manifest_enable_zero_cmd = True
if manifest is not None:
manifest_enable_zero_cmd = bool(
manifest.get("model", {}).get("enable_zero_cmd_suppression", True)
)
runner = PolicyRunner(
policy_path,
device=torch.device("cpu"),
enable_zero_cmd_suppression=manifest_enable_zero_cmd,
clip_obs=float(config.get("policy", {}).get("clip_obs", 100.0)),
)
obs_mean = np.asarray(runner.policy.obs_mean.detach().cpu().numpy(), dtype=np.float32)
obs_std = np.asarray(runner.policy.obs_std.detach().cpu().numpy(), dtype=np.float32)
if np.allclose(obs_mean, 0.0) and np.allclose(obs_std, 1.0):
print("[Check] obs normalizer: PASS (identity)")
else:
print(
f"[Check] obs normalizer: PASS "
f"(mean range=[{obs_mean.min():.3f},{obs_mean.max():.3f}], "
f"std range=[{obs_std.min():.3f},{obs_std.max():.3f}])"
)
if (obs_std < 1e-6).any():
issues.append(
(
"normalizer_zero_std",
f"obs_std has near-zero entries: {np.where(obs_std < 1e-6)[0].tolist()}",
)
)
raw_zero = np.zeros(runner.BASE_OBS_DIM, dtype=np.float32)
runner.reset(prime_obs=raw_zero)
_, raw = runner.step(raw_zero)
if np.max(np.abs(raw)) > 5.0:
issues.append(
(
"output_range",
f"raw action too large under zero obs: {np.max(np.abs(raw)):.3f}",
)
)
else:
print(f"[Check] zero-obs output range: PASS (max|raw|={np.max(np.abs(raw)):.3f})")
expected_default = np.array([0.0, 0.9, -1.8] * 4 + [0.0] * 4, dtype=np.float32)
if not np.allclose(runner.default_dof_pos, expected_default):
issues.append(("default_pose_mismatch", f"default_dof_pos mismatch: {runner.default_dof_pos}"))
else:
print("[Check] default_dof_pos: PASS")
if runner.BASE_OBS_DIM != 53:
issues.append(("obs_dim", f"base obs dim {runner.BASE_OBS_DIM} != 53"))
else:
print("[Check] actor obs dim: PASS (53)")
if manifest is not None:
declared_model = manifest.get("model", {})
declared_action = manifest.get("action", {})
declared_safety = manifest.get("safety", {})
declared_control = manifest.get("control", {})
if int(declared_model.get("obs_dim", -1)) != runner.policy.expected_obs_dim:
issues.append(
(
"manifest_obs_dim",
f"manifest obs_dim {declared_model.get('obs_dim')} != policy {runner.policy.expected_obs_dim}",
)
)
else:
print("[Check] manifest obs_dim: PASS")
if int(declared_model.get("action_dim", -1)) != runner.policy.expected_action_dim:
issues.append(
(
"manifest_action_dim",
f"manifest action_dim {declared_model.get('action_dim')} != policy {runner.policy.expected_action_dim}",
)
)
else:
print("[Check] manifest action_dim: PASS")
declared_clip_obs = float(declared_model.get("clip_obs", -1.0))
config_clip_obs = float(config.get("policy", {}).get("clip_obs", -2.0))
if declared_clip_obs != runner.clip_obs or config_clip_obs != runner.clip_obs:
issues.append(
(
"clip_obs",
f"clip_obs mismatch: manifest={declared_clip_obs}, config={config_clip_obs}, runner={runner.clip_obs}",
)
)
else:
print("[Check] clip_obs: PASS")
declared_default = np.asarray(declared_action.get("default_dof_pos", []), dtype=np.float32)
if declared_default.shape != runner.default_dof_pos.shape or not np.allclose(
declared_default, runner.default_dof_pos
):
issues.append(("manifest_default_pose", "manifest default_dof_pos mismatch"))
else:
print("[Check] manifest default_dof_pos: PASS")
declared_scale = np.asarray(declared_action.get("scale", []), dtype=np.float32)
if declared_scale.shape != runner.action_scale.shape or not np.allclose(
declared_scale, runner.action_scale
):
issues.append(("manifest_action_scale", "manifest action scale mismatch"))
else:
print("[Check] manifest action scale: PASS")
config_scale = np.asarray(config.get("policy", {}).get("action_scale", []), dtype=np.float32)
if config_scale.shape != runner.action_scale.shape or not np.allclose(config_scale, runner.action_scale):
issues.append(("config_action_scale", "config policy.action_scale mismatch"))
else:
print("[Check] config action scale: PASS")
declared_joint_order = list(declared_action.get("joint_order", []))
if declared_joint_order != expected_joint_names:
issues.append(("manifest_joint_order", "manifest action.joint_order mismatch"))
else:
print("[Check] manifest joint order: PASS")
declared_wheel_indices = list(declared_action.get("wheel_indices", []))
if declared_wheel_indices != [12, 13, 14, 15]:
issues.append(("manifest_wheel_indices", "manifest wheel_indices must be [12,13,14,15]"))
else:
print("[Check] manifest wheel indices: PASS")
if float(declared_safety.get("zero_cmd_lin_thresh", -1.0)) != runner.zero_cmd_lin_thresh:
issues.append(("manifest_zero_cmd_lin_thresh", "manifest zero_cmd_lin_thresh mismatch"))
if float(declared_safety.get("zero_cmd_yaw_thresh", -1.0)) != runner.zero_cmd_yaw_thresh:
issues.append(("manifest_zero_cmd_yaw_thresh", "manifest zero_cmd_yaw_thresh mismatch"))
if float(declared_safety.get("zero_yaw_rate_thresh", -1.0)) != runner.zero_yaw_rate_thresh:
issues.append(("manifest_zero_yaw_rate_thresh", "manifest zero_yaw_rate_thresh mismatch"))
if bool(declared_model.get("enable_zero_cmd_suppression", True)) != runner.enable_zero_cmd_suppression:
issues.append(("manifest_zero_cmd_switch", "manifest zero-command suppression switch mismatch"))
else:
print("[Check] manifest zero-command suppression: PASS")
if int(declared_control.get("control_freq_hz", -1)) != 50:
issues.append(("manifest_control_freq", "manifest control_freq_hz must be 50"))
else:
print("[Check] manifest control freq: PASS")
if int(config.get("control_freq", -1)) != int(declared_control.get("control_freq_hz", -2)):
issues.append(("config_control_freq", "config control_freq != manifest control_freq_hz"))
else:
print("[Check] config control freq: PASS")
controller_cfg = config.get("controller", {}) or {}
gain_pairs = (
("runtime_kp_leg", "kp_leg"),
("runtime_kd_leg", "kd_leg"),
("hold_kp_leg", "hold_kp_leg"),
("hold_kd_leg", "hold_kd_leg"),
("kd_wheel", "kd_wheel"),
)
for manifest_key, config_key in gain_pairs:
manifest_value = float(declared_control.get(manifest_key, -9999.0))
config_value = float(controller_cfg.get(config_key, -9998.0))
if not np.isclose(manifest_value, config_value):
issues.append(
(
"control_gains",
f"{manifest_key}/{config_key} mismatch: manifest={manifest_value}, config={config_value}",
)
)
if not any(tag == "control_gains" for tag, _ in issues):
print("[Check] control gains: PASS")
manifest_filter = declared_control.get("command_filter", {}) or {}
config_filter = config.get("command_filter", {}) or {}
for key in ("enabled", "max_vx_acc", "max_vy_acc", "max_yaw_acc"):
if manifest_filter.get(key) != config_filter.get(key):
issues.append(("command_filter", f"command_filter.{key} mismatch"))
if not any(tag == "command_filter" for tag, _ in issues):
print("[Check] command filter config: PASS")
if issues:
print("\n" + "=" * 60)
print(f"Alignment check failed: {len(issues)} issue(s)")
for tag, msg in issues:
print(f" [{tag}] {msg}")
return 1
print("\n" + "=" * 60)
print("All offline alignment checks passed.")
return 0
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--policy", type=str, required=True, help="Path to policy .onnx or .pt")
parser.add_argument("--manifest", type=str, default=None, help="Optional deployment manifest yaml")
args = parser.parse_args()
manifest = Path(args.manifest) if args.manifest else None
sys.exit(check(Path(args.policy), manifest))
if __name__ == "__main__":
main()
@@ -0,0 +1,138 @@
"""Summarize real-run logs for startup/stand/runtime diagnosis."""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
import statistics
LEG_JOINTS = (
"fl_hip_abd", "fl_hip_pitch", "fl_knee",
"fr_hip_abd", "fr_hip_pitch", "fr_knee",
"rl_hip_abd", "rl_hip_pitch", "rl_knee",
"rr_hip_abd", "rr_hip_pitch", "rr_knee",
)
def _f(row: dict[str, str], key: str, default: float = 0.0) -> float:
try:
return float(row.get(key, default))
except Exception:
return default
def _stats(values: list[float]) -> str:
if not values:
return "--"
return (
f"mean={statistics.mean(values):.3f} "
f"std={statistics.pstdev(values):.3f} "
f"min={min(values):.3f} max={max(values):.3f}"
)
def _pitch_deg(row: dict[str, str]) -> float:
gx = _f(row, "pgrav_x")
gy = _f(row, "pgrav_y")
gz = _f(row, "pgrav_z")
return math.degrees(math.atan2(gx, math.sqrt(max(1e-9, gy * gy + gz * gz))))
def summarize(log_dir: Path) -> int:
state_path = log_dir / "state.csv"
events_path = log_dir / "events.jsonl"
if not state_path.exists():
print(f"[Analyze] missing {state_path}")
return 1
if events_path.exists():
print("[Analyze] key events:")
for line in events_path.read_text(encoding="utf-8", errors="replace").splitlines():
try:
ev = json.loads(line)
except Exception:
continue
if ev.get("kind") in {
"STARTUP_PLAN",
"STARTUP_REACHED",
"STAND_BALANCE_STABLE",
"RUNTIME_BEGIN",
"POLICY_TARGET_STALE",
"POLICY_TIMEOUT",
"SAFETY_BRAKE",
"GUARD_STOP",
"POSE_INIT_FAILED",
}:
detail = {k: v for k, v in ev.items() if k not in ("t", "t_rel")}
print(f" t={ev.get('t_rel', 0):.2f}s {detail}")
with state_path.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
print(f"\n[Analyze] state rows: {len(rows)}")
global_notes: list[str] = []
for phase in sorted({r.get("phase", "") for r in rows}):
phase_rows = [r for r in rows if r.get("phase") == phase]
if not phase_rows:
continue
pitch = [_pitch_deg(r) for r in phase_rows]
loop = [_f(r, "loop_dt_ms") for r in phase_rows]
imu = [_f(r, "imu_age_ms") for r in phase_rows]
print(f"\n[Phase] {phase} n={len(phase_rows)} t={phase_rows[0].get('t_rel')}..{phase_rows[-1].get('t_rel')}")
print(f" pitch_deg {_stats(pitch)}")
print(f" loop_ms {_stats(loop)}")
print(f" imu_age {_stats(imu)}")
if "stand_pitch_corr" in phase_rows[0]:
stand_pitch = [_f(r, "stand_pitch_deg") for r in phase_rows]
stand_corr = [_f(r, "stand_pitch_corr") for r in phase_rows]
stand_enabled = [_f(r, "stand_pitch_comp_enabled") for r in phase_rows]
print(f" stand_pitch_deg {_stats(stand_pitch)}")
print(f" stand_pitch_corr {_stats(stand_corr)} enabled_mean={statistics.mean(stand_enabled):.3f}")
for joint in ("fl_hip_pitch", "fr_hip_pitch", "rl_hip_pitch", "rr_hip_pitch", "fl_knee", "fr_knee", "rl_knee", "rr_knee"):
pos_key = f"{joint}_pos"
tgt_key = f"{joint}_tgt"
tau_key = f"{joint}_tau"
if pos_key in phase_rows[0] and tgt_key in phase_rows[0]:
err = [_f(r, pos_key) - _f(r, tgt_key) for r in phase_rows]
tau = [_f(r, tau_key) for r in phase_rows] if tau_key in phase_rows[0] else []
print(f" {joint}_err {_stats(err)} tau {_stats(tau)}")
high_tau = []
high_err = []
for joint in LEG_JOINTS:
tau_key = f"{joint}_tau"
pos_key = f"{joint}_pos"
tgt_key = f"{joint}_tgt"
if tau_key in phase_rows[0]:
tau_abs_mean = statistics.mean(abs(_f(r, tau_key)) for r in phase_rows)
if tau_abs_mean > 6.0:
high_tau.append((joint, tau_abs_mean))
if pos_key in phase_rows[0] and tgt_key in phase_rows[0]:
err_abs_mean = statistics.mean(abs(_f(r, pos_key) - _f(r, tgt_key)) for r in phase_rows)
if err_abs_mean > 0.08:
high_err.append((joint, err_abs_mean))
if high_tau:
text = ", ".join(f"{name}:{value:.2f}Nm" for name, value in sorted(high_tau, key=lambda x: -x[1])[:4])
print(f" high_tau_mean {text}")
global_notes.append(f"{phase}: high mean torque -> {text}")
if high_err:
text = ", ".join(f"{name}:{value:.3f}rad" for name, value in sorted(high_err, key=lambda x: -x[1])[:4])
print(f" high_err_mean {text}")
global_notes.append(f"{phase}: high tracking error -> {text}")
if global_notes:
print("\n[Analyze] notes:")
for note in global_notes:
print(f" - {note}")
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("log_dir", type=str)
args = parser.parse_args()
return summarize(Path(args.log_dir))
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,163 @@
"""零位偏移标定向导。
用途:把机器人摆到 sim2sim/训练侧的 stand 默认姿态(人工摆好),
跑这个脚本,它会读 16 个电机的当前位置,反算每个电机的 ZERO_OFFSET。
关键公式(与 motor_mapping.py 一致):
real = sign * sim + offset
当 sim = stand_default 时:
offset = real - sign * stand_default
⚠️ 使用前置条件:
1. 已运行过 motor_driver_direction_test 类的脚本,确认每个电机的 sign 是对的;
sign 错的话本工具会算出错误的 offset 看起来很对,但发动作时机器人会反向冲撞
2. 机器人物理上摆到 stand 姿态:四条腿微弯曲、轮子接地、机身水平
3. 电机已 enable 并清除告警
输出:把打印出来的 ZERO_OFFSET_MAP 字段直接覆盖 motor_mapping.py 中的对应字典。
"""
import argparse
import sys
import time
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from interface.motor_mapping import MotorMapping # noqa: E402
from policy.policy_runner import PolicyRunner # noqa: E402
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--can1-port", default="/dev/can1")
parser.add_argument("--can2-port", default="/dev/can2")
parser.add_argument("--motor-model", default="rs-02")
parser.add_argument("--samples", type=int, default=100,
help="平均采样帧数(去抖动)")
parser.add_argument("--target-pose", default="stand", choices=["stand", "crawl"],
help="标定时机器人摆的物理姿态")
parser.add_argument("--no-enable", action="store_true",
help="不主动 enable 电机(仅读取,适合手动转关节标定)")
args = parser.parse_args()
# 真机驱动注入(路径优先级与 main.py 一致:vendored/drivers > /home/rc2/...
sim2real_root = Path(__file__).resolve().parents[1]
for path in (sim2real_root / "vendored",
"/home/rc2/work/rcwork/control",
"/home/rc2/work/rcwork"):
sp = str(path)
if sp not in sys.path and Path(path).exists():
sys.path.append(sp)
from drivers.motor_driver import RobStrideDriver # type: ignore
mapper = MotorMapping()
drv1 = RobStrideDriver(args.can1_port, debug=False)
drv2 = RobStrideDriver(args.can2_port, debug=False)
drv1.connect()
drv2.connect()
for jk in mapper.SIM_JOINT_ORDER:
leg, joint = jk
bus, mid = mapper.CAN_ID_MAP[jk]
name = f"{leg}_{joint}"
(drv1 if bus == 1 else drv2).add_motor(name, mid, args.motor_model)
if not args.no_enable:
print("[Calib] Enable 电机以读取状态...(已就位则可加 --no-enable 跳过)")
for drv in (drv1, drv2):
for name in drv.motors:
drv.clear_warnings(name)
drv.enable(name)
time.sleep(0.5)
# 选择标定姿态
if args.target_pose == "stand":
sim_pose = PolicyRunner.DEFAULT_STAND_POSE.copy() # [0,0.9,-1.8] x4 + zeros
else:
sim_pose = np.array([
0.4, 1.65, -2.55, -0.4, 1.65, -2.55,
0.4, 1.65, -2.55, -0.4, 1.65, -2.55,
0.0, 0.0, 0.0, 0.0,
], dtype=np.float32)
print(f"\n[Calib] 请把机器人物理摆成 {args.target_pose.upper()} 姿态:")
if args.target_pose == "stand":
print(" 四条腿髋外展=0, 髋俯仰=0.9rad(~52°), 膝=-1.8rad(~-103°), 轮接地")
else:
print(" 内收外展 ±0.4rad, 髋俯仰=1.65rad, 膝=-2.55rad(深蹲下趴)")
print(" 轮子可以保持任意角度,offset 强制为 0")
print(" 按回车开始采样...")
try:
input()
except EOFError:
pass
print(f"\n[Calib] 开始采样 {args.samples} 帧并平均...")
pos_acc = np.zeros(16, dtype=np.float64)
valid = 0
for i in range(args.samples):
drv1.process_messages()
drv2.process_messages()
real_pos = {}
for drv_idx, drv in enumerate((drv1, drv2)):
bus = drv_idx + 1
for name, motor in drv.motors.items():
parts = name.split("_", 1)
if len(parts) != 2:
continue
key = (parts[0], parts[1])
if key not in mapper.CAN_ID_MAP:
continue
_, mid = mapper.CAN_ID_MAP[key]
real_pos[(bus, mid)] = motor.state.position
if len(real_pos) == 16:
ordered = np.array([real_pos[mapper.CAN_ID_MAP[jk]]
for jk in mapper.SIM_JOINT_ORDER], dtype=np.float64)
pos_acc += ordered
valid += 1
time.sleep(0.02)
if valid < args.samples * 0.5:
print(f"[Calib] 警告: 只收到 {valid}/{args.samples} 帧反馈,标定可能不可靠")
real_avg = pos_acc / max(valid, 1)
# 反算 offsetoffset = real - sign * sim
sign = mapper._sign
offsets = real_avg - sign * sim_pose
# 轮子 offset 强制 0
for i, jk in enumerate(mapper.SIM_JOINT_ORDER):
if jk[1] == "wheel":
offsets[i] = 0.0
# 打印结果(按 motor_mapping.py 的字典格式)
print("\n" + "=" * 64)
print(f"[Calib] 标定完成({valid} 帧平均)")
print("=" * 64)
print("把以下字典覆盖 sim2real/interface/motor_mapping.py 中的 ZERO_OFFSET_MAP:")
print()
print(" ZERO_OFFSET_MAP = {")
for i, jk in enumerate(mapper.SIM_JOINT_ORDER):
leg, joint = jk
cur = offsets[i]
old = mapper.ZERO_OFFSET_MAP[jk]
delta = cur - old
marker = " *" if abs(delta) > 0.01 else ""
print(f' ("{leg}", "{joint:13s}"): {cur:>+8.4f}, '
f'# old={old:+.4f} delta={delta:+.4f}{marker}')
print(" }")
print("\n标记 * 的项与现表偏离 > 0.01 rad,请重点核对该关节的 sign 是否正确。\n")
# Disable
if not args.no_enable:
for drv in (drv1, drv2):
for name in drv.motors:
drv.disable(name)
drv1.disconnect()
drv2.disconnect()
if __name__ == "__main__":
main()
@@ -0,0 +1,80 @@
"""Export the current PyTorch actor checkpoint to ONNX and verify parity."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
import numpy as np
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from policy.policy_runner import load_policy # noqa: E402
def export_onnx(pt_path: Path, onnx_path: Path, opset: int = 14) -> None:
device = torch.device("cpu")
model = load_policy(pt_path, device)
if getattr(model, "backend", "torch") != "torch":
raise ValueError(f"export source must be a .pt policy, got {pt_path}")
obs_dim = int(model.expected_obs_dim)
dummy = torch.randn(1, obs_dim, dtype=torch.float32, device=device)
onnx_path.parent.mkdir(parents=True, exist_ok=True)
torch.onnx.export(
model,
dummy,
str(onnx_path),
export_params=True,
opset_version=opset,
do_constant_folding=True,
input_names=["obs"],
output_names=["action"],
dynamic_axes={"obs": {0: "batch_size"}, "action": {0: "batch_size"}},
)
try:
import onnxruntime as ort
except ImportError:
print("[Export] onnxruntime not installed; export done but parity check skipped.")
return
opts = ort.SessionOptions()
opts.intra_op_num_threads = 1
opts.inter_op_num_threads = 1
opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
session = ort.InferenceSession(str(onnx_path), sess_options=opts, providers=["CPUExecutionProvider"])
with torch.no_grad():
torch_out = model(dummy).detach().cpu().numpy()
ort_out = session.run([session.get_outputs()[0].name], {session.get_inputs()[0].name: dummy.cpu().numpy()})[0]
max_diff = float(np.max(np.abs(torch_out - ort_out)))
mean_diff = float(np.mean(np.abs(torch_out - ort_out)))
print(f"[Export] ONNX parity max_diff={max_diff:.8f}, mean_diff={mean_diff:.8f}")
if max_diff > 1e-4:
raise RuntimeError(f"ONNX parity check failed: max_diff={max_diff:.8f}")
def main() -> int:
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument("--pt", default=str(root / "policies" / "model_rough.pt"), help="Source .pt checkpoint")
parser.add_argument("--onnx", default=None, help="Destination .onnx path; default replaces .pt suffix")
parser.add_argument("--opset", type=int, default=14)
args = parser.parse_args()
pt_path = Path(args.pt)
onnx_path = Path(args.onnx) if args.onnx else pt_path.with_suffix(".onnx")
if not pt_path.exists():
print(f"[Export] missing source policy: {pt_path}")
return 1
export_onnx(pt_path, onnx_path, args.opset)
print(f"[Export] wrote {onnx_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,296 @@
"""Logging helpers for sim2real runs.
Each session writes:
- `state.csv`: high-rate state stream
- `events.jsonl`: event / milestone stream
"""
import json
import queue
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
import numpy as np
class LogBundle:
"""One session directory containing state CSV and event JSONL."""
JOINT_LABELS = (
"fl_hip_abd", "fl_hip_pitch", "fl_knee",
"fr_hip_abd", "fr_hip_pitch", "fr_knee",
"rl_hip_abd", "rl_hip_pitch", "rl_knee",
"rr_hip_abd", "rr_hip_pitch", "rr_knee",
"fl_wheel", "fr_wheel", "rl_wheel", "rr_wheel",
)
def __init__(self, log_root: str = "logs"):
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.dir = Path(log_root) / timestamp
self.dir.mkdir(parents=True, exist_ok=True)
self.state_path = self.dir / "state.csv"
self.events_path = self.dir / "events.jsonl"
self._state_fp = open(self.state_path, "w", encoding="utf-8")
self._events_fp = open(self.events_path, "w", encoding="utf-8")
self._t0 = time.time()
self._closed = False
self._queue: "queue.Queue[tuple]" = queue.Queue(maxsize=20000)
self._dropped_state_rows = 0
self._writer_thread = threading.Thread(target=self._writer_loop, name="sim2real-log-writer", daemon=True)
self._write_state_header()
self._writer_thread.start()
self.event("LOG_START", session_dir=str(self.dir))
print(f"[Log] {self.dir}")
def _write_state_header(self):
cols = ["t", "t_rel", "phase"]
cols += [f"{joint}_pos" for joint in self.JOINT_LABELS]
cols += [f"{joint}_vel" for joint in self.JOINT_LABELS]
cols += [f"{joint}_tau" for joint in self.JOINT_LABELS]
cols += [f"{joint}_tgt" for joint in self.JOINT_LABELS]
cols += [f"{joint}_raw" for joint in self.JOINT_LABELS]
cols += ["gyro_x", "gyro_y", "gyro_z"]
cols += ["accel_x", "accel_y", "accel_z"]
cols += ["quat_w", "quat_x", "quat_y", "quat_z"]
cols += ["pgrav_x", "pgrav_y", "pgrav_z"]
cols += ["cmd_vx", "cmd_vy", "cmd_yaw"]
cols += ["imu_age_ms", "loop_dt_ms"]
cols += ["safety_level", "guard_level"]
cols += ["holdover", "stale_max", "fresh_count"]
cols += ["kp_scale", "nan_flag"]
cols += ["kp_leg_cmd", "kd_leg_cmd", "kd_wheel_cmd"]
cols += ["runtime_release_alpha", "runtime_release_hold_s", "runtime_blend_ratio"]
cols += ["hold_target_max_err", "policy_target_max_err", "hold_policy_max_gap"]
cols += [
"stand_roll_deg",
"stand_pitch_deg",
"stand_roll_corr",
"stand_pitch_corr",
"stand_pitch_comp_enabled",
]
cols += ["target_source_code"]
cols += [
"clip_primary_joint_index",
"clip_primary_joint",
"clip_primary_target",
"clip_primary_measured",
"clip_primary_default",
"clip_primary_pos_err",
"clip_primary_raw",
"clip_primary_scaled",
]
cols += ["safety_reason", "guard_reason"]
self._state_fp.write(",".join(cols) + "\n")
self._state_fp.flush()
def state(
self,
phase: str,
joint_pos: np.ndarray,
joint_vel: np.ndarray,
joint_torque: np.ndarray,
target_pose: np.ndarray,
raw_action: Optional[np.ndarray],
gyro: np.ndarray,
accel: np.ndarray,
quat: np.ndarray,
proj_gravity: np.ndarray,
command: np.ndarray,
imu_age_ms: float,
loop_dt_ms: float,
safety_level: int = 0,
guard_level: int = 0,
holdover: int = 0,
stale_max: int = 0,
fresh_count: int = 16,
kp_scale: float = 1.0,
nan_flag: int = 0,
kp_leg_cmd: float = 0.0,
kd_leg_cmd: float = 0.0,
kd_wheel_cmd: float = 0.0,
runtime_release_alpha: float = 0.0,
runtime_release_hold_s: float = 0.0,
runtime_blend_ratio: float = 0.0,
hold_target_max_err: float = 0.0,
policy_target_max_err: float = 0.0,
hold_policy_max_gap: float = 0.0,
stand_roll_deg: float = 0.0,
stand_pitch_deg: float = 0.0,
stand_roll_corr: float = 0.0,
stand_pitch_corr: float = 0.0,
stand_pitch_comp_enabled: bool = False,
target_source: str = "",
clip_primary_joint: str = "",
clip_primary_target: float = 0.0,
clip_primary_measured: float = 0.0,
clip_primary_default: float = 0.0,
clip_primary_pos_err: float = 0.0,
clip_primary_raw: float = 0.0,
clip_primary_scaled: float = 0.0,
safety_reason: str = "",
guard_reason: str = "",
):
if self._closed:
return
if target_pose is None:
target_pose = np.zeros(16, dtype=np.float32)
if raw_action is None:
raw_action = np.zeros(16, dtype=np.float32)
now = time.time()
numeric_values = []
numeric_values += joint_pos.tolist()
numeric_values += joint_vel.tolist()
numeric_values += joint_torque.tolist()
numeric_values += target_pose.tolist()
numeric_values += raw_action.tolist()
numeric_values += gyro.tolist()
numeric_values += accel.tolist()
numeric_values += quat.tolist()
numeric_values += proj_gravity.tolist()
numeric_values += command.tolist()
numeric_values += [imu_age_ms, loop_dt_ms]
numeric_values += [safety_level, guard_level, holdover, stale_max, fresh_count, kp_scale, nan_flag]
numeric_values += [kp_leg_cmd, kd_leg_cmd, kd_wheel_cmd]
numeric_values += [runtime_release_alpha, runtime_release_hold_s, runtime_blend_ratio]
numeric_values += [hold_target_max_err, policy_target_max_err, hold_policy_max_gap]
numeric_values += [
stand_roll_deg,
stand_pitch_deg,
stand_roll_corr,
stand_pitch_corr,
1.0 if stand_pitch_comp_enabled else 0.0,
]
numeric_values += [_target_source_code(target_source)]
numeric_values += [_csv_numeric_joint_index(clip_primary_joint)]
numeric_values += [
clip_primary_target,
clip_primary_measured,
clip_primary_default,
clip_primary_pos_err,
clip_primary_raw,
clip_primary_scaled,
]
parts = [f"{now:.6f}", f"{now - self._t0:.6f}", phase]
parts += [f"{value:.6f}" for value in numeric_values]
parts += [_csv_escape(clip_primary_joint), _csv_escape(safety_reason), _csv_escape(guard_reason)]
self._enqueue(("state", ",".join(parts) + "\n"), drop_if_full=True)
def event(self, kind: str, **fields: Any):
if self._closed:
return
record = {"t": time.time(), "t_rel": time.time() - self._t0, "kind": kind}
for key, value in fields.items():
if isinstance(value, np.ndarray):
record[key] = value.tolist()
elif isinstance(value, (np.integer, np.floating)):
record[key] = value.item()
else:
record[key] = value
self._enqueue(("event", json.dumps(record, ensure_ascii=False) + "\n", kind != "STATE_TICK"), drop_if_full=False)
if kind != "STATE_TICK":
print(f"[Event {record['t_rel']:7.2f}s] {kind} {_short_fields(fields)}")
def flush(self):
if not self._closed:
self._queue.join()
self._state_fp.flush()
self._events_fp.flush()
def close(self):
if self._closed:
return
self.event("LOG_END")
self._queue.join()
self._closed = True
self._enqueue(("close",), drop_if_full=False, allow_after_closed=True)
self._writer_thread.join(timeout=2.0)
self._state_fp.flush()
self._state_fp.close()
self._events_fp.flush()
self._events_fp.close()
print(f"[Log] saved -> {self.dir}")
def _enqueue(self, item: tuple, drop_if_full: bool, allow_after_closed: bool = False):
if self._closed and not allow_after_closed:
return
try:
if drop_if_full:
self._queue.put_nowait(item)
else:
self._queue.put(item, timeout=0.2)
except queue.Full:
if item and item[0] == "state":
self._dropped_state_rows += 1
def _writer_loop(self):
while True:
item = self._queue.get()
try:
kind = item[0]
if kind == "close":
return
if kind == "state":
self._state_fp.write(item[1])
elif kind == "event":
self._events_fp.write(item[1])
if item[2]:
self._events_fp.flush()
finally:
self._queue.task_done()
def _csv_escape(text: str) -> str:
if not text:
return ""
return text.replace(",", ";").replace("\n", " ").replace("\r", " ")
def _csv_numeric_joint_index(joint_name: str) -> float:
if not joint_name:
return -1.0
try:
return float(LogBundle.JOINT_LABELS.index(joint_name))
except ValueError:
return -1.0
def _target_source_code(target_source: str) -> float:
mapping = {
"": -1.0,
"startup_hold": 0.0,
"stand_balance": 1.0,
"stand_hold": 1.5,
"runtime_hold": 2.0,
"runtime_blend": 3.0,
"runtime_policy": 4.0,
}
return mapping.get(target_source, 99.0)
def _short_fields(fields: Dict[str, Any]) -> str:
parts = []
for key, value in fields.items():
if isinstance(value, (list, tuple, np.ndarray)):
arr = np.asarray(value).ravel()
if arr.size > 4:
continue
try:
parts.append(f"{key}=[{','.join(f'{float(x):.2f}' for x in arr)}]")
except (TypeError, ValueError):
parts.append(f"{key}={list(arr)[:4]}")
elif isinstance(value, float):
parts.append(f"{key}={value:.3f}")
else:
parts.append(f"{key}={value}")
return " ".join(parts)
SimpleLogger = LogBundle
@@ -0,0 +1,111 @@
"""数学工具 — 与 rc_mjlab/sim2sim/tools/math_utils.py 数值完全一致。"""
from typing import Optional
import numpy as np
def get_gravity_orientation(quat_wxyz: np.ndarray) -> np.ndarray:
qw, qx, qy, qz = quat_wxyz
gx = 2.0 * (-qz * qx + qw * qy)
gy = -2.0 * (qz * qy + qw * qx)
gz = 1.0 - 2.0 * (qw * qw + qz * qz)
return np.array([gx, gy, gz], dtype=np.float32)
def quat_rotate_inverse(quat_wxyz: np.ndarray, v: np.ndarray) -> np.ndarray:
q_w = quat_wxyz[0]
q_vec = quat_wxyz[1:]
a = v * (2.0 * q_w * q_w - 1.0)
b = np.cross(q_vec, v) * q_w * 2.0
c = q_vec * np.dot(q_vec, v) * 2.0
return a - b + c
def quat_from_accel(accel: np.ndarray) -> np.ndarray:
"""用静止重力方向初始化机身姿态四元数。
思想:仿真启动时 quat = [1,0,0,0] 隐含"机身完全水平",但真机摆在地面上
pitch/roll 通常各自有几度偏差,会让 projected_gravity 一开始就错。
用加速度计读数与 [0,0,-1] 的最短旋转作为初值,可以把首步重力误差
降到 IMU 噪声级。
"""
g_meas = accel / (np.linalg.norm(accel) + 1e-9)
g_ref = np.array([0.0, 0.0, 1.0], dtype=np.float32)
cross = np.cross(g_ref, g_meas)
dot = float(np.dot(g_ref, g_meas))
if dot < -0.999999:
return np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32)
s = float(np.sqrt((1.0 + dot) * 2.0))
q = np.array([s * 0.5, cross[0] / s, cross[1] / s, cross[2] / s], dtype=np.float32)
return q / (np.linalg.norm(q) + 1e-9)
class LowPassFilter:
"""一阶 IIR 低通,alpha 公式与训练侧 rc_mjlab/src/robot/mdp/lowpass_actions.py
`_lowpass_weights` 完全一致:
alpha = 1 - exp(-2π · cutoff_freq / control_freq)
= 1 - exp(-2π · cutoff_freq · dt)
注意:这与 rc_mjlab/sim2sim/interface/mujoco_io.py 用的近似公式
(dt / (dt + 1/(2π·fc))) 数值上不同,在 15Hz 截止时差约 30%
我们以训练侧为准,因为策略是在那个滤波下学的。
"""
def __init__(self, cutoff_freq: float, dt: float, dim: int):
self.alpha = float(1.0 - np.exp(-2.0 * np.pi * cutoff_freq * dt))
self.y_prev = None
def filter(self, x: np.ndarray) -> np.ndarray:
if self.y_prev is None:
self.y_prev = x.copy()
y = self.alpha * x + (1.0 - self.alpha) * self.y_prev
self.y_prev = y.copy()
return y
def reset(self):
self.y_prev = None
class MahonyFilter:
"""互补滤波器:高频用陀螺仪积分,低频用加速度计修正。"""
def __init__(self, kp: float = 2.0, ki: float = 0.0, dt: float = 0.02):
self.kp = kp
self.ki = ki
self.dt = dt
self.q = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32)
self.e_int = np.zeros(3, dtype=np.float32)
def reset_with_accel(self, accel: np.ndarray):
self.q = quat_from_accel(accel)
self.e_int.fill(0.0)
def update(self, accel: np.ndarray, gyro: np.ndarray, dt: Optional[float] = None) -> np.ndarray:
if dt is None:
dt = self.dt
norm_a = float(np.linalg.norm(accel))
if norm_a > 1e-6:
a = accel / norm_a
q = self.q
v = np.array([
2.0 * (q[1] * q[3] - q[0] * q[2]),
2.0 * (q[0] * q[1] + q[2] * q[3]),
q[0] * q[0] - q[1] * q[1] - q[2] * q[2] + q[3] * q[3],
], dtype=np.float32)
e = np.cross(a, v)
if self.ki > 0.0:
self.e_int += e * dt
else:
self.e_int.fill(0.0)
gyro = gyro + self.kp * e + self.ki * self.e_int
q = self.q
q_dot = 0.5 * np.array([
-q[1] * gyro[0] - q[2] * gyro[1] - q[3] * gyro[2],
q[0] * gyro[0] + q[2] * gyro[2] - q[3] * gyro[1],
q[0] * gyro[1] - q[1] * gyro[2] + q[3] * gyro[0],
q[0] * gyro[2] + q[1] * gyro[1] - q[2] * gyro[0],
], dtype=np.float32)
self.q += q_dot * dt
self.q /= (np.linalg.norm(self.q) + 1e-9)
return self.q
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from input_dev.remote_uart import RemoteCommandSource # noqa: E402
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--port", required=True)
parser.add_argument("--max-vx", type=float, default=0.8)
parser.add_argument("--max-vy", type=float, default=0.3)
parser.add_argument("--max-yaw", type=float, default=0.5)
parser.add_argument("--hz", type=float, default=20.0)
parser.add_argument("--baudrate", type=int, default=100000)
parser.add_argument("--timeout", type=float, default=0.02)
parser.add_argument("--deadzone", type=int, default=50)
args = parser.parse_args()
remote = RemoteCommandSource(
port=args.port,
baudrate=args.baudrate,
timeout=args.timeout,
axis_deadzone=args.deadzone,
active_threshold=args.deadzone,
max_vx=args.max_vx,
max_vy=args.max_vy,
max_yaw=args.max_yaw,
)
remote.open()
print(f"[remote-test] listening on {args.port}")
try:
period = 1.0 / max(args.hz, 1.0)
while True:
remote.poll()
status = remote.get_status()
print(
"cmd=({:+.3f}, {:+.3f}, {:+.3f}) active={} estop={} raw=({}, {}, {}, {})".format(
status["cmd"][0],
status["cmd"][1],
status["cmd"][2],
status["command_active"],
status["estop_requested"],
status["ch1"],
status["ch2"],
status["ch3"],
status["ch4"],
)
)
time.sleep(period)
finally:
remote.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,105 @@
"""Check whether `sim2real/` is self-contained enough for direct deployment."""
from __future__ import annotations
import importlib
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
REQUIRED_FILES = [
"config.yaml",
"deployment_manifest.yaml",
"main.py",
"policy/policy_runner.py",
"interface/real_io.py",
"interface/imu_client.py",
"interface/motor_driver.py",
"vendored/drivers/motor_driver.py",
"vendored/drivers/usb_can_adapter.py",
"vendored/odin1_imu/odin1_imu.py",
"vendored/odin1_imu/build/libodin1_imu_bridge.so",
"mjcf/wheelleg.xml",
]
REQUIRED_IMPORTS = [
"numpy",
"yaml",
"serial",
]
OPTIONAL_IMPORTS = [
("onnxruntime", "required when deploying the default ONNX policy"),
("torch", "required only for exporting/checking .pt policies"),
("pynput", "only needed for CLI keyboard control"),
]
def check() -> int:
root = Path(__file__).resolve().parents[1]
issues: list[str] = []
warnings: list[str] = []
print(f"[Check] sim2real root: {root}")
for rel in REQUIRED_FILES:
path = root / rel
if path.exists():
print(f"[Check] file: PASS {rel}")
else:
issues.append(f"missing required file: {rel}")
if (root / "policies" / "model_rough.onnx").exists():
print("[Check] file: PASS policies/model_rough.onnx")
elif (root / "policies" / "model_rough.pt").exists():
warnings.append("policies/model_rough.onnx not found; runtime will fall back to .pt unless exported")
print("[Check] file: PASS policies/model_rough.pt fallback")
else:
issues.append("missing policy file: policies/model_rough.onnx or policies/model_rough.pt")
for module_name in REQUIRED_IMPORTS:
try:
importlib.import_module(module_name)
print(f"[Check] import: PASS {module_name}")
except Exception as exc:
issues.append(f"missing python dependency `{module_name}`: {exc}")
for module_name, note in OPTIONAL_IMPORTS:
try:
importlib.import_module(module_name)
print(f"[Check] optional import: PASS {module_name}")
except Exception:
warnings.append(f"optional dependency `{module_name}` not found ({note})")
index_html = (root / "web" / "static" / "index.html").read_text(encoding="utf-8")
if "https://unpkg.com/three@" in index_html:
warnings.append(
"web 3D viewer depends on remote three.js CDN; CLI/web backend are standalone, "
"but full offline 3D viewer is not bundled yet"
)
if issues:
print("\n" + "=" * 60)
print("Standalone deployment check: FAIL")
for item in issues:
print(f"- {item}")
else:
print("\n" + "=" * 60)
print("Standalone deployment check: PASS")
if warnings:
print("\nWarnings:")
for item in warnings:
print(f"- {item}")
return 1 if issues else 0
def main():
raise SystemExit(check())
if __name__ == "__main__":
main()
@@ -0,0 +1,45 @@
"""Offline checks for RS02 multi-turn angle wrapping in startup/control."""
from __future__ import annotations
from pathlib import Path
import sys
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from interface.motor_mapping import MotorMapping # noqa: E402
from startup.pose_initializer import STAND_POSE, _periodic_leg_delta # noqa: E402
def main() -> int:
mapper = MotorMapping()
# Real log example: startup saw rl_knee sim angle 4.648rad while the stand
# target was -1.8rad. Those are close modulo 2*pi and must not plan a full turn.
raw_sim = STAND_POSE.copy()
raw_sim[8] = 4.648097991943359
raw_real = mapper.sim_to_real(raw_sim)
canonical = mapper.real_to_sim({(2, 3): raw_real[(2, 3)]})
delta = _periodic_leg_delta(canonical, STAND_POSE)
real_target = mapper.sim_to_real(STAND_POSE, current_real_pos={(2, 3): raw_real[(2, 3)]})[(2, 3)]
real_move = real_target - raw_real[(2, 3)]
print(f"[WrapCheck] canonical_sim_idx8={canonical[8]:.6f}")
print(f"[WrapCheck] startup_delta_idx8={delta[8]:.6f}")
print(f"[WrapCheck] real_move_idx8={real_move:.6f}")
if abs(float(delta[8])) > 0.25:
print("[WrapCheck] FAIL: periodic startup delta is too large")
return 1
if abs(float(real_move)) > 0.25:
print("[WrapCheck] FAIL: real target would command a long-path move")
return 1
print("[WrapCheck] PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,54 @@
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from input_dev.remote_uart import ( # noqa: E402
RemoteCommandMapper,
RemoteControlState,
RemoteSwitchState,
SWITCH_HIGH,
SWITCH_MID,
)
def assert_close(actual: float, expected: float, tol: float = 1e-6) -> None:
if abs(actual - expected) > tol:
raise AssertionError(f"expected {expected}, got {actual}")
def test_deadzone() -> None:
mapper = RemoteCommandMapper(max_vx=0.8, max_vy=0.3, max_yaw=0.5, active_threshold=50)
state = RemoteControlState(ch1=40, ch2=-49, ch4=50, switches=RemoteSwitchState(ch7=SWITCH_MID), frame_ok=True)
cmd = mapper.map_command(state)
assert_close(float(cmd[0]), 0.0)
assert_close(float(cmd[1]), 0.0)
assert_close(float(cmd[2]), 0.0)
if mapper.is_command_active(state):
raise AssertionError("deadzone values should not be active")
def test_mapping() -> None:
mapper = RemoteCommandMapper(max_vx=0.8, max_vy=0.3, max_yaw=0.5, active_threshold=50)
state = RemoteControlState(ch1=330, ch2=-660, ch4=165, switches=RemoteSwitchState(ch7=SWITCH_MID), frame_ok=True)
cmd = mapper.map_command(state)
assert_close(float(cmd[0]), -0.8)
assert_close(float(cmd[1]), 0.075)
assert_close(float(cmd[2]), 0.25)
if not mapper.is_command_active(state):
raise AssertionError("mapped command should be active")
def test_soft_estop_flag() -> None:
state = RemoteControlState(ch1=0, ch2=0, ch4=0, switches=RemoteSwitchState(ch7=SWITCH_HIGH), frame_ok=True)
if not state.estop_requested:
raise AssertionError("switch high should request estop")
if __name__ == "__main__":
test_deadzone()
test_mapping()
test_soft_estop_flag()
print("remote command mapping tests passed")