PAYATHON 2026

如何消除 Direct Collocation 重建状态轨迹中的振荡

排障赵师傅

结论

应先排查离散化误差、求解容差和模型尺度。这里的振荡很可能是 Direct Collocation 在稀疏网格上产生的伪解。轨迹只需在 knot points 和 collocation points 满足离散后的动力学约束,而 ReconstructStateTrajectory() 返回的三次多项式无法保证区间内每个时刻都严格满足连续动力学。

建议按以下顺序处理:

  1. 将时间步长基准改为 T / (N - 1)。
  2. 明确调用 SnoptSolver().Solve(prog),并收紧 feasibility tolerance。
  3. 密集采样重建轨迹,检查动力学残差。
  4. 用粗网格解 warm start 更细的网格,避免增加 sample points 后直接冷启动。
  5. 添加速度、输入变化率或加速度正则项,并限制输入。
  6. 检查末端 link 的惯量、joint damping 和执行器等效惯量是否合理。

仅增加 knot 数量,或只在 knot points 添加 velocity limits,通常无法消除区间内部的振荡。

为什么 knot points 正常,区间内部仍会振荡

Direct Collocation 优化的是有限个 knot points 上的状态和输入。对于相邻节点 (i) 和 (i+1),Drake 根据状态及其动力学导数构造分段多项式,并在 collocation point 施加动力学缺陷约束。

因此,离散问题满足的是有限组方程,而非:

[ \dot{x}(t)=f(x(t),u(t)),\quad \forall t ]

对于机械系统:

[ x = \begin{bmatrix}q\v\end{bmatrix} ]

重建的 (q(t)) 和 (v(t)) 都来自分段多项式。在约束点之外,数值上可能出现:

[ \frac{d q(t)}{dt} \neq N(q(t))v(t) ]

或者:

[ \dot{v}(t) \neq f_v(q(t),v(t),u(t)) ]

所以,knot points 上的状态平滑,不代表重建轨迹在区间内部也合理。

如果增加 sample points 后振荡频率反而升高,通常是优化器找到了另一组网格尺度上的伪解,或者问题的数值尺度较差,不能据此断定 collocation points 的实现有误。

先修正时间步长设置

num_time_samples=N 对应 N - 1 个时间区间。固定总时长为 (T),且所有区间等长时,每个区间应为:

[ h=\frac{T}{N-1} ]

当前代码使用了 time_constraint / num_samples。当 N=11、T=1 时,实际时间步长为 0.1,代码设置的最大值也正好是 0.1。这样一来,每个时间步都会落在构造函数允许范围的上边界,平白增加一个活动约束,影响数值求解。

可以改成:

num_samples = 11
duration = 1.0
nominal_h = duration / (num_samples - 1)

direct_collocation = DirectCollocation(
    plant,
    plant_context,
    num_time_samples=num_samples,
    input_port_index=plant.get_actuation_input_port().get_index(),
    minimum_time_step=0.5 * nominal_h,
    maximum_time_step=1.5 * nominal_h,
)

direct_collocation.AddEqualTimeIntervalsConstraints()
direct_collocation.AddDurationBounds(duration, duration)

总时长固定、区间等长时,最终仍会得到 h = nominal_h。放宽构造时的取值范围,可以避免时间步长同时卡在边界上。

确认真正使用了 SNOPT,并收紧容差

代码虽然创建了 SnoptSolver,最后调用的却是通用入口:

result = Solve(prog)

Solve() 会自行选择求解器。即便当前环境最终选中了 SNOPT,也建议明确调用,以免安装环境变化后使用了其他求解器:

solver = SnoptSolver()
solver_id = solver.solver_id()

prog.SetSolverOption(
    solver_id, "Major feasibility tolerance", 1e-7)
prog.SetSolverOption(
    solver_id, "Major optimality tolerance", 1e-6)
prog.SetSolverOption(
    solver_id, "Minor feasibility tolerance", 1e-7)
prog.SetSolverOption(
    solver_id, "Minor optimality tolerance", 1e-6)

result = solver.Solve(prog)

对于轨迹优化中的动力学等式约束,1e-3 可能过松,尤其是在状态量、力矩和惯量相差多个尺度时。上面的数值可作为排查起点,不是所有模型通用的固定配置。如果收紧容差后求解变得困难,应先检查变量尺度和模型惯量,不要继续盲目缩小容差。

求解完成后,还要查看 infeasible constraints 和动力学约束的实际残差,不能只检查 result.is_success()。

