这篇关注一个实际问题:程序能运行,是否就说明我调用对了?
对浮动基机器人,数组尺寸对了,也可能用错速度坐标系;姿态看着正常,也可能把四元数当成了欧拉角;得到一个对称矩阵,也可能把质量矩阵对角线加了两遍。比记住函数名更重要的是:为每次调用写出输入约定、缓存前置条件、输出所在空间,以及一个能暴露错误的检查。
阅读路线:专题入口 → 01:模型与流形 → 02:动力学源码 → 本篇。原来的 ABA 入门笔记 保留原地址。
本文固定研究 Pinocchio v3.8.0,源码提交为 655877b314baed68c7e2d4dd56b0a0200bb9f98e;这是教学版本基线,不是“最新版”的声明。下面的 Python 自检与 C++ 调用片段均针对这一版本。不要把不同版本网页中相似的函数签名拼在一起。
1. 先给每次调用写一张小合同
设模型包含一个 free-flyer 和两个单自由度转动关节:nq=9,nv=8。这不是所有机器人的固定尺寸,只是本专题自检模型的尺寸。
| 对象 | 尺寸 | 它到底是什么 |
|---|---|---|
q |
model.nq |
配置存储;根关节包含位置和单位四元数 |
v |
model.nv |
切空间速度;根关节前六维为局部线速度、角速度 |
a / ddq |
model.nv |
与该速度表示配套的广义加速度,不是四元数二阶导 |
tau |
model.nv |
广义力;根六维是力与力矩,不是电机力矩 |
M |
nv × nv |
同一广义速度坐标中的质量矩阵 |
J |
6 × nv |
空间雅可比,必须同时注明参考系 |
dtau_dq |
nv × nv |
对配置的切空间扰动求导,不是对 nq 个存储数直接求导 |
坐标、符号和索引要一起记录。例如:q 的四元数顺序为 x,y,z,w,Motion.vector 的顺序是线分量在前、角分量在后。不要仅写“一个 6D 向量”。相关定义见 free-flyer 与 Motion。
2. Model 是结构,Data 是会被覆盖的计算现场
一个常见误会是:做了一次 FK,后面的任何查询就都有效。实际应按查询对象满足前置条件。
| 需要什么 | 清楚、可读的调用顺序 |
|---|---|
关节位姿 data.oMi |
forwardKinematics(model, data, q) |
所有 frame 位姿 data.oMf |
FK 后 updateFramePlacements(model, data);或 framesForwardKinematics |
| 某 frame 的速度 | forwardKinematics(model, data, q, v) 后查询 getFrameVelocity |
| 某 frame 的经典线加速度 | forwardKinematics(model, data, q, v, a) 后查询 getFrameClassicalAcceleration |
| frame 雅可比 | computeJointJacobians(model, data, q),更新 frame 位姿,再 getFrameJacobian |
| 逆动力学、前向动力学 | 直接调用相应高层 rnea / aba;它们执行各自需要的递归 |
这里给的是方便审计的顺序,不是在说每个额外调用都是最小必需集合。先明确数据由谁写入,再根据版本文档优化冗余工作。运动学源码、frame 源码 能确认具体缓存的来源。
同一个 Data,不等于每个字段都对应同一个时刻
例如先以 q1 计算 FK,再以 q2 调用另一个算法,不能无条件认为 data 中所有 frame 缓存也都更新到了 q2。如果只调用了只接受 q 的 FK,也不要把此前留下的 data.v 当成本次速度结果。
建议开始时采用两条规则:
- 一个计算线程使用自己的
Data,不要让多个线程同时写同一个实例。 - 保存跨调用结果时显式复制;C++ 返回的引用、Eigen 表达式,以及 Python 暴露的缓存视图,都不应被当成独立快照。
data_tau = model.createData() |
这是“容易确认正确”的基线,不是让实时循环不停创建 Data。结构固定后,应在循环外创建工作区、数组和必要副本缓冲;再检查实际调用有没有动态分配。
3. CRBA:矩阵尺寸正确,仍然可能读错一半
v3.8.0 的 C++ CRBA 核心接口需要特别注意上三角结果:不要依赖下三角中某次调用碰巧留下的数值;以完整矩阵做代数运算前,明确恢复对称部分。CRBA 核心约定
但这个版本的 Python 包装已经做了 make_symmetric,pin.crba 返回的是完整对称矩阵。下面的 triu 写法是让跨语言教学和核心契约保持一致的防御性处理,不是在说 Python 返回了旧的下三角。这正是为什么读源码时不能停在同名 C++ 函数,还要继续看包装层。Python crba_proxy
upper = np.triu(pin.crba(model, data_mass, q).copy()) |
这里第二项的 1 很重要:它只复制严格上三角,不再次累加对角线。M + M.T 会把对角线加倍,数值看起来仍很整齐,但物理上已经错了。
C++ 中可以从上三角构造一份完整的自伴矩阵。这段假定 model、q、v、a 已按第 01 篇建立,是算法调用片段,不是独立 main:
|
不要替换成 M.inverse() * (tau - h)。分解求解避免构造完整逆矩阵,但 LDLT 不是“输入再坏也稳定”的补丁:质量为零、惯量不合理、数量级悬殊仍然需要处理。
4. 先验证动力学恒等式,再跑复杂控制器
不带外力、接触约束时,定义
同一模型、同一 q,v,a 下,应满足
再做一次逆向检查:
第一条检查质量矩阵恢复、惯量和逆动力学约定;第二条检查逆动力学与前向动力学的一致性。它们仍可能共同使用同一种错误的模型,所以还要增加独立的几何检查,而不是只让两个接口互相证明。
这个闭环不是“机器人已经能执行这个加速度”
将任意 a 输入 RNEA,通常会得到非零的基座六维广义力。这是实现该加速度所需的广义力,不代表自由漂浮基座有六个驱动器。
对本专题的无接触浮动基示例,给电机关节施加命令时,应把广义力的基座部分设成零,再让 ABA 计算整个系统的响应:
tau_command = np.zeros(model.nv) |
这段切片只适用于本专题“根 free-flyer 后紧接两个电机关节”的模型。一般机器人应建立显式执行器映射,不能认为所有 nv 都是可驱动转轴。接触时还要加入接触力或约束求解,不能把 tau[:6]=0 理解成“基座不会运动”。
4.1 加入 armature 后,哪些恒等式应该改变?
model.armature 是与广义速度对应的附加对角惯量,不是库自动替你建立的电机电气模型。对常数向量 b,本版本把它加到 CRBA 的对角线,并在 RNEA 中加入 b * a;动能接口也计入相应的二次项。CRBA、RNEA、能量实现
把同一模型分别设为零 armature 和非零 armature,保持 q,v,a 不变,可以检查:
此时 h = RNEA(q,v,0) 不会凭空多出 b * v:那是黏性阻尼形式,不是惯量项。惯量对应加速度,阻尼对应速度,二者单位也不同。
实验时只给两个电机关节分配小的正 armature,基座六维保留零;先记录原向量,或使用独立模型实例。修改模型后重新计算全部相关量,不能拿“修改前的 M”和“修改后的 RNEA”比较。也不要在 API 已经计入 armature 后,再手动加一遍。
若输入的是转子惯量与减速比,要先确认折算到关节侧的物理约定;不能仅因为字段名里有 rotor,就假定所有接口都会自动使用它。更复杂的转子耦合需要明确模型,不由这项对角参数代替。
4.2 外力必须在等式的两边使用同一份定义
令 fext[j] 是施加在关节 j 所附刚体上、表达在该关节局部坐标系、关于该关节原点的外部 wrench;数组长度是 model.njoints,包含 universe 槽位。不要把 6 × nv 矩阵或电机命令当成这个容器。
固定同一组外力时,先用带外力的 RNEA 定义偏置,而不是复用无外力 nonLinearEffects:
再将同一 fext 传给 ABA,检查 ABA(q,v,tau_f,fext) ≈ a。这是“外力重载的一致性”,不是已经求解了接触力。RNEA 从刚体所需净力中减去外力,符号可直接从 rnea.hxx 的外力分支核对。
还可以独立核验 tau_no_ext - tau_ext = J_local.T @ wrench_local:只施加一处外力,使用该关节的 LOCAL 雅可比,并保证 wrench 与雅可比具有相同原点。若力实际作用在偏置工具点,先把力矩连同作用点一起变换到关节原点;只旋转三维力会遗漏力臂。
差分时尤其要记录“固定外力”指固定哪一种坐标。保持局部六个数不变,和保持世界中同一个 wrench 不变,是不同实验;后者需要随扰动后的位姿重新变换外力。第 06 篇 进一步说明相应链式法则。
5. 不要对四元数的七维数组做普通有限差分
如果目标是验证 computeRNEADerivatives 的配置导数,扰动属于切空间:
其中 e_j 的长度是 nv,不是 nq。这与源码把配置导数表达成流形上的局部扰动相一致;官方接口和 Python 绑定测试见 rnea-derivatives.hpp、绑定测试。
derivative_data = model.createData() |
这里让 derivative_data 保持存活,并立即复制要保存的矩阵;该版本 Python 包装向结果元组放入缓存的引用,不能忽略其所有权。导数包装层
这次差分保持 v,a 的数值坐标不变,用来比较这个 API 所定义的偏导。如果你的目标是“保持同一个世界坐标速度不变”,而配置变化导致局部坐标也变化,那是另一个扰动实验,需要转换速度并应用链式法则。
为什么 eps 不能一味减小?
中心差分的截断误差通常随 eps² 缩小;浮点相减消去造成的相对影响却大致随机器精度除以 eps 放大。可试 1e-4、1e-5、1e-6、1e-7,看误差是否出现合理的平台或谷底,而不是把最小步长当成真值。
配置中平移用米、旋转用弧度,力与力矩的单位也不同。统一阈值适合一个固定尺度的回归 fixture,不是所有机器人的物理误差标准;生产指标应按每组量的单位和尺度分开检查。
扫描步长:看趋势,不挑一个最好看的数字
把上面的中心差分封装成 central_difference(eps) 后,至少保留完整扫描表。下面是实验框架;函数内部仍必须用 integrate,每一侧独立 Data,并复制输出:
# analytic_q 和 central_difference 来自本节同一 q,v,a、同一模型。 |
这里 1.0 只是固定单位制下避免近零分母的诊断尺度,不是通用的无量纲标准。绝对误差说明“错了多少”,相对指标帮助分辨大矩阵数值大是否就是精度差;不要只保留一个相对数就丢掉行列信息。
对于单位混合的工程模型,应选配置尺度矩阵 D_q 和力/力矩尺度矩阵 D_tau,比较无量纲残差:
这时差分第 j 列应沿 eps * D_q[:,j] 扰动,得到的是已经按配置尺度缩放的列;比较时不能再把 D_q 重复乘一次。平移尺度可取典型长度,旋转尺度可取典型角度,输出尺度分别取典型力和力矩,并写入实验记录。
若误差随步长变化几乎不动且明显偏大,优先查参考系、正负号、列顺序、外力是否随姿态更新和缓存所有权;若先下降后上升,再考虑截断与舍入平衡。若只有接近 pi 的姿态出问题,先判断是否跨越对数分支,不能直接放宽全局阈值。
建议额外保存最大误差的 (row, column),映射回关节名及其 idx_v。这样“第 0 行第 5 列失败”才能解释成“哪一个力分量对哪一个局部旋转扰动不一致”。下载脚本保留的是固定步长基线,尚未包含这里的多步长扫描实验。
6. LOCAL、WORLD 和 LOCAL_WORLD_ALIGNED 不是三个别名
| 参考系 | 轴方向 | 空间运动所用原点 | 常见用途 |
|---|---|---|---|
LOCAL |
被查询 frame 的轴 | 该 frame 原点 | 局部位姿误差、工具坐标控制 |
LOCAL_WORLD_ALIGNED |
世界轴 | 该 frame 原点 | 世界方向表达的足端/末端线速度 |
WORLD |
世界轴 | 世界原点 | 完整空间运动变换及相应动力学运算 |
后两者方向相同,但原点不同。对运动向量,仅旋转线分量和角分量,不等于完整 SE3.act;平移会通过角速度叉乘产生额外项。源码中的 getFrameVelocity 和 getFrameJacobian 对三种参考系分别处理,见 frames.hxx。
一个很有效的自检是:在每一种相同参考系中分别比较
然后人为把一边改成 LOCAL、另一边改成 WORLD,检查测试是否真的会失败。fixture 需要非零姿态、非零平移、非零角速度;在单位姿态和原点下,许多错误会被巧合掩盖。
空间线加速度也不能直接当作点的经典加速度
当你要和位置轨迹的二阶导比较时,要辨清 spatial acceleration 与 classical acceleration。Pinocchio 的 frame 经典加速度接口在线分量上加入 omega × v_linear。否则,“运动学位置差分”和“空间加速度接口”之间出现差值,未必是动力学算错了。具体实现
7. 读源码时值得停下来的几个数值细节
7.1 小角度:不是直接把 sin(theta)/theta 算到底
旋转指数映射里有接近 0/0 的系数。数学上它们有连续极限,但直接浮点求值可能消去有效数字。v3.8.0 的四元数 exp3 使用小角度展开;例如,向量部分的系数围绕 sin(theta/2)/theta 展开,而标量部分围绕 cos(theta/2) 展开。
阅读时分别追踪:平方角度 t2、数值阈值、一般角度分支、小角度分支和最终条件选择。不要把某个内部阈值照抄成全项目统一 epsilon。exp3/exp6 四元数实现
7.2 一阶归一化:快,但有“已经接近单位长度”的前提
firstOrderNormalize 的核心可以理解为:设四元数的平方模长为 s,在 s≈1 附近用
对四元数做轻微修正。它用于把刚刚经过稳定运算、仅偏离单位长度一点点的四元数拉回来;不是给任意输入做完整归一化,更不能修复零四元数。源码包含接近单位长度的断言。quaternion.hpp
外部日志或传感器数据进来时,先检查有限性和模长;偏差大时做明确的完整归一化或拒绝输入,再进入算法。Release 模式可能不执行断言,应用层检查不能省掉。
7.3 四元数符号连续性:同一个旋转,可以有两组系数
q 与 -q 表示同一旋转。SE(3) 的积分实现把结果四元数与输入四元数做点积,必要时翻转结果符号,使局部更新更连续。因此日志中的四个数发生整体变号,不代表机器人真的转了一圈。
比较姿态应用旋转误差、difference 或合适的配置等价检查;不要仅比较四元数四个存储分量。接近 pi 的旋转对数还存在分支问题,差分测试应区分局部平滑区域与分支边界。SE(3) integrate 实现
7.4 noalias() 不是“随手加上就更快”
Eigen 表达式可能延迟求值;auto 接住的东西不一定是独立矩阵。noalias() 则是在向 Eigen 保证输出和输入不重叠,错误保证可能产生错误结果。
对于需要跨调用保存的量,先明确写 Eigen::VectorXd / Eigen::MatrixXd 或 .eval()。对于循环中的大矩阵,明确缓冲区所有权后再评估临时对象和 noalias()。官方 IK 示例 同时展示了工作区复用、LDLT 求解和 integrate,值得逐行观察,但它不是完整的实时控制器。
7.5 位姿误差的雅可比还需要 Jlog6
如果误差定义为 log6(current.inverse() * desired),误差雅可比通常不等于原始的几何雅可比。官方 IK 示例对误差所选方向应用 Jlog6 和相应符号,再解阻尼最小二乘。
这里最值得学的不是某个固定负号,而是四件事必须一起成立:误差的乘法顺序、误差所在坐标系、雅可比参考系、配置更新的 integrate 约定。更换其中一个,其他公式可能也要跟着变。
阻尼有助于抑制奇异位形附近的过大步长,却不保证收敛、碰撞安全或关节限位满足。位置和旋转误差混合时,还要定义合理权重。
7.6 接口说明也可能有笔误,调用签名要交叉核对
在 v3.8.0 的 Python ABA 包装中,无外力重载的说明字符串把 v 与 tau 的文字描述写反了,但实际绑定参数顺序与 C++ 接口仍然是 model, data, q, v, tau:第四个是速度,第五个是广义力。不要因为 help() 中某一行文字而交换两个同为 nv 维的数组——这种错误可能根本不会触发尺寸异常。expose-aba.cpp
这也是数值自检的价值:参数名字、绑定签名、底层实现与物理预期应当互相支持,而不是只相信一个入口的描述。
8. 可下载自检:先让错误变得可观测
下载 Python 自检脚本与运行说明。脚本手工构建“free-flyer + 两个转轴”的小模型,不依赖机器人网格、私有 URDF 或随机下载的模型包。
它检查的是:状态尺寸、四元数与流形局部闭环、局部线速度到世界位移的关系、RNEA/CRBA/ABA 一致性、无基座驱动的命令响应、切空间导数,以及三种参考系下的 Jv。
在已经配置好 Pinocchio 3.8.0 的 Python 环境中运行:
python3 source/downloads/code/pinocchio_floating_base/check_floating_base.py |
注意:Python 导入名是 pinocchio,官方 Python 包安装渠道中的 PyPI 项目名是 pin,见 官方安装入口。不要因为模块名相同,就随意安装另一个同名包。环境安装应纳入项目的 Dockerfile 或受控环境配置,不要求为了读笔记修改宿主机的全局 Python。
当前验证边界
本次源码、文章与下载脚本按固定版本核对;尚未执行 Pinocchio 数值实验,也未编译配套 C++ 工程。本专题没有向现有 Docker 环境新增依赖,因此不提供实测误差或运行耗时。后续在选定容器内执行脚本后,应补记 pinocchio.__version__、Python/NumPy 版本、CPU 架构、每项误差和退出码。
脚本产生的通过结果也只适用于这个小模型与这些断言,不代表你的 URDF、接触模型或控制器自动通过。尤其不能把带非零基座广义力的 RNEA→ABA 代数闭环,当成无驱动浮动基的可执行运动计划。
能量漂移要和积分器误差分开看
Pinocchio 的 aba 给出某个状态下的加速度;integrate 是“配置与切空间增量”的流形运算。把它们串在一起,不会自动得到高阶、能量守恒、带碰撞的物理引擎。
例如,以下定义的是一个明确的一阶离散步进,而不是库替你选好的通用仿真器:
# step_data 在循环外创建;tau 的物理来源必须另行定义。 |
先更新速度、再更新配置,通常称为半隐式 Euler 风格步进。不过 free-flyer 的局部速度不是欧氏笛卡尔速度或正则动量,不能仅凭这两行就证明它是严格的辛积分器。增加 RK 阶段也不能直接对 nq 维四元数数组套普通 RK4;中间状态、切空间表达和运输同样需要一致。
关闭电机功率输入、非保守外力、阻尼和接触后,连续系统的机械能应守恒。在固定重力场里,应比较 E=T+U,而不是只看自由落体时不断增加的动能;computeKineticEnergy、computePotentialEnergy 的缓存版需要同一状态的 FK,带 q,v / q 参数的高层重载会计算对应状态。能量源码与接口
可按以下顺序增加实验:
- 单状态核对
T ≈ 0.5 * v.T @ M @ v,先排除能量与质量矩阵用的模型不同。 - 无重力、无外力短时运行;分别记录动能和线/角动量,检查趋势。
- 打开恒定重力,比较机械能,并确认势能符号与重力方向一致。
- 固定总时长,使用
dt、dt/2、dt/4重跑,比较终态配置的difference、速度差与最大能量漂移。 - 再加入执行器与外力,检查能量变化是否能由功率积分解释;此时不能再要求
E(t)=E(0)。
能量指标可写成 max(abs(E(t)-E(0))) / E_scale,其中 E_scale 是独立记录的正能量尺度。不要直接除以可能为零或因势能零点而任意变化的 E(0)。四元数保持单位长度只说明配置表示有效,不说明能量没有漂移。上述时间积分与能量轨迹目前属于待运行实验,下载自检没有执行长时仿真。
9. 遇到问题时的排查顺序
- 打印版本、
nq/nv和每个关节的idx_q/idx_v/nq/nv,先排除硬编码切片。 - 检查四元数顺序、模长、有限性;从
neutral开始构造小扰动,不从任意七维随机数组开始。 - 确认
v的线/角顺序和局部坐标,不要直接塞世界速度。 - 确认
M上三角已正确恢复,且结果没有被后续调用覆盖。 - 为每个
Data字段找到产生它的调用,排除旧位姿、旧速度或跨线程写入。 - 用无外力的
RNEA = M a + h隔离基础动力学,再逐项增加外力、执行器和接触。 - 最后才做性能优化;记录矩阵尺寸、配置、种子与误差,确保优化前后算的是同一个问题。
把失败状态保存为快照,而不只是保存一个随机种子
随机种子有帮助,但模型生成顺序、NumPy 版本或你新增的一次随机调用,都可能改变最终数组。真正可复现的失败案例应保存实际输入,再保存构造它的来源。
# 仅在实验环境中写入自己指定的输出目录;不写入源码或 public。 |
旁边再放一份文本元数据,至少记录:
pinocchio.__version__、源码提交、Python/NumPy/Eigen 相关版本、CPU 架构和构建模式。- 模型来源及校验和;手工模型要记录所有关节放置、惯量、质量和 COM,不能只记模型名称。
q/v/a/tau单位与坐标约定、frame/joint ID 对应名称、执行器映射与外力作用点。- 使用的外力六维数值、每项所用参考系、接触模式、eps 扫描表、误差尺度和容差。
- 入口命令、退出码、JSON 检查结果;优化实验还要记录线程数、编译选项和时间统计方法。
恢复时先检查有限性、形状、版本和模型校验和,再依次跑几何、单状态动力学、导数、一步离散映射,最后才跑长轨迹。这样可以区分“旧数据在新模型里解释错了”和“算法本身改变了”。读取自建 NPZ 时使用 allow_pickle=False;别用任意来源的 pickle 当作数据快照。
这套记录是在为以后 Docker 内的真实复现准备材料,不等于当前已经有了容器运行证据。将来补报告时,应明确分开“源码已核对”“静态检查通过”“单状态数值通过”和“时间仿真通过”。
10. 用五个问题检查是否真的理解了
- 为什么
q有九维,但dtau_dq是八乘八? - 同一个线速度
[1,0,0],为什么基座旋转九十度后世界位移方向变了? - 为什么
crba的结果不能无脑加上自己的转置? - 为什么
tau = rnea(q,v,a)再交给aba成功,不证明电机能让机器人实现这个a? - 如果
Jv与 frame 速度不一致,你会先看误差阈值,还是先看参考系和缓存更新?
能结合小脚本解释这些问题,再沿第 02 篇追到 joint visitor 和三趟递归,会比只背接口名扎实得多。
下一步读 04:完整 C++ 工程,把这些合同落到具名工作区和实际函数调用中。需要接触约束与总动量关系时转 05,需要优化器里的导数和一步 A/B 时转 06。