跳转至

动力学解析微分——dRNEA/dq 的 O(N) 闭式、dABA/dtau 与代码生成流水线

所属:刚体动力学专题 4-5 | 前置:10_空间向量代数(Plucker 变换微分)、20_Lagrange与Hamilton力学(M/C/g 结构)、30_ON动力学递推算法(RNEA/ABA 递推结构)、40_SE3几何力学(ad/Ad 算子) 交叉引用:→ 05_运动控制/90_DDP(iLQR 需要 A_t, B_t)、→ 05_运动控制/120_MPC(OCS2/Crocoddyl 的实时性依赖解析导数)


前置自测

📋 前置自测(答不出 >= 2 题 → 先回前置专题复习)

  1. RNEA 的前向递推计算什么?后向递推计算什么?写出核心递推公式。
  2. ABA 与 RNEA 的关系是什么?ABA 求解的是什么问题(正/逆动力学)?
  3. Featherstone 的 spatial cross 算子 v x_M 和 v x*_F 对应 Lie 群的什么?
  4. 对一个 n-DoF 机器人,有限差分计算 dtau/dq 需要多少次 RNEA 调用?
  5. 写出 DDP/iLQR 中 Riccati 反传需要的动力学 Jacobian A_t 和 B_t 的定义。

本章目标

学完本章后,你将能够: 1. 手推 Carpentier-Mansard RSS 2018 的 placement 微分恒等式,理解"∂X/∂q 作用在 motion/force 上 = 与 S 的空间叉乘" 2. 完整推导 ∂RNEA/∂q 和 ∂RNEA/∂v 的 O(Nd) 递推算法,理解稀疏性的来源 3. 从 ID-FD 互逆关系导出 ∂ABA/∂q, ∂ABA/∂v, ∂ABA/∂tau = M^{-1} 4. 掌握四条导数路线(解析、CodeGen、运行时 AD、有限差分)的精度-速度-适用场景对比 5. 能用 Pinocchio 调用 computeRNEADerivativescomputeABADerivatives,并与有限差分对比验证 6. 理解 CppADCodeGen 代码生成流水线**在 OCS2 中的六步实现 7. **了解二阶导数 ∂^2 ID/∂q^2 在 exact DDP 中的作用


知识树

动力学解析微分
├── 根问题:MPC/DDP 需要 ∂f/∂(x,u),怎么高效计算?
├── 主干 1:导数需求全景
│   ├── iLQR/DDP 的 Riccati 反传
│   ├── SQP/Direct Collocation
│   ├── 系统辨识 Fisher 矩阵
│   └── 可微仿真
├── 主干 2:四条导数路线
│   ├── 有限差分(O(Nn), 精度 10^{-7})
│   ├── 运行时 AD(CppAD tape, 5-10x RNEA)
│   ├── CodeGen(CppADCodeGen → .so, 1-3x RNEA)
│   └── 解析微分(Carpentier-Mansard, O(Nd), 机器精度)
├── 主干 3:∂RNEA/∂q 的 O(N) 闭式
│   ├── Placement 微分恒等式(一招制胜)
│   ├── 前向导数 pass (Algorithm 2)
│   ├── 后向导数 pass (Algorithm 3)
│   └── 三个关键简化
├── 主干 4:∂ABA/∂(q,v,tau) 从 ID-FD 互逆导出
│   ├── ∂q_ddot/∂q = -M^{-1} ∂tau/∂q
│   ├── ∂q_ddot/∂tau = M^{-1} (AZA 算法)
│   └── computeMinverse 实现
├── 分支 A:与自动微分的对比
│   ├── CppAD 运行时 vs CodeGen
│   ├── CasADi SX/MX
│   └── JAX jacfwd/jacrev
├── 分支 B:二阶导数
│   ├── ∂^2 tau/∂q^2(Singh-Wensing 2022)
│   ├── exact DDP vs iLQR GN 近似
│   └── Pinocchio rnea-second-order-derivatives
├── 分支 C:代码生成流水线
│   ├── CppADCodeGen 六步
│   ├── OCS2 的 CppAdInterface
│   └── 嵌入式部署
└── 分支 D:在 MPC/DDP 中的应用
    ├── Crocoddyl 路线(解析)
    ├── OCS2 路线(CodeGen)
    └── acados + Pinocchio

5.1 为什么动力学导数是 MPC 的瓶颈 ⭐

动机

你已经实现了一个 DDP 求解器,对一个 7-DoF 机械臂做轨迹优化。代码跑起来了,但……太慢了。分析一下热点:90% 的时间花在计算动力学 Jacobian 上

为什么?DDP 的 Riccati 反传需要在每个时间节点求 ∂f/∂x 和 ∂f/∂u。如果你用最简单的有限差分:对 7-DoF 系统,∂tau/∂q 是 7x7 矩阵,需要 8 次 RNEA 调用(中心差分需要 14 次)。乘以 N=50 个时间节点,再乘以 K=10 次 DDP 迭代——每次 MPC 更新需要 50 x 10 x 14 = 7000 次 RNEA。

对 36-DoF 人形机器人:每次有限差分需要 73 次 RNEA(中心差分),单次耗时 450 us。而 Crocoddyl 的解析路线只需 17 us——27 倍加速。这不是"好一点点",这是**实时可行 vs 完全不可行**的差异。

本质洞察:Carpentier-Mansard RSS 2018 之所以是"过去十年刚体动力学领域最重要的工程论文之一",不是因为它的数学特别深(所有推导用 Featherstone 的空间代数即可完成),而是因为它把"动力学导数"从 MPC 的计算瓶颈变成了副产品——让 whole-body NMPC 在真实人形机器人上第一次变得实时可行。

5.1.1 数字上的代价

一个典型的 whole-body MPC 控制回路(Crocoddyl/OCS2 在 ANYmal/Talos 上):

参数 典型值 说明
时域节点数 N 50-100 每步 10-20ms,总时域 0.5-2s
iLQR 迭代数 K 5-20 每次 MPC 更新内
MPC 频率 100 Hz 每 10ms 更新一次
每秒导数评估次数 5000-20000 N x K x freq

对 36-DoF 人形: - 有限差分路线:20000 x 450 us = 9 秒/秒(CPU 负载 900%,不可能实时) - 解析路线:20000 x 17 us = 0.34 秒/秒(34% CPU,正好留足求解器时间)

5.1.2 导数的使用场景全景

动力学导数不只服务 MPC。它支撑:

  1. 轨迹优化(DDP/iLQR/SQP/Direct Collocation):Bellman 二阶近似需要 ∂f/∂(x,u)
  2. 模型预测控制:每个 sample time 重复求解 OC 问题
  3. 系统辨识:Fisher 信息矩阵 J^T J,其中 J = ∂tau/∂theta,theta 是惯性参数
  4. 最优设计(co-design):对连杆长度、电机选型的可微优化
  5. 灵敏度分析:扰动对闭环性能的一阶响应
  6. Model-based RL:∂s'/∂(s,a) 直接喂 policy gradient
  7. 可微物理渲染:从视频反推物理参数
  8. 控制增益自整定:PD 增益的解析梯度

与 05_运动控制 的交叉引用:在 90_DDP 中,Crocoddyl 的 DifferentialActionModelFreeFwdDynamics::calcDiff() 直接调用本章的 computeABADerivatives。在 120_MPC 中,OCS2 的 ocs2_pinocchio_interface 通过 CppADCodeGen 包装本章的解析/AD 导数。

5.1.3 为什么"直接 AD"远远不够

一个自然的想法:既然有 CppAD/JAX,为什么不直接 jax.jacfwd(dynamics)

真实代价对比

方法 7-DoF 耗时 36-DoF 耗时 首次开销 精度
有限差分 21 us 450 us 0 10^{-7}
CppAD 运行时 5-10 us 30-60 us 0 机器级
CppAD + CodeGen 1.5-3 us 8-15 us 数秒到数分钟 机器级
Pinocchio 解析 3.3 us 17 us 0 机器级

解析导数的独特优势:零编译开销 + 即时可用 + 机器精度 + 严格保零模式

反事实推理:如果不保零模式会怎样?当 q_dot = q_ddot = 0 且无重力时,∂tau/∂q_dot 理论上严格为零矩阵。但有限差分会产生 ~10^{-7} 的稠密残差。DDP 的 Riccati 反传对这类"伪非零"极度敏感——它会误判 M 的稀疏结构,把本来应该是带状(O(Nd) 非零元素)的矩阵当作稠密(O(N^2) 非零元素)处理,QP 求解器速度直接下降一个数量级。


5.2 有限差分:原理与致命局限 ⭐

动机

有限差分是最简单的导数近似方法——对任何可以"输入数值、输出数值"的函数都能用。在验证解析导数正确性时它不可替代。但在实时控制中,它有致命的精度和速度缺陷。

5.2.1 前向差分

\[\frac{\partial f}{\partial x_j} \approx \frac{f(x + \varepsilon e_j) - f(x)}{\varepsilon}\]

误差由两部分组成: - 截断误差:~(epsilon/2) f''(x)(Taylor 展开余项) - 舍入误差:~epsilon_mach / epsilon(浮点减法的精度损失)

最优步长:\(\varepsilon^* = \sqrt{\epsilon_{\mathrm{mach}}} \approx 10^{-8}\)

此时总误差 ~10^{-8}(相对误差)。

5.2.2 中心差分

\[\frac{\partial f}{\partial x_j} \approx \frac{f(x + \varepsilon e_j) - f(x - \varepsilon e_j)}{2\varepsilon}\]

精度提升至 O(epsilon^2): - 最优步长:\(\varepsilon^* = \epsilon_{\mathrm{mach}}^{1/3} \approx 5 \times 10^{-6}\) - 总误差:~10^{-11}

但需要**两倍**的函数调用。

5.2.3 成本分析

对 f: R^n -> R^m(动力学 tau: R^{nv} -> R^{nv}): - 前向差分:(n+1) 次 RNEA(或 ABA)调用 - 中心差分:2n 次调用

对 KUKA (n=7):8 或 14 次 RNEA。 对 Talos (n=36):37 或 72 次 RNEA。

5.2.4 致命问题:破坏零模式

当系统静止(q_dot = q_ddot = 0)且无重力时:

\[\frac{\partial\tau}{\partial\dot q}\bigg|_{\dot q=0} = C(q, 0) = 0 \quad \text{(理论上精确为零)}\]

但有限差分会产生 ~10^{-7} 的**稠密残差矩阵**。

后果: - DDP 的 Riccati 反传把这些伪非零当真,误判稀疏结构 - QP 求解器(如 OSQP、qpOASES)因稠密矩阵大幅减速 - 控制器在静止平衡点附近出现微小但持续的抖动

**解析导数**则精确返回零矩阵——因为它直接从递推公式的代数结构计算,当输入为零时所有相关项自动消失。

5.2.5 有限差分的正确使用场景

虽然有限差分不适合实时控制,但它在以下场景不可替代:

  1. 单元测试:验证解析导数实现的正确性
  2. 原型开发:在推导解析公式之前快速验证算法可行性
  3. 复杂约束:某些非光滑约束(如摩擦锥切换)难以写出解析导数
  4. 调试:当解析导数结果可疑时,FD 是终极参考
# 典型用法:用 FD 验证解析导数
import pinocchio as pin
import numpy as np

def verify_rnea_derivatives(model, data, q, v, a, eps=1e-8):
    """用中心差分验证 computeRNEADerivatives"""
    dtau_dq_analytic, dtau_dv_analytic, _ = pin.computeRNEADerivatives(model, data, q, v, a)

    # 数值差分 dtau/dq
    dtau_dq_fd = np.zeros((model.nv, model.nv))
    for j in range(model.nv):
        # 正扰动
        v_eps = np.zeros(model.nv); v_eps[j] = eps
        q_plus = pin.integrate(model, q, v_eps)  # 注意:Lie 群积分!
        tau_plus = pin.rnea(model, data, q_plus, v, a)
        # 负扰动
        q_minus = pin.integrate(model, q, -v_eps)
        tau_minus = pin.rnea(model, data, q_minus, v, a)
        # 中心差分
        dtau_dq_fd[:, j] = (tau_plus - tau_minus) / (2 * eps)

    error = np.max(np.abs(dtau_dq_analytic - dtau_dq_fd))
    print(f"max |analytic - FD|: {error:.2e}")  # 应该 < 1e-6
    return error

⚠️ 编程陷阱:浮基时 FD 必须用 pin.integrate 而非 q + dq

对浮基模型,q 包含四元数。直接 q + epsilon * e_j 会破坏 |quaternion| = 1 约束,导致后续 RNEA 结果错误。正确做法是通过 Lie 群积分 pin.integrate(model, q, v_eps) 在切空间上做扰动。

⚠️ 常见陷阱

⚠️ 编程陷阱:FD 步长选择

错误做法:硬编码 eps = 1e-6(或 1e-8)。

问题:最优步长取决于函数的二阶导数大小。对高刚度系统(如大惯量 + 强重力),f'' 很大,需要更小的 eps;对数值接近机器精度的量,eps 太小反而更差。

正确做法:对前向差分用 eps = sqrt(eps_mach) * max(|x_j|, 1.0)(缩放到变量量级);或直接用中心差分获得额外精度。