密集检查连续动力学残差

要区分振荡来自轨迹重建误差还是动力学本身,可以在每个区间内取多个点并计算:

[ r(t)=\dot{x}{\text{recon}}(t) -f(x{\text{recon}}(t),u_{\text{recon}}(t)) ]

示例:

x_traj = direct_collocation.ReconstructStateTrajectory(result)
u_traj = direct_collocation.ReconstructInputTrajectory(result)
xdot_traj = x_traj.derivative(1)

check_context = plant.CreateDefaultContext()

sample_times = np.linspace(
    x_traj.start_time(),
    x_traj.end_time(),
    1001,
)

residual_norms = []

for t in sample_times:
    x_value = x_traj.value(t).reshape(-1)
    u_value = u_traj.value(t).reshape(-1)
    xdot_reconstructed = xdot_traj.value(t).reshape(-1)

    plant.SetPositionsAndVelocities(check_context, x_value)
    plant.get_actuation_input_port().FixValue(check_context, u_value)

    xdot_dynamics = (
        plant.EvalTimeDerivatives(check_context).CopyToVector()
    )

    residual_norms.append(
        np.linalg.norm(xdot_reconstructed - xdot_dynamics)
    )

print("maximum dense dynamics residual:", max(residual_norms))

还可以分别检查 position kinematics 和 velocity dynamics:

nq = plant.num_positions()
nv = plant.num_velocities()

position_residuals = []
velocity_residuals = []

for t in sample_times:
    x_value = x_traj.value(t).reshape(-1)
    u_value = u_traj.value(t).reshape(-1)
    xdot_reconstructed = xdot_traj.value(t).reshape(-1)

    plant.SetPositionsAndVelocities(check_context, x_value)
    plant.get_actuation_input_port().FixValue(check_context, u_value)

    xdot_dynamics = (
        plant.EvalTimeDerivatives(check_context).CopyToVector()
    )

    residual = xdot_reconstructed - xdot_dynamics
    position_residuals.append(np.linalg.norm(residual[:nq]))
    velocity_residuals.append(np.linalg.norm(residual[nq:nq + nv]))

print("maximum position residual:", max(position_residuals))
print("maximum velocity residual:", max(velocity_residuals))

如果残差主要在 knot points 之间增大,说明网格上的离散轨迹无法准确表示连续动力学。此时需要做 mesh refinement、调整正则项或改善模型尺度。

也可以将重建的输入轨迹送入连续 plant,从初始状态重新仿真。如果仿真轨迹与 ReconstructStateTrajectory() 明显分离,说明当前 collocation 解的连续动力学一致性不足。

用上一层网格的解初始化更细网格

直接把 num_samples 从 11 改成较大的数值并冷启动,优化器可能落入新的局部解,振荡也可能随着网格加密而变快。

可以逐级细化网格:

coarse_x = coarse_dircol.ReconstructStateTrajectory(coarse_result)
coarse_u = coarse_dircol.ReconstructInputTrajectory(coarse_result)

fine_dircol.SetInitialTrajectory(coarse_u, coarse_x)
fine_result = fine_solver.Solve(fine_dircol.prog())

例如依次使用 11、21、41 个 knot points,每一层都用上一层的重建轨迹初始化,并在求解后重新计算密集动力学残差。如果网格细化后残差和振幅同时下降,说明这一处理方向有效。

如果加密网格后,振荡始终集中在一两个区间的尺度上,还需要检查求解容差、缩放和代价函数。

给优化器加入平滑偏好

当前目标函数只有输入能量:

direct_collocation.AddRunningCost(u.T @ R @ u)

在固定时间内,这项代价未必能抑制状态或输入的快速变化。可以加入适度的 velocity cost:

nq = plant.num_positions()
nv = plant.num_velocities()

x = direct_collocation.state()
v = x[nq:nq + nv]

Qv = 1e-3 * np.eye(nv)
direct_collocation.AddRunningCost(v.T @ Qv @ v)

也可以惩罚相邻 knot points 的输入变化。在固定等时间间隔下,下面的形式近似惩罚 (\int |\dot{u}|^2dt):

weight_du = 1e-3
h = duration / (num_samples - 1)

for i in range(num_samples - 1):
    du = (
        direct_collocation.input(i + 1)
        - direct_collocation.input(i)
    )
    prog.AddQuadraticCost((weight_du / h) * du.dot(du))

