No.7 双摆 LQR 最优控制(含系统线性化)

本节介绍在 No.4–No.6 已有控制器的基础上,引入最优控制在线性化技术。核心思路:

  1. 线性化:用有限差分把非线性双摆模型在平衡点附近线性化ẋ = Ax + Bu
  2. LQR 设计:解连续代数 Riccati 方程 (CARE),得到最优反馈增益 K
  3. 状态反馈:控制律 u = -Kx(这里 K 已含负号,代码里直接 +K@state
  4. 鲁棒性测试:往第一个关节注入高斯噪声,看 LQR 能否镇定

文件说明

mujoco/No_7/
├── doublependulum.xml      # MuJoCo XML 模型文件
└── doublependulum_lqr.py   # 完整脚本:含线性化、LQR 设计、噪声注入

No.7 没有最小脚本(no_7.py)。要看效果必须跑 doublependulum_lqr.py


一、doublependulum.xml 详解(对比 No.6)

No.7 doublependulum.xml 完整代码

<mujoco>
    <!--
        timestep=0.001(比 No.4/5/6 的 0.0001 粗 10 倍)
        integrator=RK4
    -->
    <option timestep="0.001" integrator="RK4">
        <flag sensornoise="enable" contact="disable" energy="enable"/>
    </option>

    <worldbody>
        <light diffuse=".5 .5 .5" pos="0 0 3" dir="0 0 -1"/>
        <geom type="plane" size="1 1 0.1" rgba=".9 0 0 1"/>

        <!--
            第一连杆 body:高度 2.5(No.4 同款),euler="0 180 0"(朝下)
            【No.7 新增】<inertial> 显式指定惯量
        -->
        <body pos="0 0 2.5" euler="0 180 0">
            <joint name="pin" type="hinge" pos="0 0 0.5" axis="0 -1 0" />
            <geom type="cylinder" size=".05 .5" rgba="0 .9 0 1" />
            <inertial mass="1" pos="0 0 0" diaginertia="0.1 0.1 0.1"/>

            <body pos="0 0 -1" euler="0 0 0">
                <joint name="pin2" type="hinge" pos="0 0 0.5" axis="0 -1 0" />
                <geom type="cylinder" size=".05 .5" rgba="0 0 .9 1" />
                <inertial mass="1" pos="0 0 0" diaginertia="0.1 0.1 0.1"/>
            </body>
        </body>
    </worldbody>

    <actuator>
        <!-- pin 的 motor 被注释掉!只驱动 pin2 -->
        <motor name="torque2" joint="pin2" gear="1" ctrlrange="-1000 1000" ctrllimited="true"/>
    </actuator>
</mujoco>

XML 配置对比表

配置项No.6 (IK)No.7 (LQR)
关节定义pin, pin2(关节在杆底)pin, pin2关节在杆顶
初始 body 位置(0, 0, 1.25)(0, 0, 2.5)
朝向euler="0 90 0"euler="0 180 0"(朝下)
<inertial>❌(用默认)✅(显式指定 mass=1diaginertia=0.1 0.1 0.1
timestep0.00010.001(粗 10 倍)
actuator4 个(2 motor + 2 servo)1 个(只驱动 pin2)
sensorframepos + framelinvel❌(没有
<flag sensornoise>(但该属性已废弃,见 FAQ)
重力gravity="0 0 0"(关)默认开启

关键变更说明

1. 关节位置 pos="0 0 0.5"(No.6 是 -0.5

No.6 中关节在 body 的底部(局部 z=-0.5),第二根 body 接在第一根底部。
No.7 改成关节在 body 顶部(局部 z=0.5),第二根 body 接在第一根顶部 pos="0 0 -1"

为什么改? 因为 LQR 是基于线性化模型的,关节位置的不同会让平衡点附近的 A、B 矩阵完全不同。No.7 的设计选择是「杆朝下、关节在顶、像倒挂的钟摆」。

2. 显式 <inertial>

<inertial mass="1" pos="0 0 0" diaginertia="0.1 0.1 0.1"/>

diaginertia="0.1 0.1 0.1" 是对角惯量张量。No.4/5/6 用默认值(MuJoCo 根据 cylinder 几何自动算),No.7 显式指定让惯量与几何无关——便于 LQR 线性化时数值稳定。

3. 只驱动 pin2

<!-- <motor name="torque1" joint="pin" ... /> -->  <!-- 注释掉 -->
<motor name="torque2" joint="pin2" ... />

只控第二个关节。第一个关节是被动的(受重力、噪声扰动,但无控制输入)。

设计哲学:让 LQR 「只用一个执行器稳定整个 2 自由度系统」——这是控制理论的经典挑战。系统是欠驱动(underactuated)的。

4. timestep 变粗(0.0001 → 0.001)

为什么?LQR 在仿真里不需要那么小的步长——它设计时就考虑了「在平衡点附近有界」。粗 10 倍 = 仿真快 10 倍。


二、核心:状态空间与线性化(最重要的新概念)

2.1 状态向量

No.7 第一次明确把系统表示成状态空间形式:

state = [q1, dq1, q2, dq2]ᵀ       # 4 维列向量
                              # 两个关节角 + 两个关节角速度

控制输入:

u = data.ctrl[0]                # 1 维标量(pin2 的力矩)

2.2 状态导数函数 get_dx

def get_dx(inputs):
    """
    inputs = [q1, dq1, q2, dq2, u]   (5 维)
    outputs = [dq1, ddq1, dq2, ddq2] (4 维)
    """
    data.qpos[0] = inputs[0]
    data.qvel[0] = inputs[1]
    data.qpos[1] = inputs[2]
    data.qvel[1] = inputs[3]
    data.ctrl[0] = inputs[4]

    mj.mj_forward(model, data)

    dq1 = data.qvel[0]
    dq2 = data.qvel[1]

    M = np.zeros((2, 2))
    mj.mj_fullM(model, M, data.qM)

    f = np.array([
        [0 - data.qfrc_bias[0]],
        [data.ctrl[0] - data.qfrc_bias[1]]
    ])

    ddq = inv(M) @ f

    return np.array([dq1, ddq[0, 0], dq2, ddq[1, 0]])

这就是 ẋ = f(x, u) 的「真值」——给定当前 xu,算出状态导数

输入分量物理含义MuJoCo 字段
inputs[0]关节 1 角data.qpos[0]
inputs[1]关节 1 角速度data.qvel[0]
inputs[2]关节 2 角data.qpos[1]
inputs[3]关节 2 角速度data.qvel[1]
inputs[4]pin2 力矩data.ctrl[0]
输出分量物理含义
dq1关节 1 角速度(= inputs[1],恒等)
ddq1关节 1 角加速度(从 M @ d̈q = f 解出)
dq2关节 2 角速度
ddq2关节 2 角加速度

关键洞察dq/dt = 角速度本身,d(dq)/dt = 角加速度。这俩是不同的物理量,但 get_dx 把它们一起算出来构成完整的 ẋ。

2.3 为什么第一关节的 f[0]0 - qfrc_bias[0]

f = [[0 - qfrc_bias[0]],               # pin 没有力矩输入(motor 被注释)
     [data.ctrl[0] - qfrc_bias[1]]]   # pin2 收到 ctrl[0] - 偏置

MuJoCo 的动力学方程是:

M(q) q̈ = τ_applied + τ_bias_constraint - τ_bias

整理成 M q̈ = f 形式:

f = τ_applied - qfrc_bias
关节τ_appliedf = τ_applied - qfrc_bias
pin0(无 motor)0 - qfrc_bias[0]
pin2data.ctrl[0]data.ctrl[0] - qfrc_bias[1]

然后 q̈ = M⁻¹ f 解出加速度。

注意:这里的 qfrc_bias 含重力,所以 f 自动「扣除」了重力——这跟 No.4 的反馈线性化一个思路。

2.4 数值线性化 linearization

def linearization(pert=0.001):
    f0 = get_dx(np.zeros(5))       # 在 x=0, u=0 处求 ẋ

    Jacobians = []
    for i in range(5):
        inputs_i = np.zeros(5)
        inputs_i[i] = pert         # 扰动第 i 个分量
        jac = (get_dx(inputs_i) - f0) / pert   # 有限差分
        Jacobians.append(jac[:, np.newaxis])

    A = np.concatenate(Jacobians[:4], axis=1)  # 4×4(对 x 导数)
    B = Jacobians[-1]                          # 4×1(对 u 导数)

    return A, B

这是有限差分法求 Jacobian——把 get_dx 当黑箱,数值地求 ∂ẋ/∂x∂ẋ/∂u

A[i, j] = (get_dx(x + pert·eⱼ, u) - get_dx(x, u))[i] / pert
B[i, 0] = (get_dx(x, u + pert) - get_dx(x, u))[i] / pert

为什么在 x=0, u=0 处线性化? 因为双摆自然下垂q=0)是稳定平衡点。ẋ₀ = 0(不动),这就是线性化的基准点

2.5 线性化结果:状态空间方程

ẋ ≈ A·x + B·u            (4 维)
   = [A]·[q1, dq1, q2, dq2]ᵀ + [B]·u
矩阵形状含义
A4×4状态转移:x 变化 → ẋ 变化
B4×1控制响应:u 变化 → ẋ 变化

这是 LQR 设计的前提:把非线性系统「假装」成线性系统,再设计线性控制器。


三、LQR 设计(核心算法)

3.1 LQR 想干什么?

LQR 找最优增益 K,使下面这个二次型代价函数最小

J = ∫₀^∞ ( xᵀ Q x + uᵀ R u ) dt
       \_______________/  \_______/
            状态误差代价     控制代价
  • Q 大 → 重视状态误差(快速收敛,但控制信号大)
  • R 大 → 重视控制代价(节能,但响应慢)
  • Q、R 选得不同 → 不同 K,没有"正确"答案

3.2 代码实现

Q = np.diag([10, 10, 10, 10])    # 状态权重(q1, dq1, q2, dq2 各 10)
R = np.diag([0.1])                # 控制权重(u 一个分量 0.1)

P = solve_continuous_are(A, B, Q, R)  # ① 解 Riccati 方程
K = -inv(B.T @ P @ B + R) @ B.T @ P @ A   # ② 计算最优增益

步骤 ①:解连续代数 Riccati 方程 (CARE)

Aᵀ P + P A - P B R⁻¹ Bᵀ P + Q = 0

这是关于矩阵 P 的非线性矩阵方程scipy.linalg.solve_continuous_are 用迭代法(schur decomposition)解。

步骤 ②:LQR 增益公式

K = -R⁻¹ Bᵀ P          (连续 LQR 公式)
  = -(Bᵀ P B + R)⁻¹ Bᵀ P A     (代码里的等价形式,避免求 R⁻¹)

注意负号K 已经是「带负号」的形式了,所以控制律是 u = K @ x(不是 u = -K @ x)。

3.3 控制律

def controller(model, data):
    state = np.array([
        [data.qpos[0]],
        [data.qvel[0]],
        [data.qpos[1]],
        [data.qvel[1]],
    ])
    data.ctrl[0] = (K @ state)[0, 0]   # u = K @ x

    # 噪声注入(鲁棒性测试)
    noise = mj.mju_standardNormal(0.0)
    data.qfrc_applied[0] = noise

线性状态反馈:把 x 直接乘以 K 矩阵就得到控制量。

3.4 噪声注入:鲁棒性测试

noise = mj.mju_standardNormal(0.0)   # 标准正态 N(0, 1)
data.qfrc_applied[0] = noise          # 注入到 pin 关节

mj.mju_standardNormal 是 MuJoCo 自带的伪随机数生成器(基于 Box-Muller)。注入到第一个关节(pin,是被动的)——模拟外部扰动

为什么这样测? 因为 LQR 在「无扰动、模型精确」下一定稳。有扰动还稳才证明控制器鲁棒。这是控制理论的标配实验。

特别说明:噪声注入 qfrc_applied[0]叠加在系统上的,跟 LQR 控制无关。LQR 不需要知道这个噪声,靠反馈把它「压」回去。


四、与 No.4–No.6 的本质区别

维度No.4 反馈线性化No.5 PD+FSMNo.6 IKNo.7 LQR
控制律τ = M·v + fFSM 切换的 PDΔq = J⁻¹·Δxu = K·x
设计方法手算 + 调参手算 + 调参手算 + IK 公式算法求解(CARE)
需要模型吗✅ M, qfrc_bias✅ J✅ A, B(要线性化
需要全状态吗部分(q, q̇)部分(x = [q, q̇, q, q̇])
稳定性保证局部(精确模型时)局部(远离奇异点)全局(线性化点附近,理论上)
最优性✅(最小化二次代价)
鲁棒性差(依赖 M)中(大增益)可调(Q/R 调节)
计算复杂度O(n²)O(1)O(n³)O(n³)(一次性离线)

控制思想的演进

No.4: 已知模型 → 用模型「抵消」非线性
No.5: 不知道模型 → 用大增益「硬追」
No.6: 不知道关节该怎么动 → 问 Jacobian(局部线性映射)
No.7: 知道模型 → 把模型「线性化」→ 用最优控制理论设计 K

五、controller / get_dx / linearization 三函数协作图

线性化(仅在启动时跑一次)                LQR 控制(每物理步)
─────────────────────                ─────────────────
linearization()                       controller(model, data)
  │                                    │
  ├─ get_dx(0,0,0,0,0)                 ├─ 读 qpos, qvel → state (4,1)
  │    └─ 设 qpos/qvel/ctrl            │
  │    └─ mj_forward                   ├─ u = K @ state  ← 一次矩阵乘
  │    └─ 算 M, qfrc_bias              │
  │    └─ q̈ = M⁻¹ f                    └─ data.ctrl[0] = u
  │    └─ return ẋ
  │                                    └─ 注入噪声(鲁棒性测试)
  ├─ 扰动每个输入 → 重复 5 次                qfrc_applied[0] = N(0,1)
  ├─ 有限差分 → A (4×4), B (4×1)
  │
  └─ Q, R 选权重
     P = solve CARE
     K = -R⁻¹ Bᵀ P

六、关键设计参数与调参指南

参数当前值含义怎么调
Q = diag(10, 10, 10, 10)全 104 个状态分量同等重视增大 → 收敛更快但 u 更大
R = 0.10.1控制代价增大 → u 更小但跟踪变慢
Q/R100关键比例越大越激进越小越保守
timestep0.001仿真步长线性化假设连续时间,dt 不影响 K,但太大会让数字不稳
pert0.001有限差分步长太小 → 数值误差;太大 → 截断误差;~1e-3 ~ 1e-5 都行

调参实验建议

想看什么改什么
收敛更快Q 整体乘 2
控制更平缓R 乘 10
位置精度更高角分量 Q[0,0]Q[2,2] 调大
速度更小角速度分量 Q[1,1]Q[3,3] 调大
测试鲁棒性边界噪声系数放大 10 倍

七、运行方法

cd mujoco/No_7/
mjpython doublependulum_lqr.py

预期效果:

  • 无噪声情况下:双摆稳定在自然下垂(q=0),不摆动。
  • 有噪声注入:双摆仍然稳定在 q=0 附近,但会有小幅抖动(被 LQR 抑制)。

⚠️ 启动时控制台会打印 solve_continuous_are 的解算过程(如果用 verbose 模式)。这是正常的——Riccati 求解是离线一次性计算,影响仿真速度。


八、跟 No.4–No.6 的学习路径

No.1: 基础建模 + viewer 可视化
  ↓
No.2: GLFW + 鼠标交互
  ↓
No.3: 单摆 PD 闭环
  ↓
No.4: 双摆 + 反馈线性化(需 M)
  ↓
No.5: 双摆 + actuator + FSM + 三次多项式轨迹
  ↓
No.6: 双摆 + 任务空间 IK(需 J)
  ↓
No.7: 双摆 + 线性化 + LQR(需 A, B)  ← 当前
  ↓
(未来)No.8: MPC / 强化学习 / 接触 / 抓取

控制器「知识需求」演进

需要预先知道
No.4动力学 M、qfrc_bias
No.5轨迹生成、FSM 切换
No.6雅可比 J 的几何意义
No.7状态空间、线性化、Riccati 方程

九、常见问题

1. XML 报错 unrecognized attribute: 'sensornoise'

原因sensornoise="enable" 是老版本 MuJoCo 的写法,新版已移除该属性。

解决:删除 <flag sensornoise="enable" contact="disable" energy="enable"/> 中的 sensornoise="enable",或整个 flag 元素简化为 <flag energy="enable"/>

2. solve_continuous_are 报错 / 数值不稳定

原因

  • (A, B) 不可控(controllable)→ Riccati 无解
  • Q、R 选得不合理(如 R=0)

检查

import numpy as np
controllability = np.concatenate([B, A @ B, A @ A @ B, A @ A @ A @ B], axis=1)
print("rank:", np.linalg.matrix_rank(controllability))  # 应为 4(满秩)

3. 双摆剧烈震荡不收敛

可能原因

  • K 算错了(行/列顺序错)
  • 状态向量顺序跟 K 不匹配
  • 噪声太大盖过控制

调试

print("K =", K)
print("state =", state.flatten())
print("u =", (K @ state)[0, 0])

4. 第一关节(pin)完全不动

原因:这是预期行为。pin 没有 motor,只有 qfrc_applied 注入噪声。

意图:让 LQR 「只用 1 个执行器稳定 2 自由度欠驱动系统」——经典控制难题。

5. 噪声注入在哪一行生效?

data.qfrc_applied[0] = noise   # 注入到 pin
data.ctrl[0] = (K @ state)[0]  # LQR 控 pin2

qfrc_applied叠加到系统上的力,不经过 actuator。LQR 不需要知道这个力,靠反馈自然抑制。

6. 为什么 get_dx 里第一行写 0 - qfrc_bias[0]

因为 pin 没有 motor(τ_applied = 0)。代入 M q̈ = τ_applied - qfrc_bias

f[0] = 0 - qfrc_bias[0]

7. 跟 No.4 反馈线性化的本质区别

No.4 反馈线性化No.7 LQR
取消非线性的方式实时算 M(q) 并用它做补偿离线线性化成 A, B,不再算 M
控制律τ = M·v + f(实时)u = K·x一次矩阵乘
计算成本每步 O(n³)每步 O(n²)
全局最优?✅(线性化点附近)

8. LQR 适用范围

LQR 只在线性化点附近有效。远离平衡点性能会急剧下降(因为 A·x + B·u 不再近似 f(x, u))。

解决办法

  • 在多个平衡点设计 LQR,切换(类似 No.5 的 FSM)
  • LTV-LQR(时变 LQR,每步重新线性化)
  • MPC(模型预测控制,No.8 可能涉及)

十、整体公式对应

────────────── 系统(物理)────────────────
M(q) q̈ + C(q, q̇) + g(q) = τ
                                       ← 真实非线性动力学
────────────── 线性化(get_dx + 数值 Jacobian)────
ẋ = A x + B u                          ← 在 x=0, u=0 处的线性近似
       x = [q1, q̇1, q2, q̇2]ᵀ (4×1)
       u = pin2 力矩 (1×1)
       A: 4×4, B: 4×1
────────────── LQR 设计(离线)─────────────
min ∫(xᵀQx + uᵀRu)dt
  s.t. ẋ = Ax + Bu
  → 解 CARE: AᵀP + PA − PBR⁻¹BᵀP + Q = 0
  → 增益 K = -R⁻¹BᵀP
────────────── 实时控制(每步)────────────
u = K @ x
data.ctrl[0] = u[0]
data.qfrc_applied[0] = noise   # 鲁棒性测试
────────────── 物理执行(mj_step)─────────
更新 qpos, qvel, x → 回到第一步

十一、一句话总结

No.7 = 「离线线性化 + LQR 最优控制 + 噪声鲁棒性测试」。跟前几节比,最大跃迁是把控制器设计从「手算调参」升级到「算法求解」——Q、R 选好,自动算出最优 K。理论上的保证更强了(全局最优、闭环稳定),代价是只在线性化点附近有效