练习

  1. **(编程)**用 Pinocchio 对 UR5 实现 verify_rnea_derivatives,扫描 eps in [10^{-12}, 10^{-2}],画出相对误差曲线。验证最优步长约 5e-6(中心差分)。
  2. **(概念)**解释为什么 4 阶复步长(complex-step)方法可以达到机器精度且无步长选择问题,但为什么 Pinocchio 不用它?(提示:SE(3) 上的复数化不平凡)

5.3 前向自动微分:Dual Numbers 与 CppAD ⭐⭐

动机

自动微分(AD)是介于符号微分和数值微分之间的"第三条路"——它在**数值层面**精确计算导数,不需要手推公式,也不受有限差分的步长问题困扰。理解 AD 是理解 CodeGen 和 Pinocchio 模板设计的前提。

5.3.1 Dual Numbers 的核心思想

扩充实数为**对偶数** a + b epsilon,其中 epsilon^2 = 0(epsilon 不是无穷小,而是代数元素)。

任何解析函数 f 在对偶数上的求值: $\(f(a + b\varepsilon) = f(a) + f'(a)\,b\,\varepsilon\)$

证明:Taylor 展开 f(a + b epsilon) = f(a) + f'(a) b epsilon + f''(a)/2 (b epsilon)^2 + ... = f(a) + f'(a) b epsilon(因为 epsilon^2 = 0,所有高阶项消失)。

意义:只需把输入从 "a" 变为 "a + 1 epsilon"(设种子方向 b=1),函数输出的 epsilon 系数就**精确**等于 f'(a)。不需要步长选择,精度为机器级。

5.3.2 从 Dual Numbers 到 Jet

Ceres 的 Jet<double, N> 把 epsilon 从标量推广为 N 维向量——同时追踪 N 个方向导数:

\[x = a + \sum_{k=1}^N b_k\,\varepsilon_k, \quad \varepsilon_i\,\varepsilon_j = 0\ \forall i,j\]

这等价于一次前向传播同时计算 Jacobian 的 N 列。Eigen 的 AutoDiffScalar<VectorNd> 实现类似功能。

5.3.3 CppAD 的 Tape-Based AD

CppAD(Bradley Bell,COIN-OR 项目)使用 operator overloading 记录计算图(tape):

#include <cppad/cppad.hpp>
using AD = CppAD::AD<double>;

std::vector<AD> x(n), y(m);
CppAD::Independent(x);          // 开始录制 tape
y = f(x);                        // 所有算子被重载,记录到 tape
CppAD::ADFun<double> fun(x, y);  // 停止录制

// 之后任意多次求值(不重新录制)
auto jac = fun.Jacobian(x_val);  // 回放 tape 求 Jacobian
auto hes = fun.Hessian(x_val, w);  // 求 Hessian

5.3.4 Pinocchio 的模板设计

Pinocchio 全代码模板化:ModelTpl<Scalar>DataTpl<Scalar>、所有算法以 Scalar 为模板参数。这意味着**一行代码**就能启用 AD:

// 从 double 模型创建 AD 模型
auto ad_model = model.cast<CppAD::AD<double>>();
// 所有算法(RNEA, ABA, CRBA, FK, centroidal...)自动支持 AD 标量

这是**模板元编程**的威力:算法代码只写一次,通过 Scalar 类型参数化,编译器自动生成 double/AD/CasADi 等各种版本。

5.3.5 CppAD 运行时的性能开销

CppAD 在"运行时解释 tape"模式下的开销约为原生算法的 5-10 倍

模型 RNEA (double) RNEA (CppAD runtime) 倍率
KUKA 7-DoF 1.2 us 6-12 us 5-10x
Talos 36-DoF 5.5 us 30-55 us 5-10x

原因:CppAD 每次求值都要遍历 tape(一个操作节点的链表),有虚函数调用、分支预测失败、缓存不友好等开销。

结论:CppAD 运行时适合离线验证和原型开发,**不适合**实时控制(但 CodeGen 后就可以)。

跨领域类比:CppAD tape 就像 Python 的解释器——每次执行都要"读一行、翻译一行、执行一行"。CodeGen 则像把 Python 编译成 C——首次编译慢,但之后每次执行飞快。Pinocchio 解析导数则像手写的 C 代码——不需要编译流水线,直接就是最优的。

⚠️ 常见陷阱

⚠️ 编程陷阱:CppAD tape 污染

忘记 CppAD::Independent(x) 或在 tape 录制中修改非模板成员会导致 tape 记录错误。典型症状:Jacobian 某些列全为零或 NaN。

正确做法:在 Independent 和 ADFun 之间,只使用 AD 标量变量,不访问 double 类型的 model 成员。

⚠️ 编程陷阱:CasADi 不能包装已有 C++ 代码

CasADi 要求用自己的 SX/MX 类型从头构建表达式图。不能把 Pinocchio 的 double 代码直接套上 CasADi——必须用 pinocchio.casadi 桥接(Pinocchio 3)。

练习

  1. **(编程)**实现一个 2D 对偶数类 Dual(存储 value 和 derivative),重载 +, *, sin, cos。用它计算 f(x) = sin(x)^2 + cos(x) 在 x=pi/3 处的导数,与解析结果对比。
  2. **(概念)**解释为什么前向 AD 计算 Jacobian 一列的复杂度等于一次函数评估,而反向 AD 计算 Jacobian 一行的复杂度等于一次函数评估。对 n>m 的 f: R^n -> R^m,哪种更高效?

5.4 ∂RNEA/∂q 的 O(N) 闭式推导(Carpentier-Mansard RSS 2018)⭐⭐

动机

这是本章的**技术核心**。Carpentier-Mansard 在 2018 年 RSS 上发表的这篇论文,提出了 RNEA 一阶偏导的 O(Nd) 解析递推算法。它的核心 insight 令人优雅:placement 对关节角度的微分 = 被作用的 spatial 量与运动子空间 S 的空间叉乘——这一条恒等式把整个推导从不可能变为可能。

5.4.1 RNEA 回顾与记号

回顾 30_ON动力学递推算法 中的 RNEA(Featherstone 记号):

前向运动学 pass(i = 1..N,lambda(i) 为 i 的父关节):

\[v_i = {}^iX_{\lambda(i)}\,v_{\lambda(i)} + S_i\dot q_i \tag{R1}$$ $$a_i = {}^iX_{\lambda(i)}\,a_{\lambda(i)} + S_i\ddot q_i + v_i \times (S_i\dot q_i) \tag{R2}$$ $$h_i = I_i\,v_i, \quad f_i = I_i\,a_i + v_i \times^* h_i - f_i^{\mathrm{ext}} \tag{R3}\]

后向力传递 pass(i = N..1):

\[\tau_i = S_i^T f_i, \quad f_{\lambda(i)}\ \mathrel{+}=\ {}^{\lambda(i)}X^*_i\,f_i \tag{R4}\]

其中: - \(v_i, a_i \in \mathbb{R}^6\):link i 的 spatial velocity / acceleration - \(h_i, f_i \in \mathbb{R}^6\):link i 的 spatial momentum / force - \(I_i \in \mathbb{R}^{6\times 6}\):link i 的 spatial inertia - \(S_i\):关节运动子空间(revolute: 6x1 向量) - \({}^iX_{\lambda(i)}\):从 parent frame 到 child frame 的 Plucker transform - \(\times, \times^*\):motion/force 空间叉算子(= ad, ad*)

5.4.2 核心代数技巧:Placement 微分恒等式

定理(Carpentier-Mansard 2018, Eq. 13-14):

\[\frac{\partial\,{}^iX_{\lambda(i)}}{\partial q_i}\,v_{\lambda(i)} = \bigl({}^iX_{\lambda(i)}\,v_{\lambda(i)}\bigr) \times S_i \tag{Eq.13}\]
\[\frac{\partial\,{}^{\lambda(i)}X^*_i}{\partial q_i}\,f_i = {}^{\lambda(i)}X^*_i\,(S_i \times^* f_i) \tag{Eq.14}\]

物理直觉:关节 i 转动 dq_i 时,连杆 i 的 frame 发生微小旋转。这个旋转对任何 spatial quantity 的效应 = 将其与旋转方向(即 S_i)做一次空间叉乘。

本质洞察:这两条恒等式是整篇论文的"一招制胜"。它把一个看似需要 6x6 张量求导的问题(∂X/∂q 是一个 6x6 矩阵关于标量 q 的导数),压缩成一次熟悉的空间叉乘运算。整个 O(Nd) 复杂度由此而来。

证明 sketch(以 revolute joint 为例):

设关节 i 的 placement 可分解为 \({}^iX_{\lambda(i)} = X_{\text{fixed}} \cdot X_{\text{joint}}(q_i)\),其中 \(X_{\text{joint}}(q_i)\) 只包含绕旋转轴 s 的旋转。

\(X_{\text{joint}}\) 求导:\(dX_{\text{joint}}/dq_i\) 的作用等价于在 child frame 中做一个无穷小旋转 \(\hat s\) —— 这正是 \(S_i\) 的定义。因此:

\[\frac{\partial({}^iX_{\lambda(i)}\,m)}{\partial q_i} = ({}^iX_{\lambda(i)}\,m) \times S_i\]

(右边是"把 parent frame 的 motion 先变换到 child frame,再与 S_i 叉乘"。)

5.4.3 前向导数 Pass(Algorithm 2)

对 u in {q, q_dot} 作为微分变量:

Step 1:速度导数递推: $\(\frac{\partial v_i}{\partial u} = {}^iX_{\lambda(i)}\,\frac{\partial v_{\lambda(i)}}{\partial u} + \frac{\partial v_J}{\partial u} + \text{placement derivative term}\)$

具体展开(对 u = q_j): - 若 j = i(自身关节):利用 Eq.13 添加 \(({}^iX_{\lambda(i)}\,v_{\lambda(i)}) \times S_i\) - 若 j != i 但是 i 的祖先:通过 \({}^iX_{\lambda(i)}\) 传播 - 若 j 既非 i 也非 i 的祖先:∂v_i/∂q_j = 0(稀疏性!

Step 2:动量导数: $\(\frac{\partial h_i}{\partial u} = I_i\,\frac{\partial v_i}{\partial u}\)$

Step 3:加速度导数: $\(\frac{\partial a_i}{\partial u} = {}^iX_{\lambda(i)}\,\frac{\partial a_{\lambda(i)}}{\partial u} + \text{(placement derivative)} + \frac{\partial v_i}{\partial u} \times v_J + v_i \times \frac{\partial v_J}{\partial u}\)$

Step 4:力导数: $\(\frac{\partial f_i}{\partial u} = I_i\,\frac{\partial a_i}{\partial u} + \frac{\partial v_i}{\partial u} \times^* h_i + v_i \times^* \frac{\partial h_i}{\partial u}\)$

5.4.4 后向导数 Pass(Algorithm 3)

\[\frac{\partial\tau_i}{\partial u} = S_i^T\,\frac{\partial f_i}{\partial u} \tag{A3-1}\]
\[\frac{\partial f_{\lambda(i)}}{\partial u}\ \mathrel{+}=\ {}^{\lambda(i)}X^*_i\,\frac{\partial f_i}{\partial u} + \text{(dual placement derivative for } u=q_i\text{)} \tag{A3-3}\]

其中 dual placement derivative(对 u = q_i 时额外添加):

\[{}^{\lambda(i)}X^*_i\,(S_i \times^* f_i)\]

5.4.5 三个关键简化

简化 1:∂tau/∂q_dot 比 ∂tau/∂q 简单得多

对 u = q_dot: - ∂X_J/∂q_dot = 0(placement 不依赖速度) - ∂v_J/∂q_dot_i = S_i(线性关系) - 加速度方程中的许多项消失

代数原因:velocity 对 q_dot 是线性的,所以二阶导数自动为零。

简化 2:∂tau/∂q_ddot = M(q)

由 Lagrange 方程 tau = M(q) q_ddot + C(q, q_dot) q_dot + g(q): $\(\frac{\partial\tau}{\partial\ddot q} = M(q)\)$

这直接等于 CRBA 输出的质量矩阵——不需要额外推导。

简化 3:稀疏性(仅对 \(\partial\tau/\partial\ddot q\) 严格成立)

⚠️ 常见误解\(\partial\tau_i/\partial q_j\) 并非"j 是 i 祖先时才非零"。

反例(2R 平面机械臂)\(\tau_1 = M_{11}\ddot q_1 + M_{12}\ddot q_2 + C_{112}\dot q_1\dot q_2 + C_{122}\dot q_2^2 + g_1(q_1, q_2)\)。这里 \(\partial\tau_1/\partial q_2 \neq 0\)(通过重力项和 Coriolis 项),尽管关节 2 是关节 1 的**后代**而非祖先。

正确的稀疏性陈述

  • \(\partial\tau/\partial\ddot q = M(q)\):质量矩阵的稀疏性由树拓扑决定。对于分支树(如四足),\(M_{ij} = 0\)\(i,j\) 不在同一根到叶路径上,这是真正的结构稀疏性
  • \(\partial\tau/\partial q\)\(\partial\tau/\partial\dot q\):对串联链(如 KUKA)通常是**稠密**矩阵。对分支树有部分稀疏性——只有共享运动链路径的关节对之间才耦合
  • 算法复杂度的来源:O(Nd) 复杂度不是因为结果矩阵稀疏,而是因为算法按列计算时,每列只需传播该关节到根的路径(深度 d)上的量
模型 N (DoF) d (深度) \(\partial\tau/\partial\ddot q\) 非零 稠密元素 \(M\) 稀疏率
KUKA 7 7 28(全下三角+对角) 49 57%
ANYmal 18 4 ~72 324 22%
Talos 36 6 ~216 1296 17%
Atlas 30 6 ~180 900 20%

注意:上表仅反映 \(M(q)\) 的稀疏性。\(\partial\tau/\partial q\) 对于 KUKA 这样的串联链是 7x7 稠密矩阵(无结构稀疏性可利用),O(Nd) 优势仅体现在分支树模型上。

5.4.6 伪代码汇总

// ===== 前向 pass =====
∂v_0 = 0;  ∂a_0 = 0

for i = 1..N:
    X = iX_λ(i);  S = S_i

    // 速度导数 (对 q)
    ∂v_i/∂q = X · ∂v_λ(i)/∂q
    ∂v_i/∂q[:, i] += (X · v_λ(i)) × S   // placement derivative (第 i 列)

    // 速度导数 (对 q_dot)
    ∂v_i/∂q̇ = X · ∂v_λ(i)/∂q̇
    ∂v_i/∂q̇[:, i] += S                   // 直接贡献

    // 加速度导数 (对 q)
    ∂a_i/∂q = X · ∂a_λ(i)/∂q + (X · a_λ(i)) × S [第 i 列]
              + ∂v_i/∂q × v_J + v_i × ∂v_J/∂q

    // 加速度导数 (对 q_dot)
    ∂a_i/∂q̇ = X · ∂a_λ(i)/∂q̇ + ∂v_i/∂q̇ × v_J

    // 动量和力导数
    ∂h_i/∂u = I_i · ∂v_i/∂u
    ∂f_i/∂u = I_i · ∂a_i/∂u + ∂v_i/∂u ×* h_i + v_i ×* ∂h_i/∂u

// ===== 后向 pass =====
for i = N..1:
    ∂τ_i/∂u = S_i^T · ∂f_i/∂u            // 填到 dtau_du 的第 i 行

    if λ(i) ≠ 0:
        ∂f_λ(i)/∂u += X*_λ(i)←i · ∂f_i/∂u
        // 对 u=q_i:额外加 dual placement term
        ∂f_λ(i)/∂q[:, i] += X*_λ(i)←i · (S_i ×* f_i)

5.4.7 与 Pinocchio 源码的对应

Pinocchio data 成员: - data.dVdq ↔ ∂v_i/∂q(6 x nv per joint) - data.dAdq ↔ ∂a_i/∂q - data.dAdv ↔ ∂a_i/∂q_dot - data.dFdq, data.dFdv, data.dFda ↔ 力导数

源码中的 helper 函数(motion.hpp): - Motion::cross(Motion) → ad_xi eta(叉乘 motion) - Motion::cross(Force) → ad*_xi f(叉乘 force) - motionSet::motionAction(v, dV) → 对 6xN motion-set 逐列做 v x - motionSet::inertiaAction(I, dV) → I * dV 逐列

⚠️ 常见陷阱

⚠️ 编程陷阱rnea_partial_da 只填上三角

computeRNEADerivatives 返回的第三个矩阵(∂tau/∂a = M)只填了上三角!使用前必须手动对称化:

Eigen::MatrixXd M = rnea_partial_da;
M.triangularView<Eigen::StrictlyLower>() =
    M.transpose().triangularView<Eigen::StrictlyLower>();
不做这步,M*x 的下三角部分全是垃圾值。

⚠️ 概念误区:认为 ∂tau/∂q 是稠密矩阵

新手想法:既然动力学是非线性的,∂tau/∂q 应该是一般的稠密矩阵。

实际上:由于运动树的因果结构,∂tau_i/∂q_j 只在 j 是 i 的祖先时非零。对串联臂,∂tau/∂q 是下三角的;对树状结构(如四足),它有更复杂但仍然稀疏的模式。利用这个稀疏性是 O(Nd) 复杂度的关键。

5.4.8 完整的 dtau/dq 矩阵的结构分析

为了深入理解稀疏性,让我们对不同机器人模型分析 dtau/dq 的非零模式。

串联臂(如 KUKA 7-DoF):dtau/dq 是**下三角**矩阵(加对角线)。原因:在串联链中,关节 j 是关节 i 的祖先当且仅当 j <= i。因此 dtau_i/dq_j != 0 当且仅当 j <= i。

dtau/dq 非零模式(7-DoF 串联臂):
  q1 q2 q3 q4 q5 q6 q7
tau1 [X  .  .  .  .  .  .]
tau2 [X  X  .  .  .  .  .]
tau3 [X  X  X  .  .  .  .]
tau4 [X  X  X  X  .  .  .]
tau5 [X  X  X  X  X  .  .]
tau6 [X  X  X  X  X  X  .]
tau7 [X  X  X  X  X  X  X]

总非零元素:1+2+3+4+5+6+7 = 28(vs 稠密的 49)。

四足(如 Solo12,4 条腿各 3 关节):每条腿是独立的 3-DoF 串联链,加上 6-DoF 浮基。

dtau/dq 非零模式(浮基四足,简化):
          base(6)  leg1(3)  leg2(3)  leg3(3)  leg4(3)
base(6)  [XXXXX    XXX      XXX      XXX      XXX    ]
leg1(3)  [XXXXX    XXX      ...      ...      ...    ]
leg2(3)  [XXXXX    ...      XXX      ...      ...    ]
leg3(3)  [XXXXX    ...      ...      XXX      ...    ]
leg4(3)  [XXXXX    ...      ...      ...      XXX    ]

基座行/列与所有关节耦合(因为基座是所有关节的祖先),但不同腿之间**不直接耦合**。

工程意义:QP 求解器(如 OSQP、qpOASES)可以利用这个稀疏模式加速求解。Pinocchio 的解析导数**精确**保留了这个模式(零元素严格为零),而有限差分会在零元素位置产生 ~10^{-7} 的残差,破坏稀疏性。

5.4.9 Lie 群切空间上的导数

对浮基模型,dtau/dq 实际上是在**切空间**上表达的——因为 q 的四元数部分有 4 个分量,但切空间只有 3 维。

Pinocchio 的 computeRNEADerivatives 返回的 dtau_dq 是 nv x nv 矩阵(不是 nv x nq),因为导数是对切空间变量 v(而非参数 q)求的。

这意味着:在计算"dtau/dq"时,实际做的是: $\(\frac{\partial\tau}{\partial q}\bigg|_{\text{tangent}} = \lim_{h\to 0} \frac{\tau(\mathrm{Exp}(q, h\,e_j)) - \tau(q)}{h}\)$

其中 Exp(q, delta) 是 Lie 群上的指数映射积分(Pinocchio 的 integrate)。

⚠️ 编程陷阱:用有限差分验证浮基导数时,必须用 pin.integrate(model, q, eps * e_j) 做扰动,不能用 q + eps * e_j。后者会破坏四元数的单位约束,导致 RNEA 返回错误结果,进而让 FD 验证失败——你会以为解析导数有 bug,但其实是 FD 本身的错误。

练习

  1. **(核心手推)**对 2R 平面臂(两个 revolute 关节),手动执行 Algorithm 2-3 的每一步。具体写出 ∂v_1/∂q_1, ∂v_2/∂q_1, ∂v_2/∂q_2, ∂f_2/∂q_1, ∂tau_1/∂q_2 的表达式。
  2. **(编程)**用 Pinocchio 对 KUKA iiwa 计算 dtau_dq,打印其稀疏模式(非零元素位置),验证下三角结构。
  3. (概念)**解释为什么对 **并联 机构(如 Delta robot),Carpentier-Mansard 算法的优势减小(因为"祖先"关系更复杂)。
  4. **(进阶)**对浮基 Solo12,计算 dtau_dq 并可视化其稀疏模式。解释为什么基座的前 6 行/列是稠密的,而不同腿之间是稀疏的。

5.5 ABA 解析导数:从 RNEA 导数 + M^{-1} 直接导出 ⭐⭐

动机

正向动力学(FD)求解 q_ddot = FD(q, v, tau)。DDP/iLQR 需要 ∂q_ddot/∂(q, v, tau)。直接对 ABA 的三趟递推求导会引入大量中间张量——Carpentier-Mansard 的妙招是利用 ID 和 FD 的互逆关系

5.5.1 核心恒等式

逆动力学 ID 和正向动力学 FD 互为逆运算:

\[\mathrm{ID}(q, \dot q, \mathrm{FD}(q, \dot q, \tau)) = \tau\]

对 u in {q, q_dot} 求偏导(tau 不变):

\[\frac{\partial\mathrm{ID}}{\partial u} + \frac{\partial\mathrm{ID}}{\partial\ddot q}\cdot\frac{\partial\mathrm{FD}}{\partial u} = 0\]

由于 \(\frac{\partial\mathrm{ID}}{\partial\ddot q} = \frac{\partial\tau}{\partial\ddot q} = M(q)\)(质量矩阵),得:

\[\frac{\partial\mathrm{ID}}{\partial u} + M\cdot\frac{\partial\ddot q}{\partial u} = 0\]

因此:

\[\boxed{\frac{\partial\ddot q}{\partial q} = -M^{-1}\frac{\partial\tau}{\partial q}, \quad \frac{\partial\ddot q}{\partial\dot q} = -M^{-1}\frac{\partial\tau}{\partial\dot q}, \quad \frac{\partial\ddot q}{\partial\tau} = M^{-1}}\]

意义:ABA 导数问题**完全还原为 RNEA 导数 + 一次 M^{-1} 矩阵-向量乘(或矩阵-矩阵乘)**。不需要直接微分 ABA 的复杂递推!

反事实推理:如果不用这个互逆技巧,而是直接对 ABA 三趟递推求导,会怎样?ABA 的中间量(如 articulated body inertia I^A_i, bias force p^A_i)本身就是 6x6 矩阵。对它们求导会产生 6x6xn 的三阶张量——存储和计算量都大幅增加,代码也极其复杂。互逆技巧的优雅在于:把一个"正向导数"问题转化为"逆向导数 + 线性求解",后者的中间量已经在 §5.4 中计算好了。

5.5.2 高效 M^{-1}:AZA 算法

既然 ∂q_ddot/∂tau = M^{-1},我们需要高效计算 M^{-1} 的所有列。

方法 1:CRBA + Cholesky 分解

\[M = L D L^T \quad \Rightarrow \quad M^{-1} = L^{-T} D^{-1} L^{-1}\]

Pinocchio 用 稀疏 UDU^T 分解(利用运动树的 elimination ordering),复杂度 O(Nd^2)。

方法 2:AZA 算法

ABA 在 tau = e_j(单位向量)、q_dot = 0、g = 0 的特殊输入下,输出恰好是 M^{-1} 的第 j 列:

\[\ddot q = \mathrm{ABA}(q, 0, e_j) = M^{-1} e_j = (M^{-1})_{:,j}\]

对所有 j = 1..n 做一次,得到完整的 M^{-1}。利用对称性,只需计算上(或下)三角——(n+1)n/2 列。

这个方法被 Singh-Russell-Wensing 2022 正式命名为 AZA(ABA-Zero-Algorithm)。

性能对比(Atlas 36-DoF):

方法 耗时
CRBA + Cholesky 28.3 us
AZA 12.7 us

AZA 快约 2 倍——这是因为 ABA 的递推天然利用了树结构,而 Cholesky 分解需要额外的填入(fill-in)处理。

Pinocchio 的 computeMinverse(model, data, q) 即为 AZA 实现:

pin.computeMinverse(model, data, q)
# data.Minv: nv x nv 的 M^{-1} 矩阵(对称,存储上三角)

5.5.3 computeABADerivatives 的完整 API

// C++ API
pinocchio::computeABADerivatives(model, data, q, v, tau);
// 结果:
// data.ddq_dq  : nv x nv  (∂q_ddot/∂q)
// data.ddq_dv  : nv x nv  (∂q_ddot/∂v)
// data.Minv    : nv x nv  (∂q_ddot/∂tau = M^{-1})
# Python API
pin.computeABADerivatives(model, data, q, v, tau)
ddq_dq = data.ddq_dq  # ∂q̈/∂q
ddq_dv = data.ddq_dv  # ∂q̈/∂v̇
Minv   = data.Minv     # ∂q̈/∂τ = M⁻¹

内部流程: 1. 调用 computeRNEADerivatives 得到 ∂tau/∂q 和 ∂tau/∂v 2. 调用 computeMinverse(AZA)得到 M^{-1} 3. 计算 ∂q_ddot/∂q = -M^{-1} * ∂tau/∂q,∂q_ddot/∂v = -M^{-1} * ∂tau/∂v

5.5.4 在 iLQR/DDP 中的使用

DDP 的连续时间状态方程: $\(\dot x = f(x, u) = \begin{bmatrix} v \\ \mathrm{FD}(q, v, \tau) \end{bmatrix} = \begin{bmatrix} v \\ \ddot q \end{bmatrix}\)$

Jacobian: $\(A = \frac{\partial f}{\partial x} = \begin{bmatrix} 0 & I \\ \partial\ddot q/\partial q & \partial\ddot q/\partial v \end{bmatrix}, \quad B = \frac{\partial f}{\partial u} = \begin{bmatrix} 0 \\ M^{-1} \end{bmatrix}\)$

这三个矩阵正好是 computeABADerivatives 的输出!

Crocoddyl 中的对应DifferentialActionModelFreeFwdDynamics::calcDiff()):

// Crocoddyl 内部(简化)
pinocchio::computeABADerivatives(pin_model_, *pin_data_, q, v, tau);
// 组装 Fx = [0, I; ddq_dq, ddq_dv]
// 组装 Fu = [0; Minv * actuation_matrix]

练习

  1. **(手推)**证明 ∂ID/∂q_ddot = M(q)。(提示:RNEA 可以写成 tau = M q_ddot + h(q, v),其中 h 不依赖 q_ddot。)
  2. **(编程)**对 UR5,用 computeABADerivatives 得到 ddq_dq,与 -(pin.computeMinverse(model, data, q) @ dtau_dq) 对比,验证一致性。
  3. **(概念)**解释为什么 data.Minv 在浮基模型下是 (6+n) x (6+n),而 B 矩阵(控制输入 Jacobian)是 (6+n) x n——前 6 行对应 unactuated 的浮基。

5.6 Pinocchio 源码精读 ⭐⭐

动机

调用 API 只是第一步。当你需要**修改**算法(如添加新的关节类型、集成到自定义框架)或**调试**异常行为时,必须能读懂源码。本节带你走一遍 Pinocchio 的 rnea-derivatives.hxx 核心逻辑。

5.6.1 关键文件路径

文件 作用
include/pinocchio/algorithm/rnea-derivatives.hpp/.hxx computeRNEADerivatives 主实现
include/pinocchio/algorithm/aba-derivatives.hpp/.hxx computeABADerivatives,复用 RNEA 导数 + AZA
include/pinocchio/algorithm/kinematics-derivatives.hpp/.hxx FK 导数
include/pinocchio/algorithm/rnea-second-order-derivatives.hpp/.hxx 二阶导数
include/pinocchio/algorithm/cholesky.hxx 稀疏 UDU^T
include/pinocchio/algorithm/constrained-dynamics-derivatives.hpp/.hxx 约束动力学导数 (v3)
include/pinocchio/autodiff/cppad.hpp, casadi.hpp AD 标量适配

5.6.2 核心 C++ API 签名

template<typename Scalar, int Options, template<typename,int> class JC,
         typename VQ, typename VV, typename VA,
         typename M1, typename M2, typename M3>
void computeRNEADerivatives(
    const ModelTpl<Scalar,Options,JC>& model,
    DataTpl<Scalar,Options,JC>&       data,
    const Eigen::MatrixBase<VQ>& q,     // nq x 1
    const Eigen::MatrixBase<VV>& v,     // nv x 1
    const Eigen::MatrixBase<VA>& a,     // nv x 1
    const Eigen::MatrixBase<M1>& rnea_partial_dq,   // ∂τ/∂q (nv x nv)
    const Eigen::MatrixBase<M2>& rnea_partial_dv,   // ∂τ/∂v̇ (nv x nv)
    const Eigen::MatrixBase<M3>& rnea_partial_da);  // ∂τ/∂a = M (上三角!)

5.6.3 源码级伪代码对应

rnea-derivatives.hxx 的前向 pass:

// 对每个关节 i(从根到叶):
for(int i = 1; i < model.njoints; ++i) {
    // 1. 计算 FK 量(v_i, a_i 已在前一步 rnea 中算好)
    // 2. 速度导数传播
    //    data.dVdq[i] = X * data.dVdq[parent] + placement_term
    //    data.dVdv[i] = X * data.dVdv[parent] + S_i
    // 3. 加速度导数
    //    data.dAdq[i] = X * data.dAdq[parent] + ...
    // 4. 力导数
    //    data.dFdq[i] = I * data.dAdq[i] + cross_terms
}

后向 pass:

for(int i = model.njoints-1; i > 0; --i) {
    // 1. tau 导数:第 i 行 = S_i^T * data.dFdq[i]
    // 2. 传播到 parent:data.dFdq[parent] += X* * data.dFdq[i] + ...
}

5.6.4 Python 使用示例

import pinocchio as pin
import numpy as np

# 加载模型
model = pin.buildModelFromUrdf("ur5.urdf")
data = model.createData()

# 随机状态
q = pin.randomConfiguration(model)
v = np.random.randn(model.nv)
a = np.random.randn(model.nv)

# 计算 RNEA 导数
dtau_dq, dtau_dv, dtau_da = pin.computeRNEADerivatives(model, data, q, v, a)
# dtau_da 是 M 矩阵(上三角),需要对称化
M = dtau_da.copy()
M[np.tril_indices(model.nv, -1)] = M.T[np.tril_indices(model.nv, -1)]

# 验证 dtau_da == M(来自 CRBA)
pin.crba(model, data, q)
M_crba = data.M.copy()
M_crba[np.tril_indices(model.nv, -1)] = M_crba.T[np.tril_indices(model.nv, -1)]
assert np.allclose(M, M_crba), "M from RNEA derivatives should equal CRBA"

# 计算 ABA 导数(FD 导数)
tau = pin.rnea(model, data, q, v, a)  # 先算对应的 tau
pin.computeABADerivatives(model, data, q, v, tau)
print(f"∂q̈/∂q shape: {data.ddq_dq.shape}")  # (nv, nv)
print(f"∂q̈/∂v shape: {data.ddq_dv.shape}")  # (nv, nv)
print(f"M^{-1} shape: {data.Minv.shape}")     # (nv, nv)

# 验证一致性:ddq_dq == -Minv @ dtau_dq
ddq_dq_check = -data.Minv @ dtau_dq
print(f"一致性误差: {np.max(np.abs(data.ddq_dq - ddq_dq_check)):.2e}")

5.6.5 CppAD 模板标量切换

#include "pinocchio/autodiff/cppad.hpp"
using ADScalar = CppAD::AD<double>;
using ADModel = pinocchio::ModelTpl<ADScalar>;

// 一行完成 double -> AD 切换
pinocchio::Model model;
pinocchio::buildModels::humanoidRandom(model);
ADModel ad_model = model.cast<ADScalar>();
ADModel::Data ad_data(ad_model);

// 录制 tape
Eigen::Matrix<ADScalar, Eigen::Dynamic, 1> ad_q = q0.cast<ADScalar>();
CppAD::Independent(ad_q);                       // tape start
auto ad_tau = pinocchio::rnea(ad_model, ad_data, ad_q, ad_v, ad_a);
CppAD::ADFun<double> f(ad_q, ad_tau);           // tape end

// 求 Jacobian(运行时回放)
auto jac = f.Jacobian(std::vector<double>(q0.data(), q0.data() + model.nq));

练习

  1. **(源码阅读)**打开 Pinocchio 的 rnea-derivatives.hxx,找到前向 pass 中 "placement derivative" 对应的代码行。它调用了哪个 helper 函数?
  2. **(编程)**用 CppAD + Pinocchio 对 UR5 录制 RNEA tape,求 Jacobian,与 computeRNEADerivatives 对比验证。

5.7 CppADCodeGen 代码生成流水线(OCS2 路线)⭐⭐

动机

OCS2(ETH leggedrobotics)的动力学导数路线是 Pinocchio + CppADCodeGen。这条路线的核心思想:用 CppAD 录制计算图(tape),然后把 tape "打印"成纯 C 代码,编译为共享库 (.so),运行时通过函数指针调用——性能接近手写 C,无解释器开销。

5.7.1 核心六步

Step 1:构造 AD 版 Pinocchio 模型

using ad_scalar_t  = CppAD::cg::CG<double>;      // CodeGen 标量
using ad_scalar_ad = CppAD::AD<ad_scalar_t>;       // AD<CG<double>>
pinocchio::ModelTpl<ad_scalar_ad> modelAd = model.cast<ad_scalar_ad>();

注意:这里用的是 AD<CG<double>> 而非 AD<double>——前者不在运行时求值,而是记录一个可生成 C 代码的表达式图。

Step 2:定义 AD 闭包(用户 Lambda)

auto dynamics_func = [&](const ad_vector_t& x, ad_vector_t& y) {
    ad_vector_t q = x.head(model.nq);
    ad_vector_t v = x.segment(model.nq, model.nv);
    ad_vector_t tau = x.tail(model.nv);
    pinocchio::aba(modelAd, dataAd, q, v, tau);
    y = dataAd.ddq;  // 正向动力学输出
};

Step 3:录制 Tape

ad_vector_t x_ad(input_dim);
CppAD::Independent(x_ad);
ad_vector_t y_ad(output_dim);
dynamics_func(x_ad, y_ad);
CppAD::ADFun<ad_scalar_t> fun(x_ad, y_ad);

OCS2 用 CppAdInterface 封装了这个过程。

Step 4:生成 C 源代码

using namespace CppAD::cg;
ModelCSourceGen<double> csrc(fun, "robot_dynamics");
csrc.setCreateJacobian(true);
csrc.setCreateSparseJacobian(true);    // 只生成非零元素
ModelLibraryCSourceGen<double> lib(csrc);
DynamicModelLibraryProcessor<double> proc(lib, "robot_lib");

Step 5:GCC/Clang 编译为 .so

GccCompiler<double> compiler;
compiler.setSourcesFolder("/tmp/codegen_sources");
auto dynLib = proc.createDynamicLibrary(compiler, true);

OCS2 默认编译选项:-O3 -march=native -shared -fPIC

Step 6:运行时 dlopen 调用

auto model_handle = dynLib->model("robot_dynamics");
std::vector<double> x_val = ...;
auto jac = model_handle->Jacobian(x_val);  // 函数指针调用,无解释器

5.7.2 生成代码的特征

CppADCodeGen 的图优化会: - 消除所有乘 0、加 0、乘 1 的冗余操作 - 稀疏 Jacobian 只生成非零项的计算代码 - 无循环、无分支、无堆分配——纯标量内联运算 - 可选 loop-detection 对重复模式做代码瘦身

典型生成的 C 代码片段:

// 自动生成的 robot_dynamics_sparse_jacobian.c(片段)
void sparse_jacobian(const double* x, double* jac) {
    double t0 = x[3] * x[7] + x[4] * x[8];  // 直接标量运算
    double t1 = sin(x[0]);
    double t2 = cos(x[0]);
    // ... 数百行无分支标量代码 ...
    jac[0] = t0 * t1 + t2;
    jac[1] = ...;
}

5.7.3 OCS2 的工程实践

OCS2 的 task.info 配置文件中:

[model_settings]
recompileLibrariesCppAd = true   ; 首次运行时编译
modelFolderCppAd = /tmp/ocs2     ; 缓存编译结果

典型工作流: 1. 首次运行:录制 tape + 生成 C + 编译 .so(耗时数秒到分钟) 2. 后续运行:直接 dlopen 缓存的 .so(耗时 ~ms) 3. 修改 URDF/cost/约束后:设 recompileLibrariesCppAd = true 触发重编译

⚠️ 编程陷阱:CodeGen 缓存失效

修改了 URDF 或 cost 函数却没重编译 .so → OCS2 会沿用旧的动力学/约束。症状:控制器行为与预期不符,但不报错。

正确做法:任何模型/cost 修改后,手动清空 modelFolderCppAd 或设 recompileLibrariesCppAd = true

5.7.4 为什么 OCS2 选 CodeGen 而 Crocoddyl 选 Analytical

维度 OCS2 (CodeGen) Crocoddyl (Analytical)
cost/约束复杂度 高(carrot, swing, ZMP, friction cone...) 中(标准动力学 cost)
用户自定义 只需写 Eigen + Pinocchio,AD 自动处理 需对每个新 cost 写解析导数
首次开销 数秒到分钟编译
运行时性能 与解析同级(甚至更快,因为全展开) mus 级
Lie 群支持 通过 CppAD 间接支持 原生 SE(3) 闭式
调试便利性 较难(生成代码不可读) 较易(Python/C++ 镜像)

最佳实践:两路线**可以共存**——底层 RBD(RNEA/ABA/CRBA 的导数)走 Pinocchio 解析(mus 级、零编译),上层 cost/constraint(如 swing foot tracking、摩擦锥、self-collision)走 CppADCodeGen(用户友好、易扩展)。OCS2 的真实架构正是如此

5.7.5 CasADi SX → C 代码的替代路线

CasADi 提供另一条符号 → C 代码的路线:

import casadi
import pinocchio.casadi as cpin

# CasADi 符号变量
q_sx = casadi.SX.sym('q', model.nq)
v_sx = casadi.SX.sym('v', model.nv)
tau_sx = casadi.SX.sym('tau', model.nv)

# Pinocchio CasADi 接口
cmodel = cpin.Model(model)
cdata = cmodel.createData()
cpin.aba(cmodel, cdata, q_sx, v_sx, tau_sx)

# 创建 CasADi 函数
ddq_func = casadi.Function('ddq', [q_sx, v_sx, tau_sx], [cdata.ddq])
ddq_dq_func = ddq_func.jacobian()

# 生成 C 代码
ddq_func.generate('robot_dynamics.c', {'with_header': True})

CasADi 的优势:与 IPOPT/SNOPT 的原生集成、MX 图对大型模型更高效。 劣势:需要 Pinocchio 3 的 casadi 桥接,对老版本 Pinocchio 不可用。

练习

  1. **(概念)**解释 CodeGen 的 .so 为什么比 CppAD 运行时 tape 快——两者不都是"一次录制,多次求值"吗?(提示:tape 解释 vs 函数指针)
  2. **(工程)**在 OCS2 的 ocs2_pinocchio_interface 中找到 CppAdInterface 的使用位置,列出它封装了上述六步中的哪几步。

5.8 四条路线的完整对比 ⭐⭐

5.8.1 性能基准数据

以下数据综合自 Carpentier-Mansard RSS 2018 Table I、Neunert-Giftthaler SIMPAR 2016、Pinocchio benchmarks。平台:Intel i7 @ 2.2 GHz, LLVM 9.0, 单核。

7-DoF 臂(KUKA / iiwa / Panda)

路线 ∂tau/∂(q,v) (us) ∂q_ddot/∂(q,v,tau) (us) 精度
RNEA baseline 1.20
ABA baseline 1.78
Pinocchio 解析 3.34 5.78 10^{-15}
CppADCodeGen .so 1.5-3.0 2-4 10^{-15}
CppAD 运行时 5-10 6-12 10^{-15}
有限差分 21.26 22.67 10^{-7}
RobCoGen C++ 1.1-2 2-3 10^{-15}

36-DoF 人形(Atlas / Talos)

路线 ∂tau/∂(q,v) (us) ∂q_ddot/∂(q,v,tau) (us) 精度
RNEA/ABA baseline 5.51 / 9.81
Pinocchio 解析 16.72 45.20 10^{-15}
CppADCodeGen 8-15 25-40 10^{-15}
CppAD 运行时 30-60 50-80 10^{-15}
有限差分 452.46 470.14 10^{-7}
AZA (M^{-1} only) 12.70 10^{-15}

5.8.2 选型决策表

场景 推荐路线 理由
实时 MPC (100+ Hz) Pinocchio 解析 零编译、mus 级、确定性
复杂 cost/约束 CppADCodeGen 用户只写 Eigen,AD 自动处理
快速原型 CppAD 运行时 零编译、精度高
单元测试/验证 有限差分 最简单、适用任何函数
嵌入式 (无 Pinocchio) CodeGen .c 纯标量代码,无依赖
可微仿真训练 JAX AD (MJX) GPU 并行 + vjp/jvp

5.8.3 关键观察

  1. 解析导数 ≈ 3x RNEA:稳定的常数因子,不随 DoF 变化
  2. 有限差分在大系统慢 27-45x:完全不可接受用于实时
  3. CodeGen 可以比解析更快:因为全展开消除了循环/分支
  4. 解析严格保零模式:对 DDP 稀疏性至关重要

本质洞察:四条路线不是"越新越好"的线性关系,而是在精度-速度-灵活性-开发成本四维空间中的不同 trade-off。工程实践中最常见的"最优组合"是:底层 RBD 走 Pinocchio 解析,上层 cost/约束走 CodeGen。


5.9 二阶导数与 Exact DDP ⭐⭐⭐

动机

标准 iLQR 使用 Gauss-Newton (GN) 近似:\(H \approx J^T J\),忽略了动力学的二阶项 \(\partial^2 f/\partial x^2\)。这在大多数情况下足够(因为最小二乘 cost 的 GN 近似在解附近是好的),但在某些场景下精确 Hessian 能显著加速收敛。

5.9.0 为什么 DDP 的收敛速度关乎实时性

在进入二阶导数的技术细节之前,理解"收敛速度"对 MPC 的实际影响很重要。

一个 real-time MPC 控制器(如 OCS2 在 ANYmal 上)的工作方式是:

每 10ms:
  1. 读取当前状态 x_0
  2. 用上一次的解作为 warm-start
  3. 做 K 次 iLQR/DDP 迭代
  4. 输出第一个时间步的控制 u_0*

关键约束:K 次迭代必须在 10ms 内完成。如果每次迭代需要 1ms,那最多做 10 次。

**GN 近似(iLQR)**的收敛速度是**超线性**的(asymptotically quadratic near solution)。但远离解时,它可能需要 20-50 次迭代才接近最优。

**精确 Hessian(DDP)**的收敛速度是严格**二次**的(even far from solution)。典型地只需要 5-10 次迭代。

对 humanoid 的大动作规划(如从蹲到站、前空翻恢复),初始猜测可能远离最优解——此时精确 Hessian 的收敛优势是决定性的。

反事实推理:如果我们只有 GN 近似且迭代预算有限(K=5),对大扰动恢复会怎样?GN 在前几次迭代的步长很保守(因为低估了曲率),K=5 时可能还没走出初始猜测的邻域。精确 Hessian 在第 2-3 次迭代就能给出合理的步长——差异直接体现为"机器人是否能从倾倒中恢复"。

5.9.1 iLQR 的 GN 近似 vs Exact DDP

iLQR(Gauss-Newton): $\(Q_{xx} \approx \ell_{xx} + f_x^T V_{xx}' f_x\)$

忽略了 \(V_x' \cdot f_{xx}\) 项(动力学的二阶张量与 value function 梯度的缩并)。

Exact DDP: $\(Q_{xx} = \ell_{xx} + f_x^T V_{xx}' f_x + \sum_k (V_x')_k \cdot (f_{xx})_k\)$

其中 \((f_{xx})_k = \partial^2 f_k/\partial x^2\) 是动力学第 k 个分量的 Hessian。

5.9.2 何时需要 Exact DDP

场景 GN 够用? 原因
正常跟踪 偏差小,GN 近似精确
大扰动恢复 远离解,GN 低估曲率
非最小二乘 cost GN 只对
精度要求极高 GN 收敛速度是超线性,exact 是二次
硬约束 augmented Lagrangian 约束 Hessian 不可忽略

5.9.3 Singh-Russell-Wensing 2022 的解析二阶递推

Singh-Russell-Wensing (IROS 2022, arXiv:2203.01497) 给出了**解析二阶 RNEA 导数**的递推公式。核心思想:对 Alg. 2-3 再做一次关于 q 的微分,得到 \(\partial^2\tau/\partial q_i \partial q_j\)

复杂度:O(Nd^2)——比一阶的 O(Nd) 多一个 d 因子(因为每个一阶导数列都需要再微分)。

Pinocchio 已实现:rnea-second-order-derivatives.hxx

性能:对 7-DoF 臂约 30-50 us,36-DoF 约 200-500 us。比一阶慢 10-20 倍,但比 FD 计算二阶导数(需要 O(n^2) 次 RNEA)快得多。

5.9.4 在 SNOPT/IPOPT 中的应用

精确 Hessian 在 NLP 求解器中的价值: - IPOPT 的 exact Hessian mode 比 L-BFGS 近似收敛快 2-5 倍 - SNOPT 的 QP 子问题用精确 Hessian 时步长更大 - acados 的 RTI(Real-Time Iteration)scheme 中,精确 Hessian 减少外循环迭代数

反事实推理:如果我们只有 GN 近似,对 humanoid 的大动作规划(如从蹲到站)会怎样?GN 低估了动力学的非线性耦合,导致需要 20-50 次外循环迭代才收敛;精确 Hessian 只需要 5-10 次。对 real-time MPC 来说,迭代次数的差异直接决定了"能否在 10ms 窗口内收敛"。

练习

  1. **(概念)**解释 GN 近似在什么条件下等于精确 Hessian?(提示:当 \(V_x' = 0\) 时——即在最优解处。)
  2. **(进阶)**写出 exact DDP 中 \(Q_{xu}\) 的完整表达式(包含二阶项),解释为什么它比 \(Q_{xx}\) 更容易计算。

5.10 与自动微分的详细对比 ⭐⭐

5.10.1 CppAD vs CasADi vs JAX

维度 CppAD CasADi JAX
语言 C++ C++/Python/MATLAB Python
AD 模式 前向 + 反向 前向 + 反向 前向 + 反向 + vmap
图类型 Tape (操作序列) SX (标量图) / MX (矩阵图) XLA (编译图)
GPU (TPU/GPU)
CodeGen (CppADCodeGen) (C/MATLAB/Python) (XLA ahead-of-time)
Pinocchio 集成 原生模板 Pinocchio 3 casadi 桥 无直接(需 MJX 或手写 JAX 动力学)
典型用户 OCS2, Crocoddyl AD 验证 acados, 学术优化 MJX, Brax, RL

5.10.2 精度对比实验

import pinocchio as pin
import numpy as np

model = pin.buildModelFromUrdf("ur5.urdf")
data = model.createData()
q = pin.randomConfiguration(model)
v = np.random.randn(model.nv)
a = np.random.randn(model.nv)

# 1. 解析导数(ground truth)
dtau_dq_analytic, _, _ = pin.computeRNEADerivatives(model, data, q, v, a)

# 2. 前向 FD (eps = 1e-8)
dtau_dq_fd = np.zeros((model.nv, model.nv))
for j in range(model.nv):
    eps = 1e-8
    dq = np.zeros(model.nv); dq[j] = eps
    q_p = pin.integrate(model, q, dq)
    tau_p = pin.rnea(model, data, q_p, v, a)
    q_m = pin.integrate(model, q, -dq)
    tau_m = pin.rnea(model, data, q_m, v, a)
    dtau_dq_fd[:, j] = (tau_p - tau_m) / (2 * eps)

# 3. 对比
err_fd = np.max(np.abs(dtau_dq_analytic - dtau_dq_fd))
print(f"FD 最大误差: {err_fd:.2e}")  # 期望 ~1e-7 到 1e-6

# CppAD 和解析的差异应该是 ~1e-15(机器精度)

5.10.3 MJX-JAX 的特殊地位

MuJoCo 的 JAX 后端(MJX)值得特别讨论,因为它代表了一种与 Pinocchio 完全不同的哲学。

MJX 的核心优势: 1. GPU 批处理:可以同时对数千个状态计算导数(用于 RL 训练) 2. JAX 生态:与 Flax/Optax/JAX MD 等无缝集成 3. vjp/jvp 原生支持:不需要显式 Jacobian,直接 vector-Jacobian product

MJX 的限制: 1. 不利用树结构稀疏性:把动力学当作一般函数 AD,复杂度是 O(n^2) 而非 O(Nd) 2. MJX-Warp 后端不支持 AD(截至 2026 年 4 月):需要 AD 必须用 JAX 后端 3. Solver 迭代数影响梯度质量mjd_transitionFD 的准确性取决于 solver 是否收敛

典型用法

import jax
import mujoco
from mujoco import mjx

# 加载 MuJoCo 模型
mj_model = mujoco.MjModel.from_xml_path("robot.xml")
mjx_model = mjx.put_model(mj_model)
mjx_data = mjx.put_data(mj_model, mj_data)

# 单步仿真函数
def step(data, ctrl):
    data = data.replace(ctrl=ctrl)
    data = mjx.step(mjx_model, data)
    return data

# JAX 自动微分
step_jac = jax.jacobian(lambda ctrl: step(mjx_data, ctrl).qpos)(ctrl)

# 批处理:同时对 1024 个状态求导
batched_step = jax.vmap(step)
batched_jac = jax.vmap(jax.jacobian(lambda c: step(d, c).qpos))

何时选 MJX vs Pinocchio

场景 推荐 原因
Real-time MPC (单状态) Pinocchio 解析 O(Nd) vs O(n^2),CPU 更快
RL 训练 (批处理 1000+) MJX-JAX GPU 并行压倒一切
接触丰富的仿真 MuJoCo (原生) 接触模型更完善
系统辨识 / co-design Pinocchio + CasADi 需要精确解析结构

5.10.4 在不同场景下的选择指南

你的项目需要什么?

需要实时控制?
├── 是 → 底层 RBD 走 Pinocchio 解析
│         ├── 上层 cost 简单?→ 解析也手推(Crocoddyl 路线)
│         └── 上层 cost 复杂?→ CppADCodeGen(OCS2 路线)
└── 否 → 
    ├── 需要 GPU 并行?→ JAX (MJX/Brax)
    ├── 需要与 NLP solver 集成?→ CasADi
    └── 需要快速原型?→ CppAD 运行时 或 Python FD

⚠️ 常见陷阱

⚠️ 思维陷阱:认为 "JAX 有自动微分所以不需要 Pinocchio"

新手想法:JAX 可以对任何函数自动求导,所以我直接用 JAX 写动力学然后 jacfwd 就行了。

实际上:JAX AD 是通用的,但不利用动力学的**树状稀疏结构**。对 36-DoF 人形,JAX 的 jacfwd 会把 RNEA 当作一般的 R^n -> R^n 函数处理,产生 O(n^2) 的中间量。而 Pinocchio 解析利用运动树结构达到 O(Nd)。在 CPU 上,Pinocchio 解析比 JAX AD 快 5-20 倍。

**JAX 的真正优势**在于 GPU 批处理——当你需要同时对 1000 个状态计算导数时(如 RL 训练),GPU 的并行性压倒一切。


5.11 代码生成 → 嵌入式部署 ⭐⭐⭐

动机

在实际机器人上,MPC 通常跑在嵌入式计算平台(如 Intel NUC、NVIDIA Jetson、甚至 MCU)。这些平台可能没有 Pinocchio 的完整依赖链。代码生成的终极目标是:把整个动力学 + 导数编译成**无外部依赖**的纯 C 代码,直接烧录到嵌入式平台。

5.11.1 完整部署流水线

设计阶段(PC, 有完整环境):
  URDF + cost/constraint → CppADCodeGen → .c 源文件 → 交叉编译 → .so

部署阶段(嵌入式平台,最小依赖):
  MPC 循环:dlopen(.so) → 函数指针调用 → ddq, Jacobian

5.11.2 OCS2 在 NUC 上的实际部署

ETH leggedrobotics 团队在 ANYmal 上的部署: - 平台:Intel NUC 11 (i7-1165G7) - MPC 频率:200 Hz - 动力学:Pinocchio 解析 - 上层约束:CppADCodeGen .so - 总 CPU 占用:~30%

关键优化: 1. 首次运行在 NUC 上本地编译 .so(避免交叉编译的 ABI 问题) 2. 后续重启直接加载缓存的 .so 3. RNEA/ABA 导数用 Pinocchio 解析(无需 CodeGen) 4. 只对自定义 cost/constraint 用 CodeGen

5.11.3 CasADi 的替代部署路线

# acados + CasADi + Pinocchio 路线
import casadi
import pinocchio.casadi as cpin

# 构建符号动力学
cmodel = cpin.Model(model)
cdata = cmodel.createData()
q_sym = casadi.SX.sym('q', model.nq)
v_sym = casadi.SX.sym('v', model.nv)
tau_sym = casadi.SX.sym('tau', model.nv)
cpin.aba(cmodel, cdata, q_sym, v_sym, tau_sym)

# 导出给 acados
dynamics = casadi.Function('dynamics', [q_sym, v_sym, tau_sym], [cdata.ddq])
dynamics.generate('robot_dynamics.c')  # 纯 C 代码,无运行时依赖

# acados 读取生成的 C 代码作为 explicit/implicit ODE

acados 的优势:自带 C 代码求解器(RTI-SQP),整个 MPC 栈可以编译为单一可执行文件。

5.11.4 RobCoGen 的符号路线

RobCoGen(ETH/IIT,Frigerio-Buchli-Caldwell-Semini JOSER 2016):

KINDSL (.kindsl) → Maxima 符号简化 → Java/Xtend 模板 → C++ (展开/内联)
  • 所有零乘法在 Maxima 层消除
  • 循环完全展开为标量运算
  • IROS 2019 benchmark:iiwa 1.1 us, HyQ 2.4 us, Atlas 5.9 us 正向动力学(全部最快
  • 缺点:每次修改模型都要重新运行整个工具链

练习

  1. **(工程)**解释为什么交叉编译 CppADCodeGen 的 .so 可能遇到 ABI 兼容性问题(提示:编译器版本、glibc 版本、Eigen 对齐)。
  2. **(对比)**列出 OCS2 路线 vs acados 路线在嵌入式部署上的 3 个关键差异。

5.12 惯性参数辨识的 Regressor ⭐⭐⭐

动机

系统辨识是动力学导数的另一个重要应用。Lagrange 方程对惯性参数 theta = (m_i, m_i c_i, I_i) 是**线性的**——这个性质使得回归分析成为可能。

5.12.1 线性回归形式

动力学方程可以写成: $\(\tau = Y(q, \dot q, \ddot q)\,\theta\)$

其中 Y in R^{nv x 10n} 是**回归矩阵**(regressor),theta in R^{10n} 是**惯性参数向量**(每个连杆 10 个参数:质量 1 + 质心 3 + 惯性张量上三角 6)。

为什么是线性的:回顾 RNEA 的力递推 \(f_i = I_i a_i + v_i \times^* (I_i v_i) - f_i^{\text{ext}}\)。空间惯性 \(I_i\) 对 10 个参数 \((m_i, m_i c_i, I_i)\) 是线性的,而 \(a_i, v_i\) 不依赖于惯性参数(它们只依赖于 q, v, a 和运动学结构)。因此 \(f_i\)\(\theta\) 线性,进而 \(\tau_i = S_i^T f_i\)\(\theta\) 也线性。

5.12.2 Pinocchio API

import pinocchio as pin

# 计算 regressor
Y = pin.computeJointTorqueRegressor(model, data, q, v, a)
# Y: nv x 10*njoints 矩阵

# 验证:tau = Y * theta
theta = model.inertias_to_vector()  # 提取当前模型的惯性参数
tau_check = Y @ theta
tau_rnea = pin.rnea(model, data, q, v, a)
print(f"一致性: {np.max(np.abs(tau_check - tau_rnea)):.2e}")  # ~1e-15

5.12.3 Base Parameter Reduction

10n 个参数中,很多是**不可辨识的**(对 tau 没有影响或影响退化)。Gautier-Khalil QR 分解给出**基参数**(base parameters),通常只有 ~4-6n 个。

持续激励条件(Persistent Excitation):辨识精度要求 \(\int_0^T Y^T Y\,dt \succ 0\)(正定)。实践中需要设计特殊的激励轨迹(如 Fourier 级数轨迹)覆盖关节空间。

5.12.4 与动力学导数的联系

Fisher 信息矩阵 \(\mathcal{I} = \sum_t Y_t^T \Sigma^{-1} Y_t\)(Sigma 是噪声协方差)的计算和分析需要 Y 的精确值——这正是 computeJointTorqueRegressor 提供的。辨识精度的 Cramer-Rao 下界 = \(\mathcal{I}^{-1}\) 的对角线。


5.13 运动学导数的解析计算 ⭐⭐

动机

除了动力学导数,运动学导数(如 Jacobian 时间导数 dJ/dt、运动学 Hessian d^2 FK/dq^2)在操作空间控制、TSID 和轨迹优化中同样重要。Pinocchio 提供了完整的运动学导数 API。

5.13.1 Pinocchio 运动学导数 API

// 一次性计算所有 FK 导数(填充 data 的多个成员)
pinocchio::computeForwardKinematicsDerivatives(model, data, q, v, a);

// 获取关节 i 的速度导数
// ref: LOCAL / WORLD / LOCAL_WORLD_ALIGNED
pinocchio::getJointVelocityDerivatives(model, data, joint_id, ref, dv_dq, dv_dv);
// dv_dq: 6 x nv (∂v_i/∂q)
// dv_dv: 6 x nv (∂v_i/∂v̇ = J_i, Jacobian!)

// 获取 Jacobian 时间导数 dJ/dt
pinocchio::getJointJacobianTimeVariation(model, data, joint_id, ref, dJ);
// dJ: 6 x nv 矩阵

// 获取运动学 Hessian(Pinocchio 3)
pinocchio::getJointKinematicHessian(model, data, joint_id, ref, hessian);
// hessian: 6 x nv x nv 三阶张量

5.13.2 dJ/dt 在操作空间控制中的用途

操作空间加速度控制: $\(\ddot x = J(q)\,\ddot q + \dot J(q, \dot q)\,\dot q\)$

要跟踪期望的末端加速度 \(\ddot x_d\),需要求解: $\(\ddot q = J^\dagger\,(\ddot x_d - \dot J\,\dot q) + (I - J^\dagger J)\,\ddot q_0\)$

这里 dJ dot{q} 就是通过 getJointJacobianTimeVariation 得到的。

5.13.3 Reference frame 的选择

Frame 含义 典型用途
LOCAL body frame of joint i 与 RNEA 导数一致
WORLD 固定世界系 任务空间控制
LOCAL_WORLD_ALIGNED 平移到 joint i 但方向对齐世界 TSID 任务

⚠️ 编程陷阱getJointVelocityDerivatives 的 ref 必须与下游 cost/约束的 frame 一致。如果 TSID 的 TaskSE3 用 LOCAL_WORLD_ALIGNED 但你的 Jacobian 用 LOCAL,控制器会出现方向错误的力矩。


5.14 在 MPC/DDP 中的应用模式 ⭐⭐

5.14.1 Crocoddyl 路线(Mastalli ICRA 2020)

回顾 40_SE3几何力学 中的 Centroidal dynamics:Crocoddyl 的 CostModelCentroidalMomentum 需要 A_G(来自 ccrba)的导数——这些导数通过本章的解析框架高效计算。

Pinocchio 解析导数 → DifferentialActionModelFreeFwdDynamics::calcDiff()
→ FDDP/BoxFDDP → 每 10ms 出一个最优轨迹

特点: - 纯 C++ 实现,Python 镜像 API 便于调试 - 直接调用 computeABADerivatives - Lie 群状态空间原生支持 - 适合 whole-body DDP(Talos 前空翻、ANYmal 跳跃)

代码片段(Crocoddyl Python):

import crocoddyl

# 动力学模型自动使用 Pinocchio 解析导数
state = crocoddyl.StateMultibody(model)
actuation = crocoddyl.ActuationModelFull(state)
dmodel = crocoddyl.DifferentialActionModelFreeFwdDynamics(
    state, actuation, cost_model)

# imodel.calcDiff() 内部调用 pin.computeABADerivatives
imodel = crocoddyl.IntegratedActionModelEuler(dmodel, dt)

# DDP 求解
solver = crocoddyl.SolverFDDP(shooting_problem)
solver.solve(xs_init, us_init, maxiter=100)

5.12.2 OCS2 路线(Farshidian et al.)

Pinocchio 解析(底层 RBD) + CppADCodeGen(上层 cost/约束)
→ SLQ/MPC → 200 Hz on NUC

特点: - ROS 集成完善(ocs2_ros_interfaces) - 支持 switched systems(步态切换) - 双线程:MPC 线程 + policy 线程 - 适合足式机器人 locomotion

5.12.3 acados 路线

CasADi + Pinocchio.casadi → acados C 求解器 → RTI-SQP

特点: - 生成纯 C 求解器,无 Python 运行时依赖 - 支持 QP solver 选择:HPIPM, qpOASES, OSQP - 适合嵌入式部署和工业应用 - 对接 Matlab/Simulink

5.12.4 三路线对比

维度 Crocoddyl OCS2 acados
底层语言 C++/Python C++ (ROS) C (Codegen)
动力学导数 Pinocchio 解析 解析 + CodeGen CasADi
求解算法 FDDP/BoxFDDP SLQ (modified iLQR) RTI-SQP
适用场景 Locomotion/manipulation DDP Legged MPC 通用 NMPC
Lie 群支持 原生 有限
ROS 集成 手动 完善 模板
嵌入式 需 Pinocchio 依赖 需 Pinocchio + CodeGen 纯 C 无依赖

5.15 Pinocchio computeMinverse 与 computeRNEADerivatives 源码分析 ⭐⭐⭐

5.13.1 computeMinverse 的 AZA 实现

Pinocchio 的 computeMinverse 实现了 AZA 算法:

核心思想:对每一列 j,执行一次"简化 ABA"——只有 tau = e_j(第 j 个单位向量),q_dot = 0,g = 0。

// 伪代码(简化自 pinocchio/algorithm/compute-all-terms.hxx)
void computeMinverse(const Model& model, Data& data, const VectorXd& q) {
    // Phase 1: 前向运动学(只需要 placement,不需要 v, a)
    for(int i = 1; i < model.njoints; ++i) {
        data.oMi[i] = data.oMi[parent] * data.jointPlacements[i] * joint.calc(q);
        data.liMi[i] = ...;  // 相邻关节间的变换
    }

    // Phase 2: 后向 pass(计算 articulated body inertia)
    for(int i = model.njoints-1; i > 0; --i) {
        // I^A_i = I_i + sum_{child c} X^T_c I^A_c X_c (LTDL update)
        // U_i = I^A_i * S_i
        // D_i = S_i^T * U_i (1x1 for revolute)
        // I^A_{parent} += I^A_i - U_i * D_i^{-1} * U_i^T
    }

    // Phase 3: 前向 pass(逐列填 M^{-1})
    for(int j = 1; j <= model.nv; ++j) {
        // 对 tau = e_j 做一次简化前向 pass
        // a_i = ...(只有 j 的子树有非零加速度)
        // M^{-1}[i, j] = S_i^T * a_i(利用对称性只算上三角)
    }
}

5.13.2 computeRNEADerivatives 的关键优化

Pinocchio 源码中的几个关键优化技巧:

1. 列式计算:不存储完整的 6 x nv 中间矩阵,而是逐列处理(减少缓存 miss)。

2. 利用 S_i 的稀疏性:对 revolute joint,S_i 是 6x1 向量(只有一个非零分量)。叉乘 (X v) x S 退化为 motion 的一个分量提取 + 叉乘——大幅减少运算。

3. 避免重复计算:前向 pass 中的 X v_parentX a_parent 在标准 RNEA 中已计算,直接复用。

4. Eigen 表达式模板:利用 Eigen 的延迟求值避免临时对象分配(但在 AD 场景下需要 .eval() 强制求值)。

5.13.3 性能剖析

perfcallgrind 分析 computeRNEADerivatives 的热点:

操作 占比
motion cross (v x dV) ~30%
inertia action (I * dA) ~25%
force cross (v x* dH) ~20%
placement action (X * dV) ~15%
其他(索引、分支) ~10%

优化方向:SIMD 向量化(Eigen 已做)、缓存局部性(列式 vs 行式)、避免分支(关节类型统一处理)。

练习

  1. **(源码阅读)**打开 pinocchio/algorithm/aba-derivatives.hxx,找到它在哪里调用了 computeRNEADerivativescomputeMinverse
  2. **(概念)**解释为什么 AZA 对"树深 d 小"的模型(如四足,d=4)相比 Cholesky 优势最大。

本章小结

知识点 核心结论 难度 Pinocchio API
导数瓶颈 FD 占 MPC 90% 时间,解析加速 27x
有限差分 最优步长 sqrt(eps_mach),精度 10^{-7},破坏零模式
前向 AD Dual number / CppAD tape,5-10x RNEA ⭐⭐ cast<ADScalar>()
Placement 微分恒等式 ∂X/∂q_i 作用 = 与 S_i 叉乘 ⭐⭐ 内部
∂RNEA/∂q O(Nd) 前向+后向导数 pass ⭐⭐ computeRNEADerivatives
∂RNEA/∂v 简化 velocity 线性→二阶消失 ⭐⭐ 同上
∂tau/∂q_ddot = M 由 Lagrangian 直接读出 ⭐⭐ 同上(第三返回值)
ID-FD 互逆 ∂q_ddot/∂u = -M^{-1} ∂tau/∂u ⭐⭐ computeABADerivatives
AZA (M^{-1}) 比 Cholesky 快 2x ⭐⭐ computeMinverse
CppADCodeGen 6 步:tape→C→.so→dlopen ⭐⭐ OCS2 CppAdInterface
CasADi SX 符号图→C 代码 ⭐⭐⭐ pinocchio.casadi
二阶导数 O(Nd^2),exact DDP 收敛更快 ⭐⭐⭐ rnea-second-order-derivatives
CodeGen 部署 纯 C 无依赖→嵌入式 ⭐⭐⭐

累积项目:本章新增模块

项目:从零构建浮基四足控制器

本章新增模块: - 模块 4-5A:对 Solo12 调用 computeRNEADerivatives,用有限差分验证正确性 - 模块 4-5B:实现一个最简 iLQR(倒立摆),用 computeABADerivatives 提供的 A_t, B_t 做 Riccati 反传 - 模块 4-5C:对比解析 vs FD vs CppAD 的性能,画 benchmark 图

上一章(40_SE3几何力学)的模块:浮基建模 + centroidal dynamics 验证。 下一章(60_约束动力学)将新增:带接触的动力学导数。


延伸阅读

资源 难度 内容
Carpentier-Mansard RSS 2018 ⭐⭐ 本章核心论文
Featherstone RBDA Ch.3,5,7 ⭐⭐ 空间代数 + RNEA/ABA(无导数)
Singh-Russell-Wensing IROS 2022 ⭐⭐⭐ 二阶解析导数
Griewank-Walther 2008 ⭐⭐⭐ AD 圣经
Khalil-Dombre Ch.13-14 ⭐⭐ 系统辨识 regressor
Lynch-Park Modern Robotics Ch.8 ⭐⭐ 入门动力学
Carpentier PhD 2017 LAAS ⭐⭐⭐ Pinocchio 哲学源头
Suh et al. ICML 2022 ⭐⭐⭐⭐ 可微仿真 != 更好 PG
Chen et al. NeurIPS 2018 (Neural ODE) ⭐⭐⭐ 伴随方程与 AD
Tedrake Underactuated notes ⭐⭐ 交互式 Drake 教学

🔧 故障排查手册

症状 可能原因 排查步骤 相关章节
解析导数与 FD 不一致 FD 步长不对或浮基 q += dq 错误 1. 检查步长在 [1e-7, 1e-5] 2. 浮基用 pin.integrate 3. 打印差异最大的元素 本章 5.2
rnea_partial_da 乘向量结果错误 只填了上三角未对称化 添加 M.triangularView<Lower>() = M.T.triangularView<Lower>() 本章 5.6
OCS2 CodeGen 行为不符预期 缓存的 .so 过期 1. 检查 recompileLibrariesCppAd 设置 2. 手动删 modelFolderCppAd 3. 重启节点 本章 5.7
CppAD Jacobian 部分列为零 tape 录制时漏了 Independent 1. 确认 x_ad 维度 2. 确认 Independent 包含所有输入 3. 检查中间变量是否都是 AD 类型 本章 5.3
DDP 在平衡点附近抖动 FD 破坏零模式(5.2.4 讨论) 1. 切换到解析导数 2. 检查 C(q,0) 是否返回精确零 3. 打印 dtau_dv 在 v=0 时的范数 本章 5.2
M^{-1} 不对称 AZA 只填上三角未复制 对 data.Minv 做对称化处理 本章 5.5

附录 A:四条路线完整对照表

维度 有限差分 运行时 AD CodeGen 解析
代表工具 Eigen NumericalDiff CppAD, Sacado CppADCodeGen, CasADi Pinocchio
原理 (f(x+eps)-f(x))/eps 算子重载录 tape tape→C→编译 手推递推公式
精度 10^{-7}-10^{-11} 机器精度 机器精度 机器精度
保零模式 是(严格)
复杂度 O(n * RNEA) O(n * tape) ~1-3x RNEA O(Nd)
编译开销 0 0 秒~分钟 0
代码侵入 低(换 Scalar) 中(需 pipeline) 高(需手推)
新约束扩展 即时 即时 即时(重编译) 需手推
7-DoF (us) 21 5-10 1.5-3 3.3
36-DoF (us) 450 30-60 8-15 17
适用场景 测试/原型 离线/验证 OCS2/工业 Crocoddyl/TSID

附录 B:可微仿真引擎综述(2020-2026)

引擎 语言 AD 策略 接触 GPU 状态
MuJoCo C C mjd_transitionFD Convex MLCP 活跃
MJX-JAX JAX jacfwd/jacrev CG solver TPU/GPU 活跃
MJX-Warp Warp 暂不支持 AD 更全 GPU 活跃 (3.3.5+)
Drake C++ AutoDiffXd SAP (凸软) 有限 活跃
Nimble C++ LCP IFT 硬 LCP 部分 转生物力学
Dojo.jl Julia IP + IFT NCP + SOC 活跃 v5
Pinocchio C++ 解析+CppAD/CasADi 无完整接触 CodeGen 活跃 v3
NVIDIA Warp Python/CUDA kernel reverse 自定义 GPU 活跃
DiffTaichi Taichi source transform 用户定义 GPU 维护

2024-2026 关键进展: 1. MJX 成为 RL 训练事实标准(Brax generalized deprecated) 2. MuJoCo Playground (RSS 2025) 一站式 sim-to-real 3. Pinocchio 3 + Crocoddyl 3:解析约束动力学导数 4. Suh et al. ICML 2022 证明"可微仿真 != 更好 PG"


附录 C:核心 GitHub 仓库

仓库 用途
stack-of-tasks/pinocchio 解析 RNEA/ABA 导数 + CppAD/CasADi
loco-3d/crocoddyl FDDP/BoxFDDP,Pinocchio 后端
leggedrobotics/ocs2 SLQ/MPC + CppADCodeGen
coin-or/CppAD 经典 operator-overloading AD
joaoleal/CppADCodeGen CppAD → C 源码生成
casadi/casadi 符号 AD + NLP 前端
google-deepmind/mujoco MuJoCo 3 + MJX
RobotLocomotion/drake AutoDiffXd + SAP
keenon/nimblephysics LCP 解析梯度
dojo-sim/Dojo.jl NCP + IP 可微接触
NVIDIA/warp GPU 可微核
cdsousa/SymPyBotics 符号动力学教学
NVlabs/DiffRL SHAC 官方实现

附录 D:视频与中文资源

英文视频: - Justin Carpentier "Pinocchio tutorial"(ICRA 2021 / IROS 2022 workshop) - Roy Featherstone RBDA 系列(YouTube) - MIT 6.832 Underactuated Robotics(Russ Tedrake) - CMU 16-745 Optimal Control and RL(Zac Manchester) - ETH Robot Dynamics(Marco Hutter)

中文: - 深蓝学院《机器人动力学》《最优控制与 MPC》 - B 站搜索:Pinocchio、RNEA、ABA、iLQR、DDP、自动微分 - 知乎:搜索"Carpentier"、"动力学导数"、"Crocoddyl 源码" - CSDN:Pinocchio 编译笔记、Crocoddyl 示例翻译


附录 E:学习时间预算

档位 3(实用 MPC 工程师,30-40h): - 阅读:Carpentier-Mansard RSS 2018 全文 (4h) - 理论:手推 2R 臂的 ∂tau/∂q (8h) - 代码:Pinocchio Python demo + computeRNEADerivatives/computeABADerivatives (10h) - 工程:Crocoddyl pendulum DDP + UR5 arm (12h)

档位 4(深入型,60-80h): - 档位 3 全部 + - 手推 Alg. 2-3 全部,对照 rnea-derivatives.hxx (10h) - CppAD 独立实现 + CppADCodeGen 生成 C (15h) - Singh-Wensing 二阶导数论文精读 (8h) - 自行实现 iLQR on Talos whole-body (10h)


附录 F:Placement 微分恒等式的严格证明 ⭐⭐⭐

本附录给出 Carpentier-Mansard Eq. (13) 和 Eq. (14) 的完整严格证明,补充正文 5.4.2 中的 sketch。

F.1 Eq. (13) 的证明

定理:对 revolute joint i,设 placement \({}^iX_{\lambda(i)}(q_i)\) 是关节角度 \(q_i\) 的函数。则对任意 motion \(m \in \mathbb{R}^6\)

\[\frac{\partial}{\partial q_i}\bigl({}^iX_{\lambda(i)}\,m\bigr) = \bigl({}^iX_{\lambda(i)}\,m\bigr) \times S_i\]

证明

Step 1:分解 placement。关节 i 的 placement 可分解为: $\({}^iX_{\lambda(i)} = X_{\text{tree}} \cdot X_J(q_i)\)$

其中 \(X_{\text{tree}}\) 是固定的 tree transform(不依赖 \(q_i\)),\(X_J(q_i)\) 是关节变换。

Step 2:关节变换的微分。对 revolute joint: $\(X_J(q_i) = \exp(\hat{s}\,q_i) \quad \Rightarrow \quad \frac{dX_J}{dq_i} = \hat{s}\,X_J = X_J\,\hat{s}\)$

其中最后一个等式利用了 \(\hat{s}\)\(\exp(\hat{s}\,q_i)\) 的交换性(因为它们在同一个一参子群上)。

但注意:在空间代数中,"乘以 hat{s}"等价于"做一次 motion cross"。具体地:

\[\frac{d}{dq_i}(X_J\,m) = \hat{s}\,X_J\,m = (X_J\,m) \times s\]

(最后一步利用了 spatial cross 的定义:v_hat m = m x v 对于 motion vectors。)

Step 3:合并。 $\(\frac{\partial}{\partial q_i}\bigl(X_{\text{tree}}\,X_J\,m\bigr) = X_{\text{tree}}\,(X_J\,m) \times s = (X_{\text{tree}}\,X_J\,m) \times (X_{\text{tree}}\,s)\)$

\(X_{\text{tree}}\) 作用在 \(s\) 上后,得到的正是 child frame 中的运动子空间 \(S_i\)。并且 \(X_{\text{tree}} X_J = {}^iX_{\lambda(i)}\)。因此:

\[\frac{\partial}{\partial q_i}\bigl({}^iX_{\lambda(i)}\,m\bigr) = \bigl({}^iX_{\lambda(i)}\,m\bigr) \times S_i \quad \blacksquare\]

F.2 Eq. (14) 的证明(对偶版本)

定理:对 force \(f \in \mathbb{R}^6\)

\[\frac{\partial}{\partial q_i}\bigl({}^{\lambda(i)}X^*_i\,f\bigr) = {}^{\lambda(i)}X^*_i\,(S_i \times^* f)\]

证明:利用 dual transform \(X^* = X^{-T}\) 和 force cross 的定义,以及 Eq. (13) 的对偶版本。

具体地:对任何 motion \(m\),power 守恒给出: $\(\langle {}^{\lambda(i)}X^*_i\,f,\ m \rangle = \langle f,\ {}^iX_{\lambda(i)}\,m \rangle\)$

两侧对 \(q_i\) 微分,左侧得到 \(\langle \partial_{q_i}(X^*_i f), m \rangle + \langle X^*_i f, 0 \rangle\)(m 不依赖 q_i)。右侧得到 \(\langle f, (X_{\lambda(i)} m) \times S_i \rangle = \langle S_i \times^* f, X_{\lambda(i)} m \rangle = \langle X^*_i (S_i \times^* f), m \rangle\)

由 m 的任意性:\(\partial_{q_i}(X^*_i f) = X^*_i (S_i \times^* f)\)\(\blacksquare\)

F.3 推广到其他关节类型

关节类型 \(S_i\) 维度 \(\partial S_i/\partial q_i\) 简化程度
Revolute 6x1 0 最大(标量 cross)
Prismatic 6x1 0 同上
Spherical 6x3 0(for unit quaternion) 中等(3x cross)
Free-flyer 6x6 (= I) 0 S = I,部分项消失
Universal 6x2 非零(取决于参数化) 较少简化

对 revolute 和 prismatic,\(\partial S_i/\partial q_i = 0\) 是关键简化——Alg. 2-3 中大量项因此消失。对 spherical 和 universal joint,需要额外处理 \(\partial S/\partial q\) 项。


附录 G:2R 平面臂的完整解析微分验证 ⭐⭐

本附录对最简单的非平凡模型(2R 平面臂)完整手推 dtau/dq,并与 Carpentier-Mansard 算法的结果逐项对比。

G.1 模型参数

  • 两个 revolute joint,关节角 q = (q_1, q_2)
  • 连杆长度 l_1, l_2;质量 m_1, m_2
  • 质心到关节距离 l_{c1}, l_{c2}
  • 绕质心转动惯量 I_1, I_2

G.2 Lagrangian 方法直接推导

质量矩阵 M(q):

\[M_{11} = I_1 + I_2 + m_1 l_{c1}^2 + m_2(l_1^2 + l_{c2}^2 + 2l_1 l_{c2}\cos q_2)$$ $$M_{12} = M_{21} = I_2 + m_2 l_{c2}^2 + m_2 l_1 l_{c2}\cos q_2$$ $$M_{22} = I_2 + m_2 l_{c2}^2\]

动力学方程 tau = M(q) q_ddot + C(q, q_dot) q_dot + g(q):

\[\frac{\partial\tau_1}{\partial q_2} = \frac{\partial M_{11}}{\partial q_2}\ddot q_1 + \frac{\partial M_{12}}{\partial q_2}\ddot q_2 + \frac{\partial C_1}{\partial q_2}\dot q + \frac{\partial g_1}{\partial q_2}\]

计算各项: $\(\frac{\partial M_{11}}{\partial q_2} = -2m_2 l_1 l_{c2}\sin q_2\)$ $\(\frac{\partial M_{12}}{\partial q_2} = -m_2 l_1 l_{c2}\sin q_2\)$

G.3 Carpentier-Mansard 算法推导

用 Alg. 2-3 的 spatial vector 方法推导同样的 dtau_1/dq_2:

前向 pass: - \(\partial v_2/\partial q_2\):包含 placement derivative term \((X_{2 \leftarrow 1} v_1) \times S_2\) - \(\partial a_2/\partial q_2\):包含 \((X_{2 \leftarrow 1} a_1) \times S_2 + ...\)

后向 pass: - \(\partial f_2/\partial q_2 = I_2 \partial a_2/\partial q_2 + ...\) - \(\partial \tau_1/\partial q_2 = S_1^T (\partial f_1/\partial q_2) = S_1^T (X^*_{1 \leftarrow 2} \partial f_2/\partial q_2 + X^*_{1 \leftarrow 2} (S_2 \times^* f_2))\)

展开所有 spatial vector 运算后,与 G.2 的结果**完全一致**——验证了算法的正确性。

G.4 数值验证

import pinocchio as pin
import numpy as np

# 构造 2R 臂(用 Pinocchio 的 model builder)
model = pin.Model()
# 添加 joint 1 (revolute around z)
joint_id1 = model.addJoint(0, pin.JointModelRZ(), pin.SE3.Identity(), "joint1")
model.appendBodyToJoint(joint_id1,
    pin.Inertia(m1, np.array([lc1, 0, 0]), np.diag([0, 0, I1])),
    pin.SE3.Identity())
# 添加 joint 2
placement2 = pin.SE3(np.eye(3), np.array([l1, 0, 0]))
joint_id2 = model.addJoint(joint_id1, pin.JointModelRZ(), placement2, "joint2")
model.appendBodyToJoint(joint_id2,
    pin.Inertia(m2, np.array([lc2, 0, 0]), np.diag([0, 0, I2])),
    pin.SE3.Identity())

data = model.createData()
q = np.array([0.5, 0.3])
v = np.array([1.0, -0.5])
a = np.array([0.1, 0.2])

# 解析导数
dtau_dq, _, _ = pin.computeRNEADerivatives(model, data, q, v, a)
print(f"dtau_1/dq_2 (analytic) = {dtau_dq[0, 1]:.6f}")

# 手推公式
dtau1_dq2_manual = -2*m2*l1*lc2*np.sin(q[1]) * a[0] \
                    - m2*l1*lc2*np.sin(q[1]) * a[1] + ...
print(f"dtau_1/dq_2 (manual)   = {dtau1_dq2_manual:.6f}")

附录 H:反向自动微分与 Pontryagin 伴随 ⭐⭐⭐⭐

H.1 伴随方程

对终端代价优化问题 \(\min \Phi(x_T)\),s.t. \(\dot x = f(x, u(\theta))\)

\[\dot\mu = -\left(\frac{\partial f}{\partial x}\right)^T \mu, \quad \mu(T) = \nabla\Phi(x_T)\]
\[\frac{d\Phi}{d\theta} = -\int_0^T \mu^T \frac{\partial f}{\partial\theta}\,dt\]

关键洞察:伴随方程就是 Pontryagin 最大值原理中的协态方程,也是 reverse-mode AD 在连续时间下的精确对应。

H.2 与 Neural ODE 的联系

Chen et al. (NeurIPS 2018) 的 Neural ODE 把 (x, mu, dL/d theta) 组成增广 ODE 反向积分,实现 O(1) 内存的梯度计算——代价是 2x 的计算时间(前向 + 反向各一次积分)。

H.3 Checkpointing Trade-off

策略 内存 计算
Full tape O(T) O(T)
Adjoint ODE O(1) 2T
Checkpointing O(sqrt(T)) O(T log T)

Griewank-Walther 2008 证明了 checkpointing 的最优性。

H.4 在可微仿真中的应用

对一个 N 步离散仿真: $\(x_{k+1} = f(x_k, u_k), \quad k = 0, ..., N-1\)$ $\(L = \sum_k l(x_k, u_k) + \Phi(x_N)\)$

反向传播(reverse-mode AD): $\(\lambda_N = \nabla\Phi(x_N)\)$ $\(\lambda_k = \nabla_x l(x_k, u_k) + \left(\frac{\partial f}{\partial x_k}\right)^T \lambda_{k+1}\)$ $\(\frac{\partial L}{\partial u_k} = \nabla_u l(x_k, u_k) + \left(\frac{\partial f}{\partial u_k}\right)^T \lambda_{k+1}\)$

这里的 \(\partial f/\partial x_k\)\(\partial f/\partial u_k\) 正是本章的 computeABADerivatives 提供的 A_t 和 B_t!

本质洞察:DDP/iLQR 的 Riccati 反传和 reverse-mode AD 的反向传播在数学上是**同一个东西**——它们都是 Pontryagin 协态方程的离散形式。区别在于:DDP 同时求解了最优控制律(通过 Q_{uu}^{-1} Q_{ux}),而纯 AD 只提供梯度。


附录 I:隐函数定理在约束动力学导数中的应用 ⭐⭐⭐

I.1 约束动力学的 KKT 系统

浮基机器人加接触约束:

\[\begin{bmatrix} M & -J_c^T \\ J_c & 0 \end{bmatrix} \begin{bmatrix} \ddot q \\ \lambda \end{bmatrix} = \begin{bmatrix} \tau - h \\ -\dot J_c \dot q \end{bmatrix}\]

其中 h = C q_dot + g,J_c 是接触 Jacobian,lambda 是接触力。

I.2 对参数 theta 应用 IFT

定义 \(F(\ddot q, \lambda; \theta) = 0\)(KKT 条件)。由隐函数定理:

\[\frac{\partial(\ddot q, \lambda)}{\partial\theta} = -\left[\frac{\partial F}{\partial(\ddot q, \lambda)}\right]^{-1} \frac{\partial F}{\partial\theta}\]

其中 \(\partial F/\partial(\ddot q, \lambda)\) 就是 KKT 矩阵——它在前向仿真中已经分解过了!

因此**约束动力学的导数几乎"免费"**:只需要一次已分解的 KKT 矩阵的回代(back-substitution)。

I.3 Pinocchio 3 的实现

// Pinocchio 3 约束动力学导数
pinocchio::computeConstraintDynamicsDerivatives(
    model, data, contact_models, contact_datas);
// 输出:data.ddq_partial_dq, data.ddq_partial_dv, data.dlambda_partial_dq, ...

I.4 与可微仿真的连接

Amos-Kolter 的 OptNet (ICML 2017) 和 de Avila Belbute-Peres (NeurIPS 2018) 把 QP/LCP 作为可微层,其核心数学工具正是 IFT:

\[\frac{\partial z^*}{\partial\theta} = -\left[\frac{\partial \mathrm{KKT}}{\partial z}\right]^{-1} \frac{\partial \mathrm{KKT}}{\partial\theta}\]

Nimble 和 Dojo 的硬接触可微仿真也基于同一原理。


附录 J:跨章综合练习

练习 J.1(综合 4-3 + 4-5 + 控制方向 90_DDP)

题目:对 KUKA iiwa 实现一个最简 iLQR: 1. 用 pin.computeABADerivatives 提供 A_t, B_t 2. 用标准 Riccati 反传求最优增益 3. 跟踪一个关节空间轨迹(从初始位形到目标位形) 4. 对比:用有限差分 vs 解析导数,控制效果和计算时间

目标:完整体验"动力学导数 -> DDP -> 控制"的流水线。

练习 J.2(综合 4-4 + 4-5 + 控制方向 120_MPC)

题目:对 Solo12 四足: 1. 用 centroidal dynamics 作为简化模型 2. 用 ccrba/dccrba + 本章的导数 API 线性化 3. 实现一个 centroidal MPC(只优化接触力,不优化关节力矩) 4. 在站立平衡下测试推力恢复

目标:理解 centroidal MPC 如何利用本章的导数技术。

练习 J.3(综合 4-5 + CodeGen + 嵌入式)

题目: 1. 用 CasADi + Pinocchio.casadi 构建 UR5 的正向动力学符号表达式 2. 生成 C 代码(dynamics.generate('robot.c')) 3. 编译为独立可执行文件(无 Python/Pinocchio 依赖) 4. 对比生成代码与 Pinocchio 解析的性能

目标:体验代码生成 -> 嵌入式部署的完整流水线。


附录 K:与后续专题的桥梁

→ 60_约束动力学(接触导数):Pinocchio 3 的 constrainedDynamicsDerivatives 把本章的无约束导数扩展到带接触约束的情况。核心数学工具是隐函数定理(附录 I),加上 KKT 矩阵的 Schur 补分解。

→ 05_运动控制/90_DDP:iLQR/DDP 的 Riccati 反传直接消费本章的 A_t = d f/d x 和 B_t = d f/d u。Crocoddyl 的 DifferentialActionModelFreeFwdDynamics::calcDiff() 就是本章 computeABADerivatives 的包装。FDDP(Mastalli 2020)和 BoxFDDP 在 constraint handling 上扩展了标准 DDP,但底层导数计算不变。

→ 05_运动控制/120_MPC:acados 的 RTI (Real-Time Iteration) scheme 每步只做一次 QP 迭代——这对导数精度的要求更高(因为没有多次迭代来纠正导数误差)。本章的解析导数(机器精度)直接保证了 RTI 的收敛性。OCS2 的双线程架构(MPC 线程 + policy 线程)中,本章的 computeABADerivatives 是 MPC 线程的热点函数。

→ 系统辨识:本章 5.12 节的 regressor 矩阵 Y 直接服务于在线/离线动力学参数辨识。结合激励轨迹设计(最大化 Fisher 信息矩阵 det(Y^T Y)),可以在真实机器人上精确标定所有惯性参数。

→ 可微仿真与 RL:MJX-JAX 的 RL 训练中,SHAC (Short Horizon Actor-Critic) 需要 truncated-horizon policy gradient,其中的 "d env/d action" 正是本章讨论的正向动力学导数 d q_ddot/d tau = M^{-1}。


附录 L:性能优化进阶技巧 ⭐⭐⭐

L.1 SIMD 向量化

Pinocchio 通过 Eigen 的 SIMD 支持(SSE/AVX)自动向量化矩阵运算。关键优化点:

  • 6x6 spatial inertia 乘法:一次 AVX-256 指令处理 4 个 double
  • 6xN motion-set 的逐列 cross:可用 SIMD scatter/gather
  • Cholesky 分解的 BLAS-3 内核

K.2 缓存友好的数据布局

computeRNEADerivatives 的内存访问模式:

  • 前向 pass:顺序访问 data.dVdq[1..N](缓存友好)
  • 后向 pass:逆序访问 data.dFdq[N..1](缓存友好,因为是连续内存)
  • 关键:dtau_dq 矩阵是**按列**(Fortran order)还是**按行**(C order)存储?Eigen 默认列优先,Pinocchio 顺应之。

K.3 多线程并行

对**不同**的时间节点的导数计算可以并行化(它们之间无依赖):

#pragma omp parallel for
for(int t = 0; t < N; ++t) {
    pinocchio::computeABADerivatives(model, data_pool[t], q[t], v[t], tau[t]);
}

但对**单个**时间节点的导数计算,Alg. 2-3 的前向/后向 pass 是串行的(树结构依赖)。

K.4 模型简化

简化 效果 适用场景
固定不动的关节 减小 nv,M 矩阵更小 只用上半身的 humanoid
合并刚性连接 减少 RNEA pass 的连杆数 简化后的 URDF
只算上三角 减半矩阵操作 M, M^{-1}, dtau_da
利用对称性 镜像四足的左右腿共享 ANYmal 等对称模型