如果所用 Drake 版本没有公开 input(i) 重载,可以通过该版本提供的 input decision-variable accessor 添加同样的代价。

velocity cost 和 input-rate cost 的权重应逐步增加,同时观察任务轨迹、控制量和动力学残差。权重过大可能改变原问题的最优解,甚至使固定时间的边界条件难以满足。

添加真实的输入和状态限制

最小示例没有设置 actuator effort limits。对于惯量很小的关节,优化器可能利用很大的瞬时输入和加速度,得到数值上可行但物理上没有意义的轨迹。

输入限制可以直接加到所有 knot points:

u = direct_collocation.input()

u_lower = np.array([-20.0, -20.0, -20.0])
u_upper = np.array([ 20.0,  20.0,  20.0])

direct_collocation.AddConstraintToAllKnotPoints(u >= u_lower)
direct_collocation.AddConstraintToAllKnotPoints(u <= u_upper)

这里的 20.0 仅用于展示代码结构,实际数值必须取自执行器和传动系统。

joint position limits 和 velocity limits 也应采用机器人的真实范围。施加在 knot points 上的 bound 无法保证三次重建曲线在区间内部不越界,因此仍需密集采样检查。如果存在越界,应继续细化网格,或者为边界预留一定裕量。

任务允许时,还可以限制平均加速度:

a_max = np.array([5.0, 5.0, 5.0])
h = duration / (num_samples - 1)

for i in range(num_samples - 1):
    v0 = direct_collocation.state(i)[nq:nq + nv]
    v1 = direct_collocation.state(i + 1)[nq:nq + nv]
    dv = v1 - v0

    prog.AddBoundingBoxConstraint(
        -a_max * h,
         a_max * h,
         dv,
    )

该约束控制相邻节点间的平均加速度,无法单独约束区间内部的瞬时加速度。它可以抑制节点间的交替跳变,但不能替代密集残差检查。

检查 damping 与惯量的相对尺度

为 revolute joint 设置:

<dynamics damping="0.1"/>

通常表示粘性阻尼系数,其力矩与关节速度成正比。这个数值是否过大,要结合关节的等效惯量、齿轮传动和单位设置判断。对于惯量很小的末端 link,近似时间尺度为:

[ \tau \sim \frac{J}{d} ]

其中 (J) 是关节看到的等效惯量,(d) 是 damping。较小的 (\tau) 会形成快速动力学模式,使固定阶数的 collocation 难以在粗网格上准确表示。

增加末端 link 的 mass 或 length 后振荡有所改善,说明需要检查:

  • URDF 中 mass、length、inertia tensor 的单位是否一致。
  • inertia tensor 是否符合 link 的实际几何和质量。
  • 末端 link 是否被建模成近乎无惯量的刚体。
  • damping="0.1" 对该关节是否过大。
  • 是否遗漏了真实执行器的电机转子惯量和减速器反射惯量。
  • 是否缺少 actuator effort limits。

修改 link mass 不应成为消除优化问题的手段。如果真实机器人包含电机和减速器,应将它们对关节等效惯量的贡献纳入动力学模型。具体建模方式取决于当前 Drake 版本和机器人模型格式。

修正状态维度的写法

示例假设:

NUM_JOINTS = plant.get_actuation_input_port().size()
start_state = np.zeros(NUM_JOINTS * 2)

这种写法只适用于 num_positions == num_velocities == num_actuators 的完全驱动简单关节系统。更稳妥的写法是:

nq = plant.num_positions()
nv = plant.num_velocities()

start_state = np.zeros(nq + nv)
end_state = np.concatenate([
    np.ones(nq),
    np.zeros(nv),
])

如果模型中有 quaternion、floating base 或未驱动关节,就不能根据 actuation input 的维度推断 state 维度。

推荐的排查顺序

先用 T / (N - 1) 修正时间步长范围,并确认实际调用的是 SNOPT。然后将 feasibility tolerance 从 1e-3 收紧到 1e-6 或 1e-7 量级,再计算重建轨迹的密集动力学残差。

如果残差集中在区间内部,就用上一层解初始化下一层,逐级细化网格,同时加入真实的 effort limits 和较小的 velocity 或 input-rate cost。效果仍不明显时,再检查末端惯量、joint damping、传动系统等效惯量和变量缩放。

验收时不能只看 Meshcat 动画或 knot samples。还应检查重建轨迹、密集动力学残差,以及相同输入下的连续系统仿真结果。

备注:内容仅供参考。