[software] 添加16DOF早期训练仿真与Sim2Real闭环

This commit is contained in:
2026-07-21 16:15:14 +08:00
parent 9bd22225f9
commit e9e2c946b3
681 changed files with 137221 additions and 8 deletions
@@ -0,0 +1,136 @@
"""Odin1 IMU 客户端封装。
核心改动相对 sim_rl/odin1/python/odin1_imu.py
- 自动加载默认 .so 路径,调用方只需要 IMUClient(lib_path=...)
- 启动后做一次"重力对齐" — 用静止时的加速度计读数初始化 Mahony 滤波器,
把首步姿态偏差从可能的 5°+ 降到 0.3° 内。这是方法论 D4 的关键一步。
- 数据老化检测:若 imu_age_ms > stale_threshold 则报警(不阻塞)。
"""
import sys
import time
from pathlib import Path
from typing import Optional
import numpy as np
class IMUClient:
"""Odin1 IMU 包装。
Args:
lib_path: libodin1_imu_bridge.so 的绝对路径;None 则按方法论 1.2 中
约定的相对位置寻找。
gravity_align_samples: 启动时取多少帧加速度计平均值用于姿态初始化
stale_threshold_ms: 单帧数据超过该 age 视为陈旧
"""
def __init__(self, lib_path: Optional[str] = None, gravity_align_samples: int = 50,
stale_threshold_ms: float = 50.0):
# 优先级 1: vendored/odin1_imu(独立部署模式)
# 优先级 2: ../../odin1/odin1/python(开发模式,即 sim_rl/odin1/odin1/python
sim2real_root = Path(__file__).resolve().parents[1]
candidates = [
sim2real_root / "vendored" / "odin1_imu",
sim2real_root.parents[1] / "odin1" / "odin1" / "python",
]
for cand in candidates:
if cand.exists() and str(cand) not in sys.path:
sys.path.insert(0, str(cand))
break
try:
from odin1_imu import Odin1ImuClient # type: ignore
except ImportError as e:
raise ImportError(
f"无法导入 Odin1ImuClient,已尝试的路径: {[str(c) for c in candidates]}: {e}"
)
# lib_path 默认查找:vendored/odin1_imu/build/libodin1_imu_bridge.so → 开发路径
if lib_path is None:
so_candidates = [
sim2real_root / "vendored" / "odin1_imu" / "build" / "libodin1_imu_bridge.so",
sim2real_root / "vendored" / "odin1_imu" / "libodin1_imu_bridge.so",
sim2real_root.parents[1] / "odin1" / "odin1" / "build" / "libodin1_imu_bridge.so",
]
for so in so_candidates:
if so.exists():
lib_path = str(so)
break
self._client = Odin1ImuClient(lib_path=lib_path)
self._gravity_align_samples = gravity_align_samples
self._stale_threshold_ms = stale_threshold_ms
self._initial_gravity: Optional[np.ndarray] = None
# 用本机时钟追踪数据新鲜度(stamp_ns 是设备单调时钟,不能和 time.time 混算)
self._last_seq: int = -1
self._last_fresh_time: float = 0.0
def version(self) -> str:
return self._client.version()
def start(self, timeout_ms: int = 8000):
"""启动 IMU 流,并采集若干帧用于重力对齐。"""
self._client.start(timeout_ms=timeout_ms)
self._wait_for_stream()
self._initial_gravity = self._collect_gravity_samples()
self._last_fresh_time = time.time()
def stop(self):
try:
self._client.stop()
except Exception:
pass
@property
def initial_gravity(self) -> Optional[np.ndarray]:
"""启动后的初始重力向量(机身坐标系),用于初始化 Mahony 四元数。"""
return self._initial_gravity
def get_latest(self):
"""返回 (gyro[3], accel[3], age_ms, fresh)fresh=False 表示无新数据。"""
sample = self._client.get_latest()
if sample is None:
return (np.zeros(3, dtype=np.float32),
np.array([0.0, 0.0, 9.81], dtype=np.float32),
-1.0, False)
gyro = np.array([sample.gyro_x, sample.gyro_y, sample.gyro_z], dtype=np.float32)
accel = np.array([sample.accel_x, sample.accel_y, sample.accel_z], dtype=np.float32)
# 用 stamp_ns 判断是否有新数据,因为 sequence 字段在 C++ 中可能没有赋值,导致永远为 0
stamp = getattr(sample, "stamp_ns", 0)
now = time.time()
if stamp != self._last_seq:
self._last_seq = stamp
self._last_fresh_time = now
fresh = True
else:
fresh = False
age_ms = (now - self._last_fresh_time) * 1000.0
return gyro, accel, age_ms, fresh
# ---- 内部方法 ----
def _wait_for_stream(self, timeout: float = 3.0):
deadline = time.time() + timeout
while time.time() < deadline:
if self._client.wait_for_data(timeout_ms=200):
# 有数据进来后清空一次队列以保证后续 get_latest 拿到的都是最新
while self._client.pop_sample() is not None:
pass
return
raise RuntimeError("IMU 启动超时,未收到任何样本")
def _collect_gravity_samples(self) -> np.ndarray:
accels = []
for _ in range(self._gravity_align_samples):
sample = self._client.pop_sample()
if sample is None:
if not self._client.wait_for_data(timeout_ms=100):
continue
sample = self._client.pop_sample()
if sample is None:
continue
accels.append([sample.accel_x, sample.accel_y, sample.accel_z])
if not accels:
print("[IMU] 警告: 重力对齐期间未收到样本,使用默认重力 [0,0,-9.81]")
return np.array([0.0, 0.0, -9.81], dtype=np.float32)
gravity = np.mean(accels, axis=0).astype(np.float32)
print(f"[IMU] 重力对齐完成: g_body = {gravity}")
return gravity
@@ -0,0 +1,310 @@
"""RobStride 电机驱动包装。
职责:
- 封装 ik_real 中 RobStrideDriver 的 enable/disable/clear/control_mit 调用
- **真实的丢包检测**:旧版用「value=0 启发式」会误判(电机回机械零位时也是 0)。
新方案:
1. 调用 process_messages 前快照所有电机的 (pos, vel, torque)
2. 调用后比较:状态变了 → 这一帧有新反馈;状态完全没变 → 累计 stale_count
3. stale_count 超过阈值才沿用上一帧(方法论 3.4.2)
仍然不完美(电机长时间静止确实会有连续多帧 state 不变),但比 0 启发式可靠。
- 通过 driver_factory 由调用方注入:远程 Linux 主机用 RobStrideDriver
本地 Windows 调试可用 Mock。
"""
from dataclasses import dataclass
import threading
from typing import Callable, Dict, List, Optional, Tuple
import numpy as np
from interface.motor_mapping import MotorMapping
@dataclass
class MotorReading:
position: float
velocity: float
torque: float = 0.0
fresh: bool = False # True 表示本帧驱动板有新反馈
class HardwareIO:
"""统一的电机+IMU总线接口(不含策略),主控调用这一层。
Args:
driver_factory: () -> (drv1, drv2),由调用方注入;返回的对象需要满足:
connect()/disconnect()/disable(name)/enable(name)/clear_warnings(name)
add_motor(name, mid, model)/process_messages()
control_mit(name, q, dq, kp, kd, tau)
.motors: dict[name -> motor], motor.state.position / .velocity / .torque
config: yaml 解析后的字典
"""
def __init__(self, driver_factory: Callable[[str, str, bool], Tuple[object, object]],
motor_model: str, can1_port: str, can2_port: str, debug: bool = False,
stale_frames_to_holdover: int = 2):
self.mapper = MotorMapping()
drv1, drv2 = driver_factory(can1_port, can2_port, debug)
self.driver_can1 = drv1
self.driver_can2 = drv2
self.motor_model = motor_model
self.stale_frames_to_holdover = stale_frames_to_holdover
# 上一帧反馈(按 (bus, can_id) 索引),用于丢包兜底
self._last_pos: Dict[Tuple[int, int], float] = {}
self._last_vel: Dict[Tuple[int, int], float] = {}
self._last_torque: Dict[Tuple[int, int], float] = {}
# 每个电机连续多少帧没收到新反馈
self._stale_counts: Dict[Tuple[int, int], int] = {}
# 第一次必须读到才能解锁,避免初始化时直接用零位发送大力矩
self._initialized = False
self.lock = threading.Lock()
# 累计诊断
self.holdover_total = 0 # 累计被沿用上一帧的次数
# ---- 总线管理 ----
def connect(self):
self.driver_can1.connect()
self.driver_can2.connect()
for jk in self.mapper.SIM_JOINT_ORDER:
leg, joint = jk
bus, mid = self.mapper.CAN_ID_MAP[jk]
name = f"{leg}_{joint}"
drv = self.driver_can1 if bus == 1 else self.driver_can2
drv.add_motor(name, mid, self.motor_model)
self._stale_counts[(bus, mid)] = 0
def disconnect(self):
try:
self.driver_can1.disconnect()
finally:
self.driver_can2.disconnect()
def enable_all(self):
for drv in (self.driver_can1, self.driver_can2):
for name in drv.motors:
drv.clear_warnings(name)
drv.enable(name)
def disable_all(self):
for drv in (self.driver_can1, self.driver_can2):
for name in drv.motors:
drv.disable(name)
# ---- 状态读取 ----
def _snapshot_state(self) -> Dict[Tuple[int, int], Tuple[float, float, float, int]]:
"""快照所有电机的 (pos, vel, torque, update_count)process_messages 前后比较即可判 fresh。"""
snap: Dict[Tuple[int, int], Tuple[float, float, float, int]] = {}
for drv_idx, drv in enumerate((self.driver_can1, self.driver_can2)):
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 self.mapper.CAN_ID_MAP:
continue
_, mid = self.mapper.CAN_ID_MAP[key]
s = motor.state
snap[(bus, mid)] = (s.position, s.velocity, s.torque, getattr(s, "update_count", 0))
return snap
def read_state(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, Dict[str, object]]:
"""返回 (sim_joint_pos[16], sim_joint_vel[16], sim_joint_torque[16], debug_info)。"""
with self.lock:
# 1) 抓取上一次的状态作为「pre」快照(基线)
pre = self._snapshot_state()
# 2) 拉取本帧反馈
self.driver_can1.process_messages()
self.driver_can2.process_messages()
# 3) 抓取「post」快照
post = self._snapshot_state()
# 4) 比较:state 元组变了 → 本帧有新反馈,stale_count 清零;否则 stale_count++
per_motor_fresh: Dict[Tuple[int, int], bool] = {}
for key in post:
fresh = (pre.get(key) != post[key])
per_motor_fresh[key] = fresh
if fresh:
self._stale_counts[key] = 0
else:
self._stale_counts[key] += 1
# 5) 取出本帧 pos/vel;若该电机连续多帧没刷新,沿用上一帧(方法论 3.4.2)
real_pos: Dict[Tuple[int, int], float] = {}
real_vel: Dict[Tuple[int, int], float] = {}
real_torque: Dict[Tuple[int, int], float] = {}
holdover_this_frame = 0
for key, (pos, vel, tor, _) in post.items():
if (not per_motor_fresh[key]) and self._stale_counts[key] >= self.stale_frames_to_holdover:
# 长时间不刷新视作丢包:沿用上一帧
if key in self._last_pos:
real_pos[key] = self._last_pos[key]
real_vel[key] = self._last_vel[key]
real_torque[key] = self._last_torque[key]
holdover_this_frame += 1
else:
real_pos[key] = pos
real_vel[key] = vel
real_torque[key] = tor
else:
real_pos[key] = pos
real_vel[key] = vel
real_torque[key] = tor
self.holdover_total += holdover_this_frame
# 缓存本帧(即便部分是 holdover 也缓存)
self._last_pos = real_pos.copy()
self._last_vel = real_vel.copy()
self._last_torque = real_torque.copy()
if not self._initialized:
self._initialized = True
cur_pos = self.mapper.real_to_sim(real_pos)
cur_vel = self.mapper.real_vel_to_sim(real_vel)
cur_torque = self.mapper.real_vel_to_sim(real_torque)
# 诊断信息
stale_max = max(self._stale_counts.values()) if self._stale_counts else 0
n_stale_motors = sum(1 for c in self._stale_counts.values()
if c >= self.stale_frames_to_holdover)
# 按 SIM_JOINT_ORDER 排列的每个电机连续丢帧数
per_motor_stale = [
self._stale_counts.get(self.mapper.CAN_ID_MAP[jk], 99)
for jk in self.mapper.SIM_JOINT_ORDER
]
return cur_pos, cur_vel, cur_torque, {
"holdover_this_frame": holdover_this_frame,
"stale_max": stale_max,
"n_stale_motors": n_stale_motors,
"fresh_count": sum(1 for v in per_motor_fresh.values() if v),
"per_motor_stale": per_motor_stale,
}
def passive_poll(self):
"""发送全 0 (0刚度0阻尼0力矩) 的 MIT 指令给所有电机。
目的:在 ENABLED 状态下,不产生力矩地索要反馈(因为 RobStride 在 MIT 模式下必须有指令才反馈)。"""
with self.lock:
for jk in self.mapper.SIM_JOINT_ORDER:
bus, mid = self.mapper.CAN_ID_MAP[jk]
name = f"{jk[0]}_{jk[1]}"
drv = self.driver_can1 if bus == 1 else self.driver_can2
if name in drv.motors:
drv.control_mit(name, 0.0, 0.0, 0.0, 0.0, 0.0)
# ---- 控制下发 ----
def send_control(self, target_angles: np.ndarray, kp_leg: float, kd_leg: float,
kd_wheel: float):
"""与 sim2sim 的 PD 模型对齐:
- 腿: position 控制,目标角度由 target_angles[:12] 给出,kp/kd 来自配置
- 轮: velocity 控制,目标速度由 target_angles[12:] 给出,kd 阻尼
"""
with self.lock:
if target_angles.shape != (16,):
raise ValueError("target_angles must be (16,)")
real_targets = self.mapper.sim_to_real(target_angles.astype(np.float32))
# 轮毂速度目标暂且用 0,如果 target_angles 里包含了速度,就在 policy 那里处理,
# 这里的 target_angles 是 pose 目标,轮毂作为连续旋转关节其实位置控制没有意义。
# 为了兼容旧代码,这里构造一个 16 维的 velocity array,只有后 4 个是目标(如果当作速度的话)。
vel_targets = np.zeros(16, dtype=np.float32)
vel_targets[12:] = target_angles[12:].astype(np.float32)
real_wheel = self.mapper.sim_vel_to_real(vel_targets)
for jk in self.mapper.SIM_JOINT_ORDER:
leg, joint = jk
bus, mid = self.mapper.CAN_ID_MAP[jk]
name = f"{leg}_{joint}"
drv = self.driver_can1 if bus == 1 else self.driver_can2
if name not in drv.motors:
continue
if joint == "wheel":
v = real_wheel[(bus, mid)]
drv.control_mit(name, 0.0, v, 0.0, kd_wheel, 0.0)
else:
q = real_targets[(bus, mid)]
drv.control_mit(name, q, 0.0, kp_leg, kd_leg, 0.0)
def damping_brake(self, kd_leg: float, kd_wheel: float):
"""急停模式:所有关节卸载刚度,仅保留阻尼。
对应 270_SimToReal 方法论 97.11 Level 2 "刹车"
"""
with self.lock:
for jk in self.mapper.SIM_JOINT_ORDER:
leg, joint = jk
bus, _ = self.mapper.CAN_ID_MAP[jk]
name = f"{leg}_{joint}"
drv = self.driver_can1 if bus == 1 else self.driver_can2
if name not in drv.motors:
continue
kd = kd_wheel if joint == "wheel" else kd_leg
drv.control_mit(name, 0.0, 0.0, 0.0, kd, 0.0)
def wait_feedback_ready(self, max_attempts: int = 20,
poll_interval: float = 0.05) -> Tuple[bool, list]:
"""enable 后调用:尝试 max_attempts 次读总线,等所有 16 个电机
都至少给出一帧反馈。
返回 (all_ready, missing_motors)missing_motors 是 (bus, mid, name) 列表。
"""
import time
seen: Dict[Tuple[int, int], bool] = {
self.mapper.CAN_ID_MAP[jk]: False for jk in self.mapper.SIM_JOINT_ORDER
}
# 用第一次读到的 (pos, vel, torque) 三元组的"非零"或"已变化"作为反馈到达的判据。
# 启动瞬间所有 motor.state 默认全 0,要么收到反馈让其变化,要么收到反馈但值确实是 0。
# 退化情况下电机静止时 vel=0 且 pos=机械零位也=0,那种情况只能等多帧确认。
snap_prev = self._snapshot_state()
for attempt in range(max_attempts):
with self.lock:
self.driver_can1.process_messages()
self.driver_can2.process_messages()
snap_cur = self._snapshot_state()
for key, fields_cur in snap_cur.items():
if seen[key]:
continue
fields_prev = snap_prev.get(key)
# 任一字段不为 0 → 一定有反馈(因为初始值都是 0)
if any(v != 0.0 for v in fields_cur):
seen[key] = True
# 与上一次快照不同 → 一定有反馈(即便都很小)
elif fields_prev is not None and fields_cur != fields_prev:
seen[key] = True
snap_prev = snap_cur
if all(seen.values()):
return True, []
time.sleep(poll_interval)
# 超时:列出仍未反馈的电机
missing = []
rev_can = {v: k for k, v in self.mapper.CAN_ID_MAP.items()}
for key, ok in seen.items():
if not ok:
leg, joint = rev_can[key]
missing.append((key[0], key[1], f"{leg}_{joint}"))
return False, missing
def read_measured_pose(self) -> np.ndarray:
"""返回 (16,) 当前实测 sim 坐标系下的关节位置。
会先 process_messages 一次保证拿到本帧。
"""
self.driver_can1.process_messages()
self.driver_can2.process_messages()
real_pos: Dict[Tuple[int, int], float] = {}
for drv_idx, drv in enumerate((self.driver_can1, self.driver_can2)):
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 self.mapper.CAN_ID_MAP:
continue
_, mid = self.mapper.CAN_ID_MAP[key]
real_pos[(bus, mid)] = motor.state.position
return self.mapper.real_to_sim(real_pos)
@@ -0,0 +1,99 @@
"""仿真→实机电机映射。
数据来源:sim_rl/ik_real/sim_to_real_deploy_beifen.py 和
sim_rl/sim2real/motor_mapping.py 中的 sign / offset / can_id 表(已在实机上验证)。
关节顺序与 rc_mjlab/sim2sim 完全一致:[12 个腿关节] + [4 个轮子]。
"""
from typing import Dict, Tuple
import numpy as np
class MotorMapping:
LEG_NAMES = ("fl", "fr", "rl", "rr")
JOINT_NAMES = ("hip_abduction", "hip_pitch", "knee", "wheel")
SIM_JOINT_ORDER = (
("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"),
)
SIM_INDEX_MAP = {jk: i for i, jk in enumerate(SIM_JOINT_ORDER)}
CAN_ID_MAP: Dict[Tuple[str, str], Tuple[int, int]] = {
("fl", "hip_abduction"): (1, 1), ("fl", "hip_pitch"): (1, 2),
("fl", "knee"): (1, 3), ("fl", "wheel"): (1, 4),
("fr", "hip_abduction"): (1, 5), ("fr", "hip_pitch"): (1, 6),
("fr", "knee"): (1, 7), ("fr", "wheel"): (1, 8),
("rl", "hip_abduction"): (2, 1), ("rl", "hip_pitch"): (2, 2),
("rl", "knee"): (2, 3), ("rl", "wheel"): (2, 4),
("rr", "hip_abduction"): (2, 5), ("rr", "hip_pitch"): (2, 6),
("rr", "knee"): (2, 7), ("rr", "wheel"): (2, 8),
}
DIRECTION_MAP: Dict[Tuple[str, str], int] = {
("fl", "hip_abduction"): -1, ("fl", "hip_pitch"): -1,
("fl", "knee"): -1, ("fl", "wheel"): -1,
("fr", "hip_abduction"): -1, ("fr", "hip_pitch"): 1,
("fr", "knee"): 1, ("fr", "wheel"): 1,
("rl", "hip_abduction"): 1, ("rl", "hip_pitch"): -1,
("rl", "knee"): -1, ("rl", "wheel"): -1,
("rr", "hip_abduction"): 1, ("rr", "hip_pitch"): 1,
("rr", "knee"): 1, ("rr", "wheel"): 1,
}
ZERO_OFFSET_MAP: Dict[Tuple[str, str], float] = {
("fl", "hip_abduction"): 0.003, ("fl", "hip_pitch"): 0.030,
("fl", "knee"): 0.028, ("fl", "wheel"): 0.000,
("fr", "hip_abduction"): 0.004, ("fr", "hip_pitch"): 0.038,
("fr", "knee"): 0.011, ("fr", "wheel"): 0.000,
("rl", "hip_abduction"): 0.019, ("rl", "hip_pitch"): -0.034,
("rl", "knee"): 0.025, ("rl", "wheel"): 0.000,
("rr", "hip_abduction"): -0.001, ("rr", "hip_pitch"): 0.039,
("rr", "knee"): 0.018, ("rr", "wheel"): 0.000,
}
def __init__(self):
self.num_motors = len(self.SIM_JOINT_ORDER)
self._sign = np.array([self.DIRECTION_MAP[jk] for jk in self.SIM_JOINT_ORDER], dtype=np.float32)
self._offset = np.array([self.ZERO_OFFSET_MAP[jk] for jk in self.SIM_JOINT_ORDER], dtype=np.float32)
def sim_to_real(self, sim_angles: np.ndarray) -> Dict[Tuple[int, int], float]:
if len(sim_angles) != 16:
raise ValueError(f"expected 16 sim angles, got {len(sim_angles)}")
out: Dict[Tuple[int, int], float] = {}
for i, jk in enumerate(self.SIM_JOINT_ORDER):
real = float(self._sign[i] * sim_angles[i] + self._offset[i])
out[self.CAN_ID_MAP[jk]] = real
return out
def sim_vel_to_real(self, sim_vels: np.ndarray) -> Dict[Tuple[int, int], float]:
# 速度只受方向影响,不应用 offset。
out: Dict[Tuple[int, int], float] = {}
for i, jk in enumerate(self.SIM_JOINT_ORDER):
out[self.CAN_ID_MAP[jk]] = float(self._sign[i] * sim_vels[i])
return out
def real_to_sim(self, real_pos: Dict[Tuple[int, int], float]) -> np.ndarray:
out = np.zeros(16, dtype=np.float32)
for i, jk in enumerate(self.SIM_JOINT_ORDER):
v = real_pos.get(self.CAN_ID_MAP[jk])
if v is None:
continue
out[i] = (v - self._offset[i]) / self._sign[i]
return out
def real_vel_to_sim(self, real_vel: Dict[Tuple[int, int], float]) -> np.ndarray:
out = np.zeros(16, dtype=np.float32)
for i, jk in enumerate(self.SIM_JOINT_ORDER):
v = real_vel.get(self.CAN_ID_MAP[jk])
if v is None:
continue
out[i] = v / self._sign[i]
return out
def joint_name_at(self, idx: int) -> str:
leg, joint = self.SIM_JOINT_ORDER[idx]
return f"{leg}_{joint}_joint"
@@ -0,0 +1,135 @@
import time
from typing import Callable, Dict, Tuple
import numpy as np
from interface.imu_client import IMUClient
from interface.motor_driver import HardwareIO
from tools.math_utils import LowPassFilter, MahonyFilter, get_gravity_orientation
class RealIO:
def __init__(
self,
driver_factory: Callable[[str, str, bool], Tuple[object, object]],
motor_model: str,
can1_port: str,
can2_port: str,
imu_lib_path: str,
control_dt: float = 0.02,
kp_leg: float = 80.0,
kd_leg: float = 2.5,
kd_wheel: float = 2.0,
debug: bool = False,
):
self.control_dt = control_dt
self.kp_leg = kp_leg
self.kd_leg = kd_leg
self.kd_wheel = kd_wheel
print("[RealIO] 初始化电机驱动...")
self.hw = HardwareIO(driver_factory, motor_model, can1_port, can2_port, debug)
print("[RealIO] 初始化 IMU...")
self.imu = IMUClient(lib_path=imu_lib_path)
self.imu_filter = MahonyFilter(kp=2.0, ki=0.0, dt=control_dt)
self.quat_wxyz = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32)
self.lpf_legs = LowPassFilter(cutoff_freq=5.0, dt=control_dt, dim=12)
self.lpf_wheels = LowPassFilter(cutoff_freq=15.0, dt=control_dt, dim=4)
self._last_imu_age_ms = -1.0
self._last_imu_fresh = False
def connect(self, imu_timeout_ms: int = 8000):
self.hw.connect()
self.imu.start(timeout_ms=imu_timeout_ms)
if self.imu.initial_gravity is not None:
self.imu_filter.reset_with_accel(self.imu.initial_gravity)
self.quat_wxyz = self.imu_filter.q.copy()
def disconnect(self):
try:
self.hw.disable_all()
finally:
self.imu.stop()
self.hw.disconnect()
def enable_motors(self):
self.hw.enable_all()
def disable_motors(self):
self.hw.disable_all()
def damping_brake(self):
self.hw.damping_brake(self.kd_leg, self.kd_wheel)
def wait_feedback_ready(self, max_attempts: int = 20, poll_interval: float = 0.05):
return self.hw.wait_feedback_ready(max_attempts=max_attempts, poll_interval=poll_interval)
def read_measured_pose(self) -> np.ndarray:
return self.hw.read_measured_pose()
def read_state(self) -> Dict[str, object]:
joint_pos, joint_vel, joint_torque, motor_diag = self.hw.read_state()
gyro, accel, age_ms, fresh = self.imu.get_latest()
self._last_imu_age_ms = age_ms
self._last_imu_fresh = fresh
self.quat_wxyz = self.imu_filter.update(accel, gyro)
projected_gravity = get_gravity_orientation(self.quat_wxyz)
return {
"joint_pos": joint_pos,
"joint_vel": joint_vel,
"joint_torque": joint_torque,
"imu_gyro": gyro,
"imu_accel": accel,
"quat_wxyz": self.quat_wxyz.copy(),
"projected_gravity": projected_gravity,
"imu_age_ms": age_ms,
"imu_fresh": fresh,
"motor_stale": motor_diag,
}
def get_obs_policy(
self,
state: Dict[str, object],
command: np.ndarray,
default_dof_pos: np.ndarray,
last_actions_raw: np.ndarray,
) -> np.ndarray:
gyro = state["imu_gyro"]
joint_pos = state["joint_pos"]
joint_vel = state["joint_vel"]
projected_gravity = state["projected_gravity"]
base_ang_vel = (gyro * 0.25).astype(np.float32)
joint_pos_rel = (joint_pos[:12] - default_dof_pos[:12]).astype(np.float32)
joint_vel_leg = (joint_vel[:12] * 0.05).astype(np.float32)
wheel_vel = (joint_vel[12:] * 0.05).astype(np.float32)
return np.concatenate(
[
base_ang_vel,
projected_gravity,
command.astype(np.float32),
joint_pos_rel,
joint_vel_leg,
wheel_vel,
last_actions_raw,
]
).astype(np.float32)
def send_actions(self, scaled_actions: np.ndarray, default_dof_pos: np.ndarray):
act = (scaled_actions + default_dof_pos).astype(np.float32)
act = np.clip(act, -100.0, 100.0)
act[:12] = self.lpf_legs.filter(act[:12])
act[12:] = self.lpf_wheels.filter(act[12:])
self.hw.send_control(act, self.kp_leg, self.kd_leg, self.kd_wheel)
return act
def hold_pose(self, sim_target_pose: np.ndarray, kp_scale: float = 1.0):
target = np.clip(sim_target_pose.astype(np.float32), -100.0, 100.0)
kp_scale = float(np.clip(kp_scale, 0.0, 1.0))
self.hw.send_control(target, self.kp_leg * kp_scale, self.kd_leg, self.kd_wheel)
return target