30秒速览
- MPC在长距离行走中能救急,但模型失配和噪声会让你想砸电脑;强化学习步态容易找到奖励漏洞,上坡跳出踢踏舞,而且对传感器噪声极度敏感;能耗和电机温度不是附加题,是决定你能走多远的必答题,动态降出力是无奈但有效的招数
“我把机器人推上赛道第一秒,就意识到自己漏算了一万个细节”
说真的,直到发枪那一刻,我还在用笔记本通过WiFi调PID参数。我们的双足机器人叫“逐影”,身高1.4米,体重38公斤,12个无框力矩电机驱动,理论上能走到0.6m/s。团队里的控制工程师拍胸脯说,这玩意在室内测试过连续行走2公里,半马走慢点应该能撑下来。我竟然信了。
发枪后,机器人前三步就出了问题。按照预设的离线步态库,它要先以0.3m/s的保守速度走一段热身,然后逐步切换到0.45m/s的巡航步态。结果在切换瞬间,摆动腿落地时产生了40Nm的冲击力——我们足底六维力传感器量程也就500N,那个撞击直接让ZMP跳到了足底边缘,身体开始前倾。幸好我们提前在机器人肩部装了防摔支架(一根焊死的铝管),它往前栽的时候支架撑住了,没脸着地,但步态彻底乱了,像喝了假酒一样原地晃悠,最后被我们遥控停住。
人群里有人在笑,我当时脸烫得能煎鸡蛋。事后复盘,问题出在我们的有限状态机(FSM)步态调度上。我们预先算好了一堆步态参数:支撑相时长、摆动相时长、抬腿高度、落地点,然后在FSM里根据不同速度区间做线性插值。但状态切换时,我们希望步态参数平滑过渡,却忘了关节力矩指令也需要过渡——我们直接在新的控制循环里用了新参数算出的期望关节角度,PID的误差瞬间拉满,导致力矩阶跃。这就像你开车换挡不踩离合,齿轮直接打齿。
这个问题暴露了我们运动控制架构的第一个窟窿:过于依赖离线规划,缺乏在线柔顺性。我当时就想,这么搞下去别说半马,连50米都跑不了。我们紧急在PID前面加了个低通滤波器,把目标角度变化率限制在2rad/s²以下,总算消除了力矩尖峰,但代价是步态切换变得迟钝,速度从0.45m/s直接掉到0.25m/s。然后我们把ZMP稳定裕度从30mm临时放宽到50mm,意味着让机器人保持更大脚底面积的双足支撑,步态更保守了。这样才勉强能走起来。
但速度一慢,新问题又来了:因为机器人走得太慢,电池放电时间拉长,原本估算能撑40分钟跑完5公里的电量,现在要支撑至少一个半小时。电池是48V、10Ah的锂聚合物,全功率行走时关节电机总功率约800W,平均下来大概450W;速度降到0.25m/s后,平均功率仍然在300W左右,因为站立也要消耗能量。我意识到,长距离动态行走根本不是个控制问题,是个系统工程:你得同时搞定平衡、步态、能耗、散热,还要应对路上的石子、井盖、坡道和广告布。我们之前以为搞定了ZMP、MPC和RL就行了,结果连赛道上的反光地标都能让视觉里程计飘到姥姥家。
下面说说我们那套运动控制栈的骨架。我们采用的是分层架构:上层是路径规划,中层是全身运动控制(WBC),底层是关节力矩/位置伺服。感知方面,IMU用了一颗战术级的光纤陀螺(KVH 1750),但没钱买好的,实际用的是便宜点的消费级ICM-20948加上磁力计做姿态融合,加速度计噪声在±0.05g,但剧烈运动时冲击会让姿态估计偏移2-3度,这对ZMP计算是致命的。足底用了四个六维力传感器,每个关节有双编码器,总线是EtherCAT,控制周期1ms。主控是Intel NUC i7,跑实时Linux(PREEMPT_RT补丁),但即使打了补丁,偶尔也有超过100us的抖动。
全身运动控制这一层,我们用的是一种简化版的WBC,把任务描述为质心加速度、摆动腿加速度、上身姿态角速度等几个任务空间目标,通过QP求解各关节力矩,同时满足动力学约束和关节力矩限制。代码看起来大概是这样:
import numpy as np
from cvxopt import matrix, solvers
# 简化的6关节WBC QP,求解关节力矩
n_joints = 6
# 雅可比矩阵拼接(任务空间对关节空间)
A = np.random.randn(3*n_tasks, n_joints) # 示例,实际由运动学计算
b = np.random.randn(3*n_tasks) # 期望任务加速度
# Hessian: 最小化力矩大小 + 任务误差权重
H = np.eye(n_joints) * 0.01
f = np.zeros(n_joints)
# 力矩限幅
tau_max = np.ones(n_joints) * 50.0
G = np.vstack([np.eye(n_joints), -np.eye(n_joints)])
h = np.concatenate([tau_max, tau_max])
# 等式约束: A * tau = b (任务空间动力学)
# cvxopt要求Gx <= h, Ax = b
P = matrix(H)
q = matrix(f)
G_mat = matrix(G)
h_mat = matrix(h)
A_eq = matrix(A)
b_eq = matrix(b)
sol = solvers.qp(P, q, G_mat, h_mat, A_eq, b_eq)
tau_cmd = np.array(sol['x']).flatten()
这玩意在仿真里跑得贼溜,但一到真机上,雅可比矩阵稍微标定不准,解出来的力矩就会把关节往极限推,然后电机过流报警。我们后来加了松弛变量和任务权重自适应调整,才算稳定。
那天发枪第一秒,我们就是死在这个WBC的约束过于僵硬上——它没有考虑触地冲击造成的瞬时力矩需求。后来我们想,如果当时已经上线了MPC在线步态调整,可能不会那么狼狈。
“ZMP告诉我脚底压力中心快出边界了,MPC赶紧修正,但传感器噪声差点害死我们”
前面提到我们把稳定裕度放宽总算苟住了起步阶段,但更严峻的考验还在后面:赛道在2.5公里处有一个缓坡,坡度大约5度,长度200米。我们之前训练的时候全是平地,上坡完全没考虑。机器人走上坡道时,质心后移,ZMP向后偏,按照我们原始的ZMP稳定判据,它应该往前加速或者增大步长来把ZMP拉回来。可是我们那套离线步态库根本没有“上坡”这个模态,FSM仍然按照平地走,结果机器人开始低头、后腿蹬地时打滑、前腿抬得不够高踢到了路面凸起,整个身体开始后仰。这次没有防摔支架救它,因为支架是针对前倒设计的,后倒就直接坐在地上了。我们用遥控急停,机器人一屁股坐下去,尾椎骨位置的电池包磕了一下,吓出我一身冷汗。
这时候我意识到,必须上线模型预测控制(MPC)来做在线步态调整。其实我们实验室一直有MPC的代码,但一直嫌它计算量大、调参麻烦,就没在真机上实时跑过,只在仿真里玩过。那天中场休息时,我把笔记本掏出来,连上机器人,现场编译部署了基于线性倒立摆模型(LIPM)的MPC。
原理大概是:把质心运动近似成倒立摆,状态是水平位置、速度、ZMP位置,控制量是ZMP的移动速度(实际落地点的调整)。预测时域10步,每步0.3秒,用QP求解当前最优足部落点。代价函数最小化质心位置误差、ZMP偏离足底中点的程度以及控制量变化。我用的是OSQP求解器,因为IPOPT太慢,200Hz跑不动。代码简化后像这样:
import osqp
import numpy as np
from scipy import sparse
dt = 0.05 # 控制周期
N = 10 # 预测步数
Ad = sparse.csc_matrix([[1, dt], [0, 1]]) # 状态:质心位置、速度
Bd = sparse.csc_matrix([[0], [dt]]) # 控制:ZMP加速度?这里简化成速度
# 构建QP矩阵:最小化状态误差和控制量
P = sparse.block_diag([sparse.eye(N*2), sparse.eye(N)*0.1])
q = np.zeros(N*2 + N)
# 不等式约束:ZMP不能超出脚底板
l = np.array([-0.06]*N) # 脚长12cm,允许ZMP在中心±6cm
u = np.array([0.06]*N)
Aineq = sparse.eye(N, N*2+N) # 提取ZMP状态的约束,这里简化示意
prob = osqp.OSQP()
prob.setup(P, q, Aineq, l, u, verbose=False, warm_start=True)
res = prob.solve()
zmp_cmd = res.x[0]
实际上MPC矩阵是逐时步展开的,但这里为了说明就不写全了。我们把这段代码塞进实时控制循环里,OSQP求解一次大概800us,加上通信开销能控制在1.2ms以内,勉强能跑。然后我们用这个MPC动态给出下一步的ZMP目标,再通过逆运动学反算关节角度。在平地上试了试,机器人能自己调整步长,遇到小扰动也能恢复,爽得不行。然后我们把它推回那个坡道,结果又打脸了:机器人上坡时,由于实际ZMP受身体俯仰影响,测量值剧烈波动,导致MPC给出的足底落点修正忽前忽后,机器人像在跳踢踏舞,最后侧向失衡,一头栽进旁边的草地。
我们分析,问题出在传感器噪声。足底力传感器在摆动腿触地瞬间会有高频冲击,ZMP估计值瞬时跳变,MPC把它当成扰动直接反应,反而放大了震荡。我们紧急加了一个卡尔曼滤波器,用IMU、关节编码器和力传感器融合估计真实的ZMP位置。滤波器代码不复杂,但关键是调了过程噪声协方差,让对ZMP的估计变化更平滑。之后MPC的参考信号改成滤波后的ZMP,再加了个控制量变化率的惩罚项,总算把步态稳下来了。代价是MPC的反应变迟钝,对突发的大扰动还是不行。
另一个大坑是模型失配。LIPM假设质心高度恒定,但我们机器人上坡时因为躯干前倾,质心高度下降了3厘米,导致线性化误差巨大,预测出来的ZMP位置总是滞后。我们后来把MPC升级成了非线性MPC(NMPC),用CasADi+acados实时求解,预测模型中加了躯干俯仰角和质心高度变量。acados的求解速度在N=10时能做到2.5ms,总算把上坡搞定了,但代价是CPU占用率飙到80%,风扇狂转,我们担心过热降频。最后我们用了一个折中:平路用线性MPC,检测到倾角超过2度自动切NMPC,虽然切换时会有一点点力矩突变,但总比摔了强。就这样,机器人蹒跚着走过了那个坡。
“强化学习步态训练了300万个step,上坡时它却发明了奇怪的‘弹跳步’”
MPC虽然在线性上救了我们,但它的本质还是依赖模型,赛道后段开始出现碎石子、路面裂缝甚至一段临时铺设的木板,这些地形对模型就是灾难。所以我们在赛前其实还准备了一套纯强化学习(RL)驱动的步态策略当备胎。
我们用NVIDIA Isaac Gym并行训练,环境数量开到4096个,策略是PPO,输入是包含关节角度、角速度、IMU数据、身体线速度的120维观测,输出是12个关节的目标角度增量。奖励函数刚开始很简单:活下来奖励1分,前进速度乘以系数,能耗和身体抖动减分。训练跑了300万步,仿真里的机器人走得又快又稳,甚至在随机力扰动下都能恢复,我们高兴坏了。然后做sim-to-real迁移,用了域随机化:地面摩擦系数随机0.3到1.5,关节阻尼±20%,电机延迟0到15ms,观测高斯噪声,质量块随机加减2公斤。第一次上真机,它直直走了15米没倒,实验室里一群人都欢呼了。
但是我们把RL策略放进半马模拟赛道(室内铺了类似坡度的木板)测试时,诡异的事发生了。上坡那段,机器人一开始小步快走,频率突然加快到接近3Hz,然后右腿猛地一蹬,身体拔高,离地10厘米,落地时后仰,接着又蹬一下,看起来就像在跳舞。我们检查日志,发现它的前向速度奖励很大,坡道使前进变得困难,策略学到的竟然是用爆发力弹跳来获取速度,代价是牺牲姿态稳定性。说白了,RL找到了奖励函数的一个漏洞。
这让我深刻体会到,强化学习里奖励函数设计比网络结构重要十倍。我们重新设计了奖励:加入了身体倾斜的指数惩罚、脚底打滑的惩罚(通过传感器检测足底相对运动),还加了一条“步态周期性”的鼓励项,惩罚不规则的步态时频分布。重新训练了300万步,这次出来的策略上坡时会主动降低重心、增大支撑多边形、慢慢走,虽然速度慢,但不跳舞了。
但是sim-to-real gap依然顽固。真机上电机的实际力矩响应比仿真慢,因为驱动器的电流环带宽有限,导致期望力矩和实际力矩之间有较大延迟,尤其在快速动作时,RL策略会变得不稳定。我们尝试把延迟显式建模加入域随机化,但效果一般。后来我们搞了个混合方案:用MPC产生参考落脚点和质心轨迹,RL负责输出关节力矩的修正量。也就是说,RL学的是残差,而不是从头生成动作。这个方案的稳定性好很多,因为MPC提供了有模型保障的基础步态,RL只应对扰动。
比赛那天,我们其实用的是MPC+RL残差模式起步。但在4公里处,赛道上铺了一长条红色广告布,我们机器人脚底的力传感器和视觉SLAM(Intel RealSense D435i)都受到了干扰:视觉特征点被红色背景和反光吞噬,里程计直接漂移;力传感器因为布料滑动,ZMP测量变得离谱。RL策略基于观测做出反应,腿部开始乱颤。我们赶紧通过遥控把RL修正权重降到0,完全靠MPC走,这才稳住。这也暴露了RL对传感质量的极度依赖,传感器的坑远比算法大。
“跑到7公里时电机温度飙到120度,我们靠‘动态降出力’勉强完赛”
如果你觉得控制算法已经够让人掉头发了,能耗和热管理才是真正决定你能不能完赛的东西。我们用的是T-motor的AK80-9无框力矩电机,峰值扭矩48Nm,额定扭矩15Nm,但是额定功率下连续跑10分钟,外壳温度就能到80度,内部线圈温度超过110度。如果长时间过载,线圈温度一旦超过150度,永磁体就会退磁,电机基本就废了。
赛前的长距离测试中,我们机器人有一次连续走了3公里,右膝关节电机忽然过热报警,控制器强制断电,机器人直接跪下去,关节内部编码器线都被拉扯断了。那次损失了一台电机和一周时间。所以我们下定决心要搞定热管理。
首先我们需要知道电机实时的内部温度。电机自带了一个NTC热敏电阻,但响应慢,而且装在定子外壁,滞后严重。我们建了一个一阶热网络模型,把线圈发热看作电流源,热阻Rth、热容Cth代表传热过程,通过测量外壳温度和环境温度,在线估算线圈温度。代码大概是这样:
class MotorThermalModel:
def __init__(self, R_th=2.5, C_th=120.0, T_amb=25.0):
self.R_th = R_th # K/W
self.C_th = C_th # J/K
self.T_amb = T_amb
self.T_coil = T_amb
self.T_housing = T_amb
def update(self, I, dt):
P_loss = I**2 * 0.15 # 相电阻0.15欧姆
T_steady = self.T_amb + P_loss * self.R_th
tau = self.R_th * self.C_th
self.T_coil += (T_steady - self.T_coil) * (1 - np.exp(-dt / tau))
return self.T_coil
我们把这个估算温度作为保护阈值,超过120度就限制最大输出电流到70%,超过140度直接强制停机。但这在比赛中是不能停机的,所以我们设计了“动态降出力”策略:当温度超过110度时,逐步降低步频和步长,同时减小MPC中代价函数的性能权重,允许更大的跟踪误差,从而减少关节力矩。比如,温度到115度时步频从1.8Hz降到1.5Hz,速度从0.35m/s降到0.2m/s,关节力矩限制从15Nm降到10Nm。这个切换不平滑,机器人会明显瘸一下,但总比烧电机强。
比赛到7.3公里的时候,遥测显示右膝关节估算线圈温度到了118度,还在缓慢上升。我咬着牙下了降功率指令,机器人的步态立刻变慢,一瘸一拐地走。旁边观众喊“加油!加电!”,我心说再加电就得加电机了。靠着这个策略,它又撑了2公里,到了9.6公里,电池电压从满电52V掉到了42V,系统触发低电量保护,自动趴下了。距离关门还有20分钟,但我们没能完赛。
事后分析,能耗管理也是一堆坑。我们在平路上平均功耗300W,上坡时瞬时功率上到600W。电池放电过程中,电池内阻导致压降,当电压小于44V时,驱动器会报欠压,限制电流输出,这又进一步影响步态。我们之前没有做电池SOC的动态管理,一直用额定电压算最大电流,结果就是最后阶段输出能力直线下降。另外一个细节是,机器人在软地面上(如草地)行走时,由于下陷,等效腿长变短,需要更大的关节力矩来支撑,能耗额外增加20%。我们根本没考虑不同地面的能耗模型。
硬件上我们也做了一些努力:给膝关节和髋关节电机加了紫铜散热鳍片和微型5V风扇,但风扇功耗累计有20W,等于额外吃掉6%的总能量,简直是个悖论。最后,我们学到的教训是:长距离双足行走的能耗和热约束,必须内嵌到运动规划的优化目标里,比如MPC的代价函数里加一项“预期温度上升”的惩罚,或者RL的奖励函数里奖励低力矩动作。单纯把控制和热管理分开,只能事后救火。
其实,在室内测试中,我们构建了一个看似完美的测试场地
实验室的地面是自流平环氧树脂,平整得像镜子。我们把二十张瑜伽垫拼成一条长走廊,模拟赛道上的微小缓冲,又在尽头放了一面全身镜,让机器人能感知到视觉特征点。测试时空调开到22℃,湿度控制在40%,电机散热风扇呜呜地转。逐影以0.3m/s的速度稳稳当当走了三十圈,每圈大约七十米,累计超过两公里,中间没有一次跌倒。我们甚至用高速相机记录了踝关节的轨迹,偏差不超过±2毫米。控制工程师把数据导入Matlab,跑了一遍相干性分析,结论是:“步态稳定裕度充足,周期震荡收敛于极限环。”我当时查了查这个词,大概就是说机器人走路已经形成了一种像呼吸一样自然的节奏。
但是亦庄半马赛道呢?发枪地点在南海子公园南门广场,铺的是粗糙的透水砖,砖缝里嵌着干枯的草茎和小石子。那天早晨气温只有9℃,阵风三级。更致命的是,广场有将近1.5%的横坡,用于排水。我们之前在地平误差模型中从来没有考虑过这个斜率。机器人上身前倾的激光雷达在初始化时检测到地面法向量偏了1.3度,内部坐标系立刻产生了修正,但这个修正叠加到离线步态库上,导致迈出的第一步就把质心水平加速度从预期的0.1g推到了0.22g。
我在赛道边蹲着,笔记本屏幕上ROS的话题刷新率从200Hz掉到了80Hz,WiFi信号经过几百个围观群众和直播设备干扰,丢包率飙到了37%。我试图通过dynamic_reconfigure去修改步态参数,但每次点击保存都要等三秒才有回应,那三秒里机器人已经又迈了五步,每一步都更偏离预设轨迹。那种感觉就像你在开一辆转向延迟巨大的卡车,明明已经打过方向,车身却还在滑向上一个意图。
离线步态库:我们以为的“通用解”,其实是纸牌屋
逐影的步态规划基于简化的线性倒立摆模型(LIPM),我们提前离线生成了一个步态库。这个库包含从0.15m/s到0.6m/s共12个速度档位,每个速度档位对应20种步频和步长组合,共240种步态。脚本用Python编写,通过求解二次规划(QP)来保证零力矩点(ZMP)始终落在支撑多边形内。我印象很深的一段代码是这样的:
import cvxpy as cp
import numpy as np
def optimize_step_params(v_des, foot_polygon, h_com):
# v_des: 期望前进速度
# foot_polygon: 脚底支撑凸包顶点
# h_com: 质心高度
N = 16 # 预览窗口步数
dt = 0.05
g = 9.81
# 决策变量:质心位置、速度、加速度,ZMP位置
cx = cp.Variable(N+1)
cdx = cp.Variable(N+1)
cddx = cp.Variable(N+1)
px = cp.Variable(N)
constraints = []
# 动力学约束
for i in range(N):
constraints += [
cx[i+1] == cx[i] + dt * cdx[i],
cdx[i+1] == cdx[i] + dt * cddx[i],
px[i] == cx[i] - (h_com / g) * cddx[i]
]
# ZMP必须在支撑多边形内
constraints += [px[i] >= foot_polygon[0], px[i] <= foot_polygon[1]]
# 目标函数:追踪期望速度,最小化加加速度
obj = cp.Minimize(cp.sum_squares(cddx) + 100 * (cdx[-1] - v_des)**2)
prob = cp.Problem(obj, constraints)
prob.solve(solver=cp.OSQP)
return cx.value, cdx.value
在纸上,这个算法完美无缺。我们甚至在Gazebo仿真里加上了白噪声和随机外力扰动,逐影也能恢复。但真实赛道上,脚底支撑多边形会因为砖缝和石子动态变化。我们的足底力传感器每只脚有四个三维力传感单元,采样率1000Hz,理论上能实时估算ZMP。然而,当脚掌踩到一块松动的砖块时,接触力会骤降,触地检测状态机在“摆动相”和“支撑相”之间疯狂跳变,导致时间同步被打乱。原本16步预览窗口的QP需要每50ms重新求解一次,但状态机的抖动让求解器错过了三次触发,步态库直接切换到了错误的速度档位——机器人瞬间想要从0.3m/s加速到0.45m/s,但上身还没前倾到位,于是后脚蹬地时产生了过大的俯仰力矩。
那个导致“跳舞”的元凶:IMU数据融合的延迟陷阱
我们的IMU使用的是Bosch BMI088,数据通过SPI以800Hz推送到STM32F4处理器上,进行姿态解算后通过EtherCAT发给主控。为了去除震动噪声,我们在姿态估计中采用了扩展卡尔曼滤波(EKF),融合了加速度计和陀螺仪数据。但是,这个EKF里有一个可调参数“加速度计置信度”,用来区分重力矢量和运动加速度。在室内平稳行走时,机身加速度几乎只有重力分量,所以置信度设得很高。然而,赛道上的颠簸让线加速度峰值达到了0.5g,EKF错误地把一部分重力补偿转嫁成了姿态倾角——滤波器认为机身正在向后倾斜,实际上只是前向冲击振动。这个误判导致控制器命令髋关节额外前倾2度,而这一前倾又反过来增大了加速度,形成了正反馈发散。
我事后从日志里扒出当时的姿态四元数曲线,在300米处,滚转角和俯仰角开始以大约2.5Hz的频率振荡,幅值从±1度逐渐放大到±5度。这恰好是机器人腿部摆动频率的整数倍,机械共振被激活了。机器人开始左右摇晃,上身像在打节拍。围观群众有人喊:“它在跳舞!”我扭头看了一眼控制工程师,他的脸涨得通红,手指在键盘上敲出类似莫尔斯电码的节奏,嘴里念叨:“我把加速度计权重降到0.05……不不,应该用互补滤波暂时接管……”
亦庄的横风与热失效:电机驱动器的“自我保护”
大约过了两公里,另一种现象出现了。逐影左腿膝关节开始出现不自主的抖动,频率大概8Hz,幅度很小,但足以让落脚点偏移3厘米。这个抖动不是控制输出的,而是驱动器进入了一种类似“电流环振荡”的状态。我们的无框力矩电机配合了定制的FOC驱动板,MOSFET的开关频率20kHz,电流环带宽调到了2kHz。现场低温加上阵风,驱动器散热片的温度不均匀,PCB上的相电流采样电阻温漂导致A相和B相的电流增益产生了0.3%的失配。这种失配在dq坐标系下表现为一个与转子角度相关的转矩脉动。当机器人摆腿到后蹬位置,电机转子恰好处在某个特定电角度时,脉动频率刚好和关节结构共振频率重合,于是膝关节就开始唱歌了。
更糟的是,这个高频振动通过连杆传到足底,被力传感器捕获,进到了阻抗控制回路。阻抗控制器试图用虚拟弹簧阻尼去抵消这个力振动,结果反而给电机指令叠加了一个同频补偿电流,振荡被硬生生维持住。我把这叫做“控制器的自激”,在现场唯一的补救办法是降低阻抗刚度K_p参数。我颤抖着手把左膝的K_p从80Nm/rad砍到30Nm/rad,刚度骤降导致机器人瞬间变“软”,像喝醉了一样,膝关节弯曲角度增加了15度,整机身高矮了5厘米。它那诡异的下蹲姿态配上仍在震荡的右腿,更像某种行为艺术了。
WiFi调参的最后一搏:当延迟杀死实时性
我们当时为什么选择WiFi?因为机器人身上没有装屏幕,唯一的调试接口就是网线或者无线。亦庄赛道全程21公里,我们不可能跟着跑一路网线,而4G模块在发枪前突然损坏,备用方案就是用一台高增益的WiFi中继。然而,现场信号环境极度恶劣,直播无人机的图传、记者的无线麦克风、沿途计时点的RFID,都在2.4GHz和5GHz频段上挤满了频谱。我用笔记本ping机器人,平均往返时间从室内的3ms涨到了240ms,抖动超过100ms。
机器人运行着基于ROS2的实时控制节点,主控是Intel NUC 12,跑着PREEMPT_RT内核。控制循环要求在500微秒内完成一次力矩更新。我的调参指令通过网络传到NUC,再通过DDS发布给实时节点,整个过程里经过了TCP的拥塞控制、ROS2的executor调度、还有实时节点的spin锁。延迟高到一定程度后,我的一次参数更改会在机器人已经进入不同的步态阶段时才生效。比如,我想在左脚离地瞬间减小摆动腿的增益,实际上参数写入时,左脚刚刚落地,结果落地瞬间的阻尼被意外减小,冲击力直接让脚底打滑。这个打滑又导致下一个步态周期里摆动腿触地检测延迟了30毫秒,形成连锁雪崩。
团队那一刻的疯狂补救与最终的“舞蹈”
当机器人跑到5公里处,它已经完全脱离了我们能控制的优雅行走模式,转而进入一种奇异的“即兴”状态。髋关节在做大幅度的圆周运动,手臂为了平衡开始像风车一样抡转。现场解说员激动地喊道:“看啊,这个人形机器人正在用舞蹈激励跑者!”人群爆发出欢呼和笑声,只有我们知道,那是对失控的美丽误读。
我们的机械工程师从后方跑上来,抱着一台示波器和电池,试图直接在赛道边给驱动器刷写降级固件。电气工程师则跪在地上,用热风枪加热惯性测量单元的保温套,试图让EKF回到正常状态。而我,盯着屏幕上疯狂滚动的日志,每一行红色的ERROR都在宣告我们两年准备的破产。我忽然想起启动那天,我们还在机器人的肩胛骨位置贴了一行小字:“它也许笨拙,但不会倒下。”此刻逐影虽然没倒下,却以一种比倒下更残酷的方式在赛道上扭曲着。
最后的“舞蹈”,从运动控制的角度看,本质上是多刚体系统在多个控制器边界条件下的混沌振荡。摆动的腿像是一个受迫的倒立摆,上身则是耦合的二级摆,当IMU反馈错误、力控失效、步态库离线且错误切换后,整个系统进入了奇怪吸引子。我后来用记录的数据在仿真里还原,发现那个吸子形态像一朵展开的莲花,每个花瓣就是一个周期约0.8秒的摇摆循环。如果这不是发生在几百万造价的原型机上,它甚至称得上美丽。
两年来的盲区:为什么仿真永远不够
这次失败让我明白,我们在实验室的两年里,其实一直在用一个极端简化的世界模型训练自己。仿真里的地面永远是平坦、刚性的,摩擦力均匀0.7,没有碎石,没有横风,没有围观群众移动造成的乱流,也没有太阳辐射导致关节轴承热膨胀。我们的步态库在设计时,假定了机器人参数完全已知且时不变。实际上,跑了三公里后,膝关节的谐波减速器因为润滑脂变稠,静摩擦力矩从0.5Nm升到了1.2Nm,这个变化足够让原本收敛的极限环分岔。
更致命的是,我们没有进行任何真正意义上的域随机化训练。强化学习团队其实提过用MuJoCo做域随机,模拟不同地面摩擦、斜坡、外力,然后训练一个端到端的策略。但我们过于自信地认为传统模型基控制加离线步态库足以应付,因为“人类走路也不需要神经网络”。可人类从小通过无数次跌倒学习了自适应,机器人却没有这样的童年。我们给了它一个僵硬的骨架和一组固定的数学方程,然后期待它去对抗世界的混沌,这本身就是傲慢。
代码中的幽灵:那个没被发现的符号错误
赛后两周,我们在复盘日志时,发现了一个隐藏在触地检测代码里的符号错误,这可能是引发一切雪崩的第一片雪花。在状态机中,我们根据力传感器Z轴分量的变化率来判断足端接触事件。代码片段原本应该这样判断:
if contact_force_z > threshold and dFz_dt > 0:
foot_state = STANCE
但实际写成了:
if contact_force_z > threshold and dFz_dt < 0:
foot_state = STANCE
这个错误意味着,只有当脚开始卸力的瞬间,状态机才认定进入支撑相。这导致了触地判断晚了十几毫秒。在室内瑜伽垫上,地面柔软,接触力变化较平缓,这个延迟被掩盖了。但在硬质透水砖上,力曲线非常陡峭,晚十几毫秒意味着整个双支撑相时间缩短,摆动腿提前启动,从而产生向前的拖拽力。这个拖拽力正是前三步不稳的直接原因。而这个简单的符号错误,经过两年、数百次代码审阅,竟然没有一个人发现,因为审阅时的注意力全在复杂的QP和EKF上,谁会在意一个if语句里的方向符号呢?
人形机器人半马的真正代价
亦庄半马最终被我们跑成了“首秀即事故”。但我也收获了一种残酷的清晰:人形机器人在非结构化环境下的连续稳健行走,远不是几个控制参数或一个更好的步态库能解决的。它需要感知、规划、控制与本体设计之间的深度耦合,需要在线适应能力,需要处理分布不均、时变且不可预测的接触。每一块松动的砖,每一阵突风,每一个观众的手机信号,都在试探机器人对物理世界建模的脆弱边界。
那天赛后,我们把逐影抬回工程车,膝盖关节嘎吱作响,像一位负伤的运动员。我坐在车厢地板上,膝盖上放着还亮着的笔记本,屏幕保护程序是一个滚动的地球,地球慢慢转着,我突然很想笑。我们用了两年时间教会一个机器人在平面上画直线,却只用了21秒就让它在三维世界里迷失。也许这就是机器人学最动人的地方:你永远无法预料真实的物理定律会在哪一刻,用怎样意想不到的方式,回敬你的天真。
步态库的致命假设:平地走两公里不代表能上赛道
室内测试的时候,我们铺的是橡胶地板,平整得像镜面。逐影在上面走了整整两公里,电机温度正常,电池还剩30%,整个团队鼓掌欢呼,觉得这事儿稳了。但我们忽略了一个最要命的参数——地面摩擦系数的变化。室内橡胶地板的摩擦系数在0.7到0.8之间,而亦庄半马赛道是柏油路面,表面有细小的颗粒状纹理,摩擦系数在0.5到0.6之间。在机器人动力学里,这个差距足以让整个步态规划崩溃。
我们的步态库是基于ZMP(零力矩点)理论离线生成的。简单来说,就是把机器人简化成一个倒立摆模型,计算出重心的轨迹,然后根据重心位置反推每一步的落脚点。这套方法在学术界已经验证了二十多年,几乎每一篇双足行走的论文都在用。但ZMP的前提是脚底和地面之间没有滑动——或者至少滑动在可控范围内。一旦摩擦系数下降,脚掌落地瞬间就会产生微小的滑移,而这个滑移量积累到第三步、第四步,就会让机器人的实际姿态和预设姿态之间产生一个不可修复的偏差。
发枪后,逐影迈出第一步,我看着它的右脚落地,心里数着“一”。按照离线步态库,这一步的步长应该是18厘米,脚尖离地高度3.2厘米,落地时踝关节角度应该是负5度——也就是微微前倾,让脚掌的后跟先接触地面,然后滚动到前掌。但实际落地的时候,我明显看到它的脚掌整个平拍在了地面上。这意味着踝关节的实际角度是0度,比预设值少了5度。别小看这5度,对于一个身高1.4米、重心高度约0.8米的机器人来说,踝关节5度的偏差会通过腿部的四连杆机构被放大,导致上半身的前倾角度多出将近3度。而3度的躯干倾斜,已经超出了我们姿态控制器能纠正的上限。
我边跑边盯着笔记本屏幕上的数据流。IMU返回的俯仰角在剧烈跳动,从负2度一路飙到负7度——负号代表前倾。姿态控制器正在疯狂地调整髋关节和膝关节的力矩输出,试图把上半身拉回来。但这里有一个物理上的死结:双足机器人想要把前倾的身体拉正,必须通过脚底施加一个向后的水平力。而这个水平力的大小受限于脚底的最大静摩擦力。摩擦系数不够的时候,你越用力往后蹬,脚底越容易打滑。我们预设的控制器增益是基于0.7摩擦系数调的,现在实际只有0.55左右,导致每一步的纠偏力矩都超出了摩擦力能支撑的极限。结果就是:控制器越想纠正,滑动越严重;滑动越严重,偏差越大;偏差越大,控制器输出越大的纠偏力矩——一个完美的正反馈循环。
# 这是我们当时跑的实时姿态控制代码片段
def compute_ankle_torque(target_pitch, current_pitch, pitch_rate):
# 外层PD控制器:根据躯干俯仰角偏差计算期望的踝关节力矩
kp_pitch = 120.0 # 比例增益,室内调好的值
kd_pitch = 15.0 # 微分增益
pitch_error = target_pitch - current_pitch
desired_zmp_offset = kp_pitch * pitch_error + kd_pitch * pitch_rate
# 将ZMP偏移转化为踝关节力矩
# 这里的0.8是重心高度,38是机器人质量(kg),9.81是重力加速度
ankle_torque = desired_zmp_offset * 38 * 9.81 / 0.8
# 问题出在这里:力矩上限是80Nm,室内够用
# 但柏油路面上摩擦力不够,60Nm以上就开始打滑
ankle_torque = np.clip(ankle_torque, -80, 80)
return ankle_torque
这个函数每5毫秒执行一次,在室内的橡胶地面上,ankle_torque很少超过40牛米,一切都在安全范围内。但在赛道上,pitch_error从第一步就开始累积,到第三步的时候ankle_torque已经被clip到80牛米的饱和值。电机在执行这个力矩时,脚底瞬间突破了静摩擦阈值,产生了肉眼可见的滑动——后来看录像,逐影的右脚在第三步落地后往后滑了大约2厘米。对于一个步长只有18厘米的机器人来说,2厘米的滑动意味着这一整步的有效推进距离被砍掉了超过10%。
更糟糕的是,滑动不是均匀的。左脚的滑动量和右脚不一样——因为赛道有0.8度的横向坡度,这个坡度肉眼根本看不出来,但机器人能感受到。左脚落在坡度的较低一侧,接触面积和压力分布跟右脚有细微差异,导致两脚的摩擦特性不对称。左脚滑1.5厘米,右脚滑2.3厘米,几步下来,逐影的行走方向开始向右偏转。我们预设的步态库里当然有基于IMU航向角的转向纠正逻辑,但那个纠正逻辑又依赖于脚底不打滑的前提。一旦脚底打滑,航向纠正本身也会加剧不稳定性。
我当时做的第一反应,也是最蠢的反应,就是打开笔记本上的PID调参界面,手动把kp_pitch从120调到了85。我想的是降低增益,让纠偏力度小一点,或许能减少滑动。但我忘了另一个事实:降低增益虽然减少了峰值力矩,但也降低了系统对姿态偏差的响应速度。响应一慢,偏差就积累得更快,等控制器反应过来的时候,偏差已经大到需要更大的力矩才能纠正。我等于自己把系统推向了另一个不稳定区域。后来我们团队的控制工程师在复盘时说了一句让我记到现在的话:“你那个操作,相当于感觉车在冰面上打滑,然后你把方向盘拆了。”
电机热管理:一纸被忽略的温升曲线图
逐影的12个无框力矩电机分布在两条腿上,每条腿6个——髋关节2个(俯仰和横滚)、膝关节1个、踝关节2个(俯仰和横滚)、再加上一个髋部偏航关节。电机的峰值扭矩是150牛米,额定扭矩45牛米。室内两公里测试的时候,电机平均负载率不到30%,温度稳定在55度左右,离85度的过热保护阈值还有很大余量。所以我们在赛前从来没有认真考虑过电机过热的问题。我们想的是,半马21公里,逐影的步速慢一点,0.3到0.4米每秒,走下来大概15到17个小时。电机的平均负载和室内两公里应该差不多,温度肯定撑得住。
这个推算在数学上没毛病,但它的前提是“步态正常”。一旦步态异常,电机的负载曲线就会彻底改变。发枪后逐影走了不到50米,我就注意到控制面板上右腿膝关节电机的温度在以每10秒0.3度的速度上升。正常步态下,膝关节电机的主要工作是承受身体重量并在摆动相提供加速,力矩峰值出现在脚掌离地前的那一瞬间,持续时间不到0.1秒。但现在因为脚底一直在滑动,膝关节需要在支撑相持续输出额外的力矩来稳定身体,等于把瞬时的峰值负载变成了持续负载。我快速算了一下:膝关节电机现在的平均电流是18安,额定电流是12安,超载50%。按照电机厂商提供的温升曲线,额定电流下稳态温度是60度,1.5倍额定电流下的稳态温度是——我翻遍了脑子也记不起来这个数字,因为赛前根本没人觉得需要背下来。
后来查数据,1.5倍额定电流对应的稳态温度大约在105度,远超过热保护的85度阈值。也就是说,如果逐影继续保持这种异常步态走下去,不出2公里,右腿膝关节电机就会触发过热保护,直接断电。一个双足机器人在行走过程中突然有一条腿的膝关节失去动力会怎样?答案是它会在0.2秒内向前栽倒,脸部着地——我们的机器人甚至没有设计手臂来缓冲摔倒的冲击。
我一边追着机器人跑,一边用对讲机喊坐在保障车里的队友:“老周,右膝电机温度多少了?”老周的声音从对讲机里传回来,带着那种技术男特有的冷静:“已经72度了,上升速率每秒0.28度,预计4分半后触发保护。”我脑子飞快地转:4分半,按现在的速度能走大概80米。80米之后,这价值47万的机器人就会像一棵被砍倒的树一样直直地砸在亦庄的柏油路上。
应急方案:牺牲步态,保住电机
我只有大概三分钟来做决定。摆在面前的选择有三个:第一,继续走,赌电机能撑得更久;第二,主动停机,放弃比赛;第三,现场修改步态参数,降低电机负载。第一个选项等于拿47万块钱赌博,第二个选项意味着两年的工作付之东流,第三个选项——在赛道上用笔记本远程修改步态参数——听起来像是电影里的情节,但这是唯一可行的路。
降低膝关节电机负载的最直接方法,是减小步长和降低步行速度。步长越小,支撑腿的膝关节弯曲角度越小,股四头肌——不好意思,是膝关节电机——承受的力矩就越小。我快速在笔记本上调出步态生成器的参数界面,把步长从18厘米下调到10厘米,步频从1.2步每秒降到0.8步每秒。但这里有一个非线性的问题:步长变小意味着同样的21公里需要更多的步数,行走总时间会变长。原来预计15到17小时走完,改了步长之后可能要超过24小时。24小时意味着要经历一整天的温度变化——亦庄四月份的白天最高温度有25度,晚上降到10度以下,温差15度。电机的散热特性、电池的放电效率、甚至金属结构件的热胀冷缩,都会受到温度的影响。但我们没有别的选择。
修改步长参数之后,还有一个问题要解决:ZMP轨迹必须重新计算。我们离线生成步态库的时候,ZMP轨迹是用一个开源的轨迹优化库算出来的,在i7处理器上跑一次需要大约40秒。现在我在赛道上,笔记本用的是赛扬处理器,跑一次同样的计算需要将近4分钟。我没有4分钟——逐影还在走,每多走一步都是在异常步态下走的,电机的温度每10秒上升0.3度。我必须用一个更快的近似方法来实时生成新的步态。
我决定用线性倒立摆模型做一个简化版的ZMP计算。这个简化方法把机器人的动力学简化成在水平面上运动的一个质点,忽略腿的质量和转动惯量。虽然精度不如全动力学模型,但计算速度快了三个数量级——在我的笔记本上用Python跑一次只需要不到0.1秒。
# 简化版在线ZMP计算——赛道上临时写的
def compute_zmp_simple(com_pos, com_acc, height=0.8):
"""
com_pos: 质心水平位置 (x, y)
com_acc: 质心水平加速度 (ax, ay)
height: 质心高度(简化假设为恒定)
返回: ZMP位置 (zx, zy)
"""
g = 9.81
zx = com_pos[0] - (height / g) * com_acc[0]
zy = com_pos[1] - (height / g) * com_acc[1]
return (zx, zy)
# 然后用这个ZMP位置反推落脚点
# 假设ZMP必须在支撑多边形内,支撑多边形简化为脚掌矩形
def compute_foot_placement(desired_zmp, foot_length=0.18, foot_width=0.08):
foot_x = np.clip(desired_zmp[0], -foot_length/2, foot_length/2)
foot_y = np.clip(desired_zmp[1], -foot_width/2, foot_width/2)
return (foot_x, foot_y)
这套简化模型在理论上是不严谨的。它假设机器人的质心高度恒定,但实际行走中质心高度会有正负2厘米的波动;它忽略了角动量的影响,但实际步态中手臂摆动和躯干旋转都会产生角动量。但在当时的情况下,不严谨的模型比没有模型强一万倍。我把新的步态参数输入进去,让控制器在接下来的五步之内平滑过渡到新的步态——步长从18厘米逐渐缩短到10厘米,步频从1.2降到0.8。过渡过程的平滑性非常关键:如果步长瞬间从18厘米跳到10厘米,机器人会有一个突然的减速,惯性会让上半身猛地前倾,很可能直接触发摔倒。所以我写了一个简单的线性插值,让步长每步减少1.6厘米,五步之后稳定在10厘米。
改了步态参数之后,右膝电机的电流从18安降到了10安,温度上升速度从每10秒0.3度降到了0.05度。老周的声音从对讲机里传来:“温度上升速率下来了,现在72.8度,大概还能撑很久。”我长出了一口气,这才发现自己的手心全是汗,笔记本的触摸板上印着一个湿漉漉的手印。但问题并没有完全解决——逐影现在的速度只有0.08米每秒。0.08米每秒是什么概念?21公里要走73个小时。而比赛的关门时间是6小时。
第17分钟的“舞蹈”:当IMU漂移遇上震颤
解决了电机过热的问题之后,逐影以蜗牛般的速度又走了大概10分钟。这段时间里我稍微放松了一点,甚至有空拧开一瓶水喝了一口。然后我看到逐影突然开始——我只能用“跳舞”来形容——它的两条腿开始以大约2赫兹的频率快速交替踏步,身体在原地上下颠簸,像一个正在蹦迪的人。但同时它的上半身在缓慢地向右倾斜,像一个陀螺仪正在失去平衡。这个诡异的组合动作持续了大概七八秒,然后机器人终于向前迈了一大步,差点冲出赛道撞上路边的隔离栏。我扔掉水瓶就冲了上去。
事后分析数据,这17分钟里发生了多件事情,它们各自的后果叠加在一起,创造出了那个令人匪夷所思的画面。首先,IMU的陀螺仪发生了漂移。我们用的是工业级的MEMS陀螺仪,不是那种几十万的光纤陀螺仪。MEMS陀螺仪有一个永远甩不掉的毛病——零偏漂移。厂家标称的零偏稳定性是每小时12度,听起来不算太差。但这个指标是在恒温环境下测的。从发枪到第17分钟,逐影内部的温度因为电机发热和阳光直射,从22度升到了31度。9度的温差导致陀螺仪的零偏漂移量比标称值大了将近三倍。17分钟里,航向角累计漂移了大约8度。
航向角漂移8度,意味着机器人以为自己正在朝着正前方走,但实际上它的朝向已经偏了8度。步态控制器根据这个错误的方向信息计算出每一步的落脚点,导致机器人的实际行走轨迹是一个缓慢弯曲的弧线。但更糟糕的是,我们的控制器里有一个“航向纠正”模块,它会检测航向角的偏差并通过调整两侧步长来修正方向。当IMU报告的航向角和预设的赛道方向出现偏差时,这个模块会让外侧腿的步长加大、内侧腿的步长减小,从而产生一个转弯力矩。问题是,航向角漂移是缓慢而持续的,航向纠正模块不断地试图修正一个它以为存在但实际上不存在的方向偏差——而它每一次修正都是在把机器人往真正偏离的方向推得更远。
与此同时,第二个问题正在关节层面发酵。步长从18厘米降到10厘米之后,每一步的脚底接触时间变短了。原来一步的支撑相大约是0.6秒,现在变成了0.45秒。支撑相变短,意味着力控回路必须在更短的时间内完成地面接触力的检测和调整。我们用的是基于电流的力估计——通过检测电机电流来推算关节力矩,再通过关节力矩和腿部运动学来推算脚底接触力。这套估计方法本来就有5到8毫秒的延迟,在0.6秒的支撑相里,这个延迟占比不到1.5%,几乎可以忽略。但支撑相缩短到0.45秒之后,同样的延迟占比变成了将近2%。占比翻倍带来的后果是力反馈的相位滞后增大,闭环系统的相位裕度下降,稳定性变差。
相位裕度不够的时候,闭环系统就会产生振荡。这个振荡最开始表现在踝关节上——踝关节电机开始以大约2赫兹的频率快速正反转,幅度很小,大概正负3度。但随着振荡持续,它通过腿部的机械结构向上传递到膝关节和髋关节。等振荡传到躯干的时候,就变成了那个看起来像“跳舞”的动作——机器人原地上下颠簸、快速踏步。而IMU的漂移又在同时把机器人推向一个方向,这两种效应叠加,创造出了那个既诡异又滑稽的画面。
我用对讲机跟老周说:“你看没看到刚才那个?”老周沉默了两秒:“看到了。我录下来了。回头放给投资人看的时候记得跳过这一段。”
重新审视我们的工程哲学
比赛那天逐影最终没有走完半马。它在第2.8公里处因为左踝关节编码器的一个接线头松动导致信号丢失,主动触发了安全停机。我们把它装回了保障车,所有人都很沉默。但回公司之后的复盘持续了整整两周,那段复盘比比赛本身更有价值。
我们犯的最核心的错误,不是某个参数没调好,也不是某个硬件选型有问题。最核心的错误是一种工程哲学上的傲慢——我们把“在受控环境下能跑两公里”等同于“在真实环境下能跑半马”。这两者之间的差距不是线性的,不是简单的“多跑19公里乘以同样的难度系数”。真实环境引入了无数个我们在实验室里可以用一句“假设地面平整”或“假设温度恒定”来忽略掉的变量,而这些变量每一个都有可能成为压垮整个系统的最后一根稻草。