本教程从数学基础出发,逐步构建四旋翼无人机的完整控制体系。各章节解决的核心问题及相互关系如下:
| 章节 | 解决的核心问题 | 与其他章节的关系 |
|---|---|---|
| 一、坐标系与旋转基础 | 如何描述无人机在三维空间中的姿态? | 数学预备,后续所有章节的基础 |
| 二、动力学建模 | 力和力矩如何驱动无人机运动?运动方程是什么? | 依赖一(旋转矩阵),为所有控制器设计提供被控对象模型 |
| 三、微分平坦性 | 能否只规划 |
依赖二(牛顿方程),是连接"轨迹规划"与"控制"的桥梁 |
| 四、PID 控制基础 | 控制器参数如何影响系统响应? | 控制理论入门,后续所有控制方法的起点 |
| 五、基于模型的控制 | 如何利用模型信息改善控制效果?(2D) | 依赖二(线性化模型)+ 四(PID),引入前馈+反馈结构 |
| 六、三维轨迹跟踪 | 如何将 2D 控制推广到完整的 3D 飞行? | 五的三维扩展,增加了偏航角耦合处理 |
| 七、SE(3) 控制 | 大角度机动时如何避免欧拉角的万向节锁? | 依赖三(姿态矩阵构建),是六的非线性替代方案 |
| 八、MPC 控制 | 如何在有约束条件下同时做局部规划和控制? | 融合规划与控制,依赖二(预测模型) |
阅读建议:
- 快速入门(理解基本控制):一 → 二 → 四 → 五
- 完整学习:按顺序 一 → 八
- 专题阅读:三(微分平坦性)是理解轨迹规划教程的前置知识
控制教程(8 个代码块):
一、坐标系与旋转基础 — 3D 旋转可视化:无人机"+"字形绕 Z/Y/X 轴旋转
二、动力学建模 — 给定 4 个电机转速,计算推力/力矩,可视化推力方向
三、微分平坦性 — 螺旋轨迹 → 由平坦输出推导姿态角和推力
四、PID 控制 — 二阶系统 Kp/Kd/Ki 对阶跃响应的影响对比
五、基于模型控制 — 2D 无人机 Y-Z 平面前馈+PD 控制仿真
六、三维轨迹跟踪 — 3D 八字形轨迹跟踪,期望 vs 实际对比
七、SE(3) 控制 — 大角度翻转:欧拉角控制 vs SE(3) 控制误差对比
八、MPC 控制 — 1D 高度控制 MPC,展示预测区间和约束处理
在研究四旋翼无人机之前,我们需要先理解坐标系变换和旋转矩阵,这是描述无人机姿态的数学基础。
考虑平面上一个点
设原坐标系为
写成矩阵形式:
设点
写成矩阵形式:
个人理解:方式二是点绕原点逆时针旋转
$\theta$ 角,所以旋转矩阵与方式一(坐标轴旋转)互为转置。
模式一:坐标轴旋转(点不动,坐标系旋转)
当原坐标系不动,坐标轴旋转时,求点在新坐标系下的坐标:
模式二:点旋转(坐标系不动,点旋转)
个人理解:换个角度理解——求点在新坐标系下的坐标,等价于把坐标轴顺时针旋转
$\theta$ 。可以想象自己站在坐标轴上跟着转,周围的点相对自己的位置自然就变了。
个人理解:两种模式的旋转矩阵互为转置。模式一可理解为"坐标系主动旋转",模式二为"点的主动旋转"。在无人机中,我们通常使用模式一——已知机体旋转了多少角度,求同一个点在新坐标系下的表示。
将二维推广到三维,分别绕三个轴旋转:
绕
绕
绕
个人理解:无人机的场景属于"坐标轴旋转"——机体坐标系相对于世界坐标系发生了旋转,我们需要把机体系下的向量转换到世界系下表达。
那么从自身坐标轴旋转的意义就是
如果已知
个人理解:在无人机应用中,旋转矩阵
$R$ 通常表示从机体坐标系(Body Frame)到世界坐标系(World Frame)的变换,即$\mathbf{p}_W = R \cdot \mathbf{p}_B$ 。其逆变换(世界→机体)为$R^T$ ,因为旋转矩阵是正交矩阵,$R^{-1} = R^T$ 。
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows 自带黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方框的问题
# ========== 可视化无人机"+"十字形绕 Z/Y/X 轴旋转 ==========
# --- 构造无人机"+"形状(4 个臂端 + 中心) ---
L = 1.0 # 臂长
drone = np.array([
[ L, 0, 0], # 右臂端
[-L, 0, 0], # 左臂端
[ 0, L, 0], # 前臂端
[ 0,-L, 0], # 后臂端
[ 0, 0, 0], # 中心
]).T # 3×5 矩阵,每列一个点
# --- 三维旋转矩阵 ---
def Rz(psi):
"""绕 Z 轴旋转(偏航角 ψ)"""
c, s = np.cos(psi), np.sin(psi)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
def Ry(theta):
"""绕 Y 轴旋转(俯仰角 θ)"""
c, s = np.cos(theta), np.sin(theta)
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]])
def Rx(phi):
"""绕 X 轴旋转(滚转角 φ)"""
c, s = np.cos(phi), np.sin(phi)
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
angle = np.radians(45) # 旋转 45°
# --- 绘制 4 个子图:原始 + 3 种旋转 ---
titles = ['原始姿态', f'绕 Z 轴旋转 {int(np.degrees(angle))}°(偏航)',
f'绕 Y 轴旋转 {int(np.degrees(angle))}°(俯仰)',
f'绕 X 轴旋转 {int(np.degrees(angle))}°(滚转)']
rotations = [np.eye(3), Rz(angle), Ry(angle), Rx(angle)]
fig = plt.figure(figsize=(14, 10))
for i, (R, title) in enumerate(zip(rotations, titles)):
ax = fig.add_subplot(2, 2, i + 1, projection='3d')
pts = R @ drone # 旋转后的点坐标
# 画两条臂(十字形)
ax.plot([pts[0,0], pts[0,1]], [pts[1,0], pts[1,1]],
[pts[2,0], pts[2,1]], 'b-o', lw=3, label='臂 1')
ax.plot([pts[0,2], pts[0,3]], [pts[1,2], pts[1,3]],
[pts[2,2], pts[2,3]], 'r-o', lw=3, label='臂 2')
# 标注中心
ax.scatter(*pts[:, 4], color='k', s=80, zorder=5)
# 画旋转后的机体坐标轴
axis_len = 0.6
for j, (color, lbl) in enumerate(zip(['r','g','b'], ['Xb','Yb','Zb'])):
axis_vec = R @ (axis_len * np.eye(3)[:, j])
ax.quiver(0, 0, 0, *axis_vec, color=color, arrow_length_ratio=0.15)
ax.set_xlim(-1.5, 1.5); ax.set_ylim(-1.5, 1.5); ax.set_zlim(-1.5, 1.5)
ax.set_xlabel('X'); ax.set_ylabel('Y'); ax.set_zlabel('Z')
ax.set_title(title)
plt.suptitle('无人机"+"十字形的三维旋转演示', fontsize=14)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control1.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
四旋翼飞行器的动力来源于四个电机,其模型如下:
电机转速模型(一阶惯性环节):
个人理解:电机转速模型由厂家提供,内部已做好闭环控制,我们在建模时无需关心其内部实现,直接使用即可。系数
$k_m$ 可通过实测标定。
单个电机产生的推力:
单个电机产生的反扭矩:
其中
个人理解:推力和力矩与转速的平方成正比,这是气动力学的基本结论。实际系统中
$k_F$ 、$k_M$ 通过台架实验标定得到。电机模型为一阶惯性环节,$k_m$ 越大电机响应越快;在控制器设计中通常假设电机响应足够快,可忽略该动态。
采用 Z-X-Y 顺序的欧拉角表示旋转(先偏航
展开得(令
机体角速度
个人理解:这个转换矩阵在
$\phi = \pm 90°$ 时出现奇异性(Gimbal Lock),这也是欧拉角表示法的固有缺陷。对于小角度运动(如悬停附近),该矩阵近似为单位阵,即角速度近似等于欧拉角导数。
个人理解:牛顿-欧拉方程在惯性坐标系下建立,描述无人机在合力和合力矩作用下的运动规律。
牛顿方程描述质心的平动:
个人理解:等号左边
$m\ddot{\mathbf{p}}$ 是质心加速度乘质量;右边第一项是重力(竖直向下),第二项是旋转矩阵$R$ 将机体$z$ 轴方向的总推力转换到世界坐标系(注意推力方向始终沿机体$z$ 轴正方向)。此处忽略了空气阻力。
其中
右侧分别定义为控制力矩输入:
个人理解:欧拉方程在机体坐标系下建立,因为转动惯量矩阵
$I$ 在机体系下是常数对角阵,避免了随姿态变化的复杂性。
个人理解:
$\omega \times I\omega$ 项是科里奥利力矩 / 陀螺效应项,在悬停附近(角速度小时)可忽略,这为后续线性化提供了基础。$u_2, u_3$ 通过差分推力产生滚转/俯仰力矩,$u_4$ 通过差分反扭矩产生偏航力矩。
| 编号 | 名称 | 描述 |
|---|---|---|
| ① | 电机模型 | |
| ② | 欧拉方程 | 转动动力学 |
| ③ | 角速度转换 | 欧拉角速度与欧拉角的导数之间的转换 |
| ④ | 旋转矩阵 | Z-X-Y 欧拉角表示 |
| ⑤ | 牛顿方程 | 平动动力学 |
以平面(Y-Z平面)运动为例,状态向量
个人理解:这就是二维情况下的非线性状态方程。可以看到
$y$ 方向的加速度依赖于$\sin\phi$ (需要先倾斜才能水平移动),$z$ 方向加速度依赖于$\cos\phi$ (推力在竖直方向的分量),$\phi$ 由力矩$u_2$ 直接控制。这体现了四旋翼"欠驱动"的特性——只有4个输入却要控制6个自由度。
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows 自带黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方框的问题
# ========== 四旋翼电机模型:推力/力矩计算与可视化 ==========
# --- 电机参数 ---
kF = 6.11e-8 # 推力系数 (N/(rad/s)^2)
kM = 1.5e-9 # 力矩系数 (N·m/(rad/s)^2)
L = 0.175 # 臂长 (m)
# --- 四个电机转速 (rad/s),模拟非对称飞行 ---
omega = np.array([4000, 4200, 3800, 4100]) # ω1, ω2, ω3, ω4
# --- 计算各电机推力和反扭矩 ---
F = kF * omega**2 # 各电机推力
M = kM * omega**2 # 各电机反扭矩
# --- 计算总推力和控制力矩 ---
u1 = np.sum(F) # 总推力
u2 = L * (F[1] - F[3]) # 滚转力矩
u3 = L * (F[2] - F[0]) # 俯仰力矩
u4 = M[0] - M[1] + M[2] - M[3] # 偏航力矩
print(f"各电机推力: F = {F} N")
print(f"总推力 u1 = {u1:.4f} N")
print(f"滚转力矩 u2 = {u2:.6f} N·m")
print(f"俯仰力矩 u3 = {u3:.6f} N·m")
print(f"偏航力矩 u4 = {u4:.8f} N·m")
# --- 可视化:俯视图(电机位置 + 推力)和 3D 推力箭头 ---
fig = plt.figure(figsize=(14, 5))
# 子图 1:俯视图,四个电机位置和推力大小
ax1 = fig.add_subplot(1, 2, 1)
motor_pos = np.array([[0, L], [-L, 0], [0, -L], [L, 0]]) # 电机 1前 2左 3后 4右
colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12']
for i in range(4):
circle = plt.Circle(motor_pos[i], 0.04, color=colors[i], zorder=5)
ax1.add_patch(circle)
ax1.annotate(f'M{i+1}\nω={omega[i]}\nF={F[i]:.3f}N',
motor_pos[i], textcoords="offset points",
xytext=(15, 10), fontsize=8, color=colors[i])
# 画机体十字
ax1.plot([motor_pos[1,0], motor_pos[3,0]],
[motor_pos[1,1], motor_pos[3,1]], 'k-', lw=2)
ax1.plot([motor_pos[0,0], motor_pos[2,0]],
[motor_pos[0,1], motor_pos[2,1]], 'k-', lw=2)
ax1.set_xlim(-0.35, 0.35); ax1.set_ylim(-0.35, 0.35)
ax1.set_aspect('equal'); ax1.grid(True, alpha=0.3)
ax1.set_title('俯视图:电机分布与推力'); ax1.set_xlabel('Y (m)'); ax1.set_ylabel('X (m)')
# 子图 2:3D 视图,显示推力箭头
ax2 = fig.add_subplot(1, 2, 2, projection='3d')
for i in range(4):
x, y = motor_pos[i]
# 各电机推力箭头(沿 Z 轴向上)
scale = F[i] / np.max(F) * 0.3 # 归一化显示
ax2.quiver(x, y, 0, 0, 0, scale, color=colors[i],
arrow_length_ratio=0.2, lw=2, label=f'M{i+1}: {F[i]:.3f}N')
ax2.scatter(x, y, 0, color=colors[i], s=60)
# 总推力箭头(从中心出发)
ax2.quiver(0, 0, 0, 0, 0, 0.4, color='k', arrow_length_ratio=0.15,
lw=3, label=f'总推力 u1={u1:.3f}N')
ax2.set_xlabel('Y'); ax2.set_ylabel('X'); ax2.set_zlabel('Z(推力方向)')
ax2.set_title('3D 视图:推力矢量'); ax2.legend(fontsize=7, loc='upper left')
plt.suptitle('四旋翼电机模型可视化', fontsize=14)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control2.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
个人理解:微分平坦性是连接轨迹规划和控制的桥梁——有了微分平坦性,规划只需要设计平坦输出的轨迹,所有状态和输入都能自动推出。结合 SE(3) 控制,可以实现非常精确的位姿跟踪。
微分平坦(Differential Flatness)是指:如果系统的所有状态和输入都可以由一组**平坦输出(Flat Output)**及其有限次微分来表示,那么这个系统就是微分平坦的。
个人理解:四旋翼有 12 维状态和 4 维输入,但平坦输出只有 4 维(
$x, y, z, \psi$ )。微分平坦的意义在于:如果系统的所有状态和输入都可以由一组"平坦输出"及其有限阶导数表示,那么高维的控制问题就可以简化为对这 4 个平坦输出的轨迹设计问题。
对于四旋翼无人机,选择平坦输出为:
它定义了一个足够宽的轨迹集合:
个人理解:平坦输出
$\sigma$ 定义了$SE(3)$ 上的轨迹——即同时包含位置($\mathbb{R}^3$ )和姿态($SO(3)$ )的完整刚体运动轨迹。世界坐标系到机体坐标系的变换可以完全由$\sigma$ 及其导数确定。
完整状态向量为:
个人理解:选定
$\sigma = [x, y, z]^T$ ,$\dot{\sigma} = [\dot{x}, \dot{y}, \dot{z}]^T$ ,可以解析得到 roll、pitch、yaw 和角速度。
微分平坦的核心价值在于:只要给定
| 已知量 | 推出量 | 所需导数阶数 |
|---|---|---|
| 位置 |
0 阶 | |
| 速度 |
1 阶 | |
| 总推力 |
2 阶 | |
| 角速度 |
3 阶 | |
| 角加速度 |
4 阶(snap) |
个人理解:这就是为什么轨迹规划中要最小化 snap(四阶导数)——snap 直接关联到控制力矩的大小。如果 snap 过大,意味着需要很大的控制力矩,电机可能饱和。
从牛顿方程出发:
个人理解:总推力
$u_1$ 等于合力的模:$u_1 = m\sqrt{\ddot{x}^2 + \ddot{y}^2 + (\ddot{z}+g)^2}$ 。物理含义是:总推力必须同时克服重力并提供所需的加速度。
由此得到机体
其中
通过中间变量
个人理解:构建旋转矩阵的思路——先由推力方向确定
$\hat{z}_B$ ,再由偏航角确定辅助方向$\hat{x}_C$ 。$\hat{y}_B$ 通过叉乘$\hat{z}_B \times \hat{x}_C$ 得到,它垂直于$\hat{z}_B$ 和$\hat{x}_C$ 构成的平面。最后$\hat{x}_B = \hat{y}_B \times \hat{z}_B$ 补全右手系。
从旋转矩阵中提取各欧拉角:
个人理解:因此
$\phi, \theta$ 完全由平坦输出$[x, y, z, \psi]$ 的二阶导数(加速度)决定,无需更高阶导数。
个人理解:接下来推导角速度
$p, q, r$ ,需要对牛顿方程再求一次时间导数(从加速度到 jerk)。
对牛顿方程求时间导数(jerk):
个人理解:
$\hat{z}_W$ 是世界坐标系的固定单位向量,不随时间变化,所以$\frac{d}{dt}(-mg\hat{z}_W) = 0$ 。
由于
个人理解:对
$u_1\hat{z}_B$ 求导时用乘积法则:$\dot{u}_1$ 是推力大小的变化率,$\dot{\hat{z}}_B$ 是推力方向的变化率。根据刚体运动学,方向向量的变化率可以用角速度的叉乘表示: $\dot{\hat{z}}B = \omega{BW} \times \hat{z}_B$。
利用运动学关系 $\dot{\hat{z}}B = \omega{BW} \times \hat{z}_B$ :
消去
定义辅助向量:
个人理解:
$h_W$ 是一个辅助向量,由已知的 jerk 和推力计算得到,它包含了$\omega_{BW}$ 在垂直于$\hat{z}_B$ 平面内的分量信息,可以从中提取出$p$ 和$q$ 。
角速度在机体系下分解:
个人理解:注意
$\omega_{BW} = p\hat{x}_B + q\hat{y}_B + r\hat{z}_B$ 是矢量分解,利用$\hat{x}_B, \hat{y}_B, \hat{z}_B$ 的正交性可以通过点乘分别提取各分量。
我们知道
利用
对于
个人理解:由正交性
$\hat{x}_B \perp \hat{z}_B$ 、$\hat{y}_B \perp \hat{z}_B$ ,所以$\hat{z}_B$ 方向的角速度分量$r$ 无法从$h_W$ (垂直于$\hat{z}_B$ 的量)中获得,需要通过偏航角速率$\dot{\psi}$ 单独计算。
个人理解:
$r$ 分量直接与偏航角速率$\dot{\psi}$ 相关,这体现了偏航角是独立的平坦输出之一。$p, q$ 需要 jerk(三阶导数)才能算出,而$r$ 只需要$\dot{\psi}$ ——这也是偏航轴解耦的体现。
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows 自带黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方框的问题
# ========== 微分平坦性验证:从圆形轨迹推导姿态角和推力 ==========
# --- 物理参数 ---
m = 0.5 # 无人机质量 (kg)
g = 9.81 # 重力加速度 (m/s^2)
# --- 定义圆形轨迹(平坦输出 σ = [x, y, z, ψ]) ---
T_total = 10.0 # 总时间 (s)
dt = 0.01
t = np.arange(0, T_total, dt)
omega_c = 2 * np.pi / T_total # 圆轨迹角频率
# 位置轨迹:水平圆 + 缓慢上升 + 偏航跟随速度方向
radius = 2.0
x = radius * np.cos(omega_c * t)
y = radius * np.sin(omega_c * t)
z = 0.5 * t # 螺旋上升
psi = np.arctan2(np.gradient(y, dt), np.gradient(x, dt)) # 偏航角跟随速度方向
# --- 计算各阶导数 ---
ddx = np.gradient(np.gradient(x, dt), dt) # x 的二阶导数(加速度)
ddy = np.gradient(np.gradient(y, dt), dt) # y 的二阶导数
ddz = np.gradient(np.gradient(z, dt), dt) # z 的二阶导数
# --- 由微分平坦性推导总推力 u1 ---
u1 = m * np.sqrt(ddx**2 + ddy**2 + (ddz + g)**2)
# --- 由加速度推导姿态角 φ (roll) 和 θ (pitch) ---
# 机体 z 轴方向(单位推力方向)
tx = ddx / (ddz + g + 1e-10)
ty = ddy / (ddz + g + 1e-10)
# 通过旋转矩阵关系推导欧拉角
phi = np.arcsin(np.clip(tx * np.sin(psi) - ty * np.cos(psi), -1, 1)) # 滚转角
theta = np.arctan2(tx * np.cos(psi) + ty * np.sin(psi),
1 + ddz / g) # 俯仰角
# --- 绘制结果 ---
fig = plt.figure(figsize=(15, 10))
# 子图 1:三维轨迹
ax1 = fig.add_subplot(2, 2, 1, projection='3d')
ax1.plot(x, y, z, 'b-', lw=1.5)
# 每隔一段标注无人机位置和朝向
step = len(t) // 8
for i in range(0, len(t), step):
ax1.scatter(x[i], y[i], z[i], c='r', s=40, zorder=5)
dx_arrow = 0.3 * np.cos(psi[i])
dy_arrow = 0.3 * np.sin(psi[i])
ax1.quiver(x[i], y[i], z[i], dx_arrow, dy_arrow, 0,
color='r', arrow_length_ratio=0.3)
ax1.set_xlabel('X (m)'); ax1.set_ylabel('Y (m)'); ax1.set_zlabel('Z (m)')
ax1.set_title('螺旋上升轨迹 σ(t)')
# 子图 2:总推力 u1
ax2 = fig.add_subplot(2, 2, 2)
ax2.plot(t, u1, 'g-', lw=1.5)
ax2.axhline(y=m*g, color='k', ls='--', alpha=0.5, label=f'悬停推力 mg={m*g:.2f}N')
ax2.set_xlabel('时间 (s)'); ax2.set_ylabel('u₁ (N)')
ax2.set_title('总推力 u₁(由二阶导数推出)'); ax2.legend(); ax2.grid(True, alpha=0.3)
# 子图 3:滚转角 φ
ax3 = fig.add_subplot(2, 2, 3)
ax3.plot(t, np.degrees(phi), 'r-', lw=1.5, label='φ (roll)')
ax3.set_xlabel('时间 (s)'); ax3.set_ylabel('角度 (°)')
ax3.set_title('滚转角 φ(由 σ̈ 推出)'); ax3.legend(); ax3.grid(True, alpha=0.3)
# 子图 4:俯仰角 θ
ax4 = fig.add_subplot(2, 2, 4)
ax4.plot(t, np.degrees(theta), 'b-', lw=1.5, label='θ (pitch)')
ax4.set_xlabel('时间 (s)'); ax4.set_ylabel('角度 (°)')
ax4.set_title('俯仰角 θ(由 σ̈ 推出)'); ax4.legend(); ax4.grid(True, alpha=0.3)
plt.suptitle('微分平坦性验证:从轨迹 [x,y,z,ψ] 推导姿态与推力', fontsize=14)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control3.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
PID 控制器的三个参数
| 参数 | Rise Time(上升时间) | Overshoot(超调量) | Settling Time(调节时间) | Steady-state Error(稳态误差) |
|---|---|---|---|---|
| 减少 | 增加 | 减少 | 减少 | |
| — | 减少 | 减少 | — | |
| 增加 | 增加 | 增加 | — |
阶跃响应的关键指标:
- Rise Time(上升时间):响应从初始值上升到稳态值的时间
- Overshoot(超调量):响应超过稳态值的最大偏差
-
Settling Time(调节时间):响应进入并保持在稳态值
$\pm 1%$ (或$\pm 5%$ )范围内的时间 - Steady-state Error(稳态误差):稳态时响应值与期望值之间的差异
个人理解:Settling Time 常用的标准有两种:进入
$\pm 5%$ 范围或$\pm 1%$ 范围所需的时间。工程中更常用$\pm 2%$ 标准。
个人理解:
$K_p$ 减少上升时间但增加超调,$K_d$ 增加阻尼减少超调,$K_i$ 消除稳态误差但可能引起振荡。在四旋翼控制中,合理调节这三个参数至关重要——通常先调$K_p$ 保证响应速度,再调$K_d$ 抑制超调,最后适量加入$K_i$ 消除稳态误差。
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows 自带黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方框的问题
# ========== PID 参数调节对二阶系统阶跃响应的影响 ==========
# 被控对象:无人机高度方向简化模型 m * z̈ = u - mg
# 等效二阶系统:ë + (Kd/m)*ė + (Kp/m)*e = -(Ki/m)*∫e dt
def simulate_pid(Kp, Kd, Ki, dt=0.001, T=5.0):
"""仿真 PID 控制的二阶系统阶跃响应"""
steps = int(T / dt)
z = 0.0 # 当前位置(高度)
dz = 0.0 # 当前速度
z_des = 1.0 # 期望高度(阶跃输入)
m = 0.5 # 无人机质量
g = 9.81 # 重力加速度
integral = 0.0 # 积分项
z_hist = np.zeros(steps)
for i in range(steps):
e = z_des - z # 位置误差
de = -dz # 速度误差(期望速度为 0)
integral += e * dt # 误差积分
# PID 控制律:u = mg + Kp*e + Kd*ė + Ki*∫e
u = m * g + Kp * e + Kd * de + Ki * integral
# 动力学:m * z̈ = u - mg
ddz = (u - m * g) / m
dz += ddz * dt
z += dz * dt
z_hist[i] = z
return z_hist
dt = 0.001; T = 5.0
t = np.arange(0, T, dt)
# --- 子图 1:改变 Kp(固定 Kd=2, Ki=0) ---
fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
ax = axes[0]
for Kp in [1, 5, 15, 30]:
z_hist = simulate_pid(Kp=Kp, Kd=2.0, Ki=0.0, dt=dt, T=T)
ax.plot(t, z_hist, label=f'Kp={Kp}')
ax.axhline(y=1.0, color='k', ls='--', alpha=0.4, label='目标')
ax.set_title('改变 Kp(Kd=2, Ki=0)')
ax.set_xlabel('时间 (s)'); ax.set_ylabel('高度 z (m)')
ax.legend(fontsize=8); ax.grid(True, alpha=0.3)
ax.set_ylim(-0.1, 1.8)
# --- 子图 2:改变 Kd(固定 Kp=15, Ki=0) ---
ax = axes[1]
for Kd in [0, 1, 3, 8]:
z_hist = simulate_pid(Kp=15.0, Kd=Kd, Ki=0.0, dt=dt, T=T)
ax.plot(t, z_hist, label=f'Kd={Kd}')
ax.axhline(y=1.0, color='k', ls='--', alpha=0.4, label='目标')
ax.set_title('改变 Kd(Kp=15, Ki=0)')
ax.set_xlabel('时间 (s)'); ax.set_ylabel('高度 z (m)')
ax.legend(fontsize=8); ax.grid(True, alpha=0.3)
ax.set_ylim(-0.1, 1.8)
# --- 子图 3:改变 Ki(固定 Kp=10, Kd=3) ---
ax = axes[2]
for Ki in [0, 2, 8, 20]:
z_hist = simulate_pid(Kp=10.0, Kd=3.0, Ki=Ki, dt=dt, T=T)
ax.plot(t, z_hist, label=f'Ki={Ki}')
ax.axhline(y=1.0, color='k', ls='--', alpha=0.4, label='目标')
ax.set_title('改变 Ki(Kp=10, Kd=3)')
ax.set_xlabel('时间 (s)'); ax.set_ylabel('高度 z (m)')
ax.legend(fontsize=8); ax.grid(True, alpha=0.3)
ax.set_ylim(-0.1, 1.8)
plt.suptitle('PID 参数对无人机高度阶跃响应的影响', fontsize=14)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control4.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
一个常见的二阶系统模型:
其中
PD 控制律:
其中
个人理解:纯 PD 控制没有利用模型信息——它不可能完全补偿系统动态,加上传感器噪声和模型误差,跟踪误差一定会存在。这正是引入基于模型的前馈控制的动机。
个人理解:该控制律的结构 = 前馈项(
$\hat{m}\ddot{x}^{des} + \hat{b}\dot{x} + \hat{k}x$ ,利用模型抵消已知动态) + PD 反馈项($k_d\dot{e} + k_pe$ ,修正建模误差和扰动)。
个人理解:与纯 PD 控制相比,基于模型的控制律多了
$\hat{b}\dot{x}$ 和$\hat{k}x$ 两项前馈补偿。前馈项利用模型知识主动抵消已知动态,PD 反馈负责修正建模误差。$\hat{m}, \hat{b}, \hat{k}$ 为模型估计值——估计越准,反馈项的负担越轻。
- 获得目标系统的动态
- 特定子模型
基于伺服的控制:
- 使用带有微分的 PID 或 PD 控制器来将误差收敛到 0
- 独立子系统模型
个人理解:PID 和 LQR 都需要线性化模型作为前提。将非线性系统在工作点(如悬停点)线性化后,再对线性化后的子系统分别设计控制器。
ydes, zdes ┌──────────┐ ┌──────┐ ┌──────────┐ u₂ ┌─────┐
ẏdes, żdes ──>│ 位置控制 │────>│ φ_c │────>│ 姿态控制 │──────>│ UAV │
└──────────┘ └──────┘ └──────────┘ └─────┘
↑ u₁ │
└──── y, z, ẏ, ż ──────── φ, φ̇ ──────────────┘
平衡条件(悬停状态):
线性化动力学模型(在悬停点附近):
个人理解:线性化后
$y$ 和$\phi$ 直接耦合($\ddot{y} = -g\phi$ ),$z$ 由$u_1$ 独立控制,$\phi$ 由$u_2$ 独立控制。这样原来的 6 维非线性系统变成了两个独立的线性子系统:(1) Z 轴高度控制,(2) Y-$\phi$ 耦合的位置-姿态控制。
PD 控制律:
代入模型
外环——PD 位置控制律:
由模型
内环——PD 姿态控制:
个人理解:Y 轴位置控制是一个典型的级联(cascade)控制结构——外环位置控制器输出期望滚转角
$\phi_c$ ,内环姿态控制器跟踪该期望角度。内环频率需高于外环(通常 5~10 倍),以保证时间尺度分离。
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows 自带黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方框的问题
# ========== 2D 无人机(Y-Z 平面)前馈 + PD 控制仿真 ==========
# --- 物理参数 ---
m = 0.5 # 质量 (kg)
g = 9.81 # 重力加速度 (m/s^2)
Ixx = 0.002 # 绕 x 轴转动惯量 (kg·m^2)
dt = 0.002 # 仿真步长 (s)
T_sim = 6.0 # 总仿真时间
N = int(T_sim / dt)
# --- PD 增益 ---
kp_z, kd_z = 15.0, 8.0 # Z 轴位置 PD
kp_y, kd_y = 8.0, 6.0 # Y 轴位置 PD
kp_phi, kd_phi = 150.0, 20.0 # 姿态角 PD
# --- 期望轨迹:悬停 2s 后移动到目标位置 ---
y_des = np.where(np.arange(N) * dt < 2.0, 0.0, 2.0)
z_des = np.where(np.arange(N) * dt < 2.0, 1.0, 2.5)
# --- 状态初始化 [y, z, phi, dy, dz, dphi] ---
state = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
hist_y, hist_z, hist_u1, hist_u2, hist_phi = [], [], [], [], []
for i in range(N):
y, z, phi, dy, dz, dphi = state
# 外环:Z 轴 PD + 重力前馈
ddz_c = kp_z * (z_des[i] - z) + kd_z * (0 - dz)
u1 = m * (g + ddz_c) / (np.cos(phi) + 1e-6) # 前馈:mg/cos(φ)
u1 = np.clip(u1, 0, 3 * m * g) # 推力限幅
# 外环:Y 轴 PD → 期望滚转角
ddy_c = kp_y * (y_des[i] - y) + kd_y * (0 - dy)
phi_c = -ddy_c / g # 由线性化关系 ÿ = -g·φ
phi_c = np.clip(phi_c, -0.5, 0.5) # 限制期望角度
# 内环:姿态 PD
u2 = Ixx * (kp_phi * (phi_c - phi) + kd_phi * (0 - dphi))
# 非线性动力学更新
ddy = -(u1 / m) * np.sin(phi)
ddz = (u1 / m) * np.cos(phi) - g
ddphi = u2 / Ixx
state += np.array([dy, dz, dphi, ddy, ddz, ddphi]) * dt
hist_y.append(y); hist_z.append(z)
hist_u1.append(u1); hist_u2.append(u2); hist_phi.append(phi)
t_arr = np.arange(N) * dt
# --- 绘图:2 行 2 列 ---
fig, axes = plt.subplots(2, 2, figsize=(13, 9))
# Y-Z 轨迹
axes[0, 0].plot(hist_y, hist_z, 'b-', lw=1.5, label='实际轨迹')
axes[0, 0].plot(y_des[0], z_des[0], 'go', ms=10, label='起始目标')
axes[0, 0].plot(y_des[-1], z_des[-1], 'r*', ms=14, label='终点目标')
axes[0, 0].set_xlabel('Y (m)'); axes[0, 0].set_ylabel('Z (m)')
axes[0, 0].set_title('Y-Z 平面飞行轨迹'); axes[0, 0].legend(); axes[0, 0].grid(True, alpha=0.3)
# 高度与水平位置响应
axes[0, 1].plot(t_arr, hist_z, 'b-', label='z 实际')
axes[0, 1].plot(t_arr, z_des, 'b--', alpha=0.5, label='z 期望')
axes[0, 1].plot(t_arr, hist_y, 'r-', label='y 实际')
axes[0, 1].plot(t_arr, y_des, 'r--', alpha=0.5, label='y 期望')
axes[0, 1].set_xlabel('时间 (s)'); axes[0, 1].set_ylabel('位置 (m)')
axes[0, 1].set_title('位置跟踪响应'); axes[0, 1].legend(); axes[0, 1].grid(True, alpha=0.3)
# 控制输入
axes[1, 0].plot(t_arr, hist_u1, 'g-', label='u₁ (总推力)')
axes[1, 0].axhline(y=m * g, color='k', ls='--', alpha=0.4, label=f'悬停推力 mg={m*g:.2f}')
axes[1, 0].set_xlabel('时间 (s)'); axes[1, 0].set_ylabel('推力 (N)')
axes[1, 0].set_title('总推力 u₁'); axes[1, 0].legend(); axes[1, 0].grid(True, alpha=0.3)
# 滚转角
axes[1, 1].plot(t_arr, np.degrees(hist_phi), 'm-', label='φ (滚转角)')
axes[1, 1].set_xlabel('时间 (s)'); axes[1, 1].set_ylabel('角度 (°)')
axes[1, 1].set_title('滚转角 φ 响应'); axes[1, 1].legend(); axes[1, 1].grid(True, alpha=0.3)
plt.suptitle('第五章:2D 无人机前馈 + PD 控制仿真(悬停→移动)', fontsize=14)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control5.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
给定期望轨迹
定义误差:
控制律:
p_des ┌────────────┐ ┌──────────┐ ┌──────────┐ u₂,u₃,u₄ ┌─────┐
ṗ_des ─>│ 轨迹跟踪 │──>│ 位置控制 │──u₁──>│ 姿态控制 │──────────>│ UAV │
└────────────┘ └──────────┘ φc,θc └──────────┘ └─────┘
↑ │
└─── p, ṗ ──────────────── φ,θ,ψ, ωx,ωy,ωz ─────────────┘
牛顿方程:
欧拉方程:
角速度回收:
其中
个人理解:该角速度转换矩阵将欧拉角导数(定义在各中间旋转坐标系中)映射为机体坐标系下的角速度分量。
欧拉角微分的逆关系(求解
在悬停点附近(
个人理解:注意
$\psi$ 并不为零时(即偏航角不为零),$x$ 和$y$ 方向的加速度都同时依赖于$\phi$ 和$\theta$ ,这就是偏航角带来的耦合。当$\psi = 0$ 时简化为$\ddot{x} = g\theta$ ,$\ddot{y} = -g\phi$ ,与第五章的 2D 情况一致。
总推力:
期望姿态角(考虑偏航角
个人理解:位置控制器只输出期望的
$\phi_c, \theta_c$ 和推力$u_1$ ,偏航角$\psi$ 由单独的偏航控制器跟踪期望值$\psi^{des}$ ,不参与位置外环。
控制力矩:
个人理解:力矩控制律中的第二项
$\omega \times I\omega$ 是前馈补偿项,用于抵消陀螺效应。在悬停附近可以忽略,但在快速机动时必须加入。
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows 自带黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方框的问题
# ========== 3D "8"字轨迹跟踪仿真 ==========
# --- 物理参数 ---
m = 0.5; g = 9.81; dt = 0.005; T_sim = 12.0
N = int(T_sim / dt)
t = np.arange(N) * dt
Kp = np.diag([6.0, 6.0, 10.0]) # 位置 PD 增益
Kd = np.diag([4.0, 4.0, 6.0])
kp_att, kd_att = 80.0, 15.0 # 姿态 PD 增益
Ixx = Iyy = 0.002; Izz = 0.004
# --- 期望 "8" 字轨迹 ---
omega_t = 2 * np.pi / T_sim
x_des = 2.0 * np.sin(omega_t * t)
y_des = 1.5 * np.sin(2 * omega_t * t)
z_des = 1.0 + 0.3 * np.sin(omega_t * t)
# 期望速度、加速度(解析求导)
dx_des = 2.0 * omega_t * np.cos(omega_t * t)
dy_des = 1.5 * 2 * omega_t * np.cos(2 * omega_t * t)
dz_des = 0.3 * omega_t * np.cos(omega_t * t)
ddx_des = -2.0 * omega_t**2 * np.sin(omega_t * t)
ddy_des = -1.5 * (2*omega_t)**2 * np.sin(2 * omega_t * t)
ddz_des = -0.3 * omega_t**2 * np.sin(omega_t * t)
# --- 状态初始化 ---
pos = np.array([0.0, 0.0, 0.0]) # 位置
vel = np.array([0.0, 0.0, 0.0]) # 速度
phi, theta, psi = 0.0, 0.0, 0.0 # 欧拉角
dphi, dtheta, dpsi = 0.0, 0.0, 0.0
hist_pos = np.zeros((N, 3))
for i in range(N):
p_des = np.array([x_des[i], y_des[i], z_des[i]])
v_des = np.array([dx_des[i], dy_des[i], dz_des[i]])
a_des = np.array([ddx_des[i], ddy_des[i], ddz_des[i]])
# 位置 PD 控制 → 期望加速度
ep = p_des - pos; ev = v_des - vel
acc_cmd = a_des + Kp @ ep + Kd @ ev
# 总推力(前馈 + PD)
u1 = m * np.sqrt(acc_cmd[0]**2 + acc_cmd[1]**2 + (acc_cmd[2] + g)**2)
u1 = np.clip(u1, 0.1, 4 * m * g)
# 期望姿态角(考虑 ψ≈0 简化)
phi_c = np.clip((acc_cmd[0]*np.sin(psi) - acc_cmd[1]*np.cos(psi)) / g, -0.5, 0.5)
theta_c = np.clip((acc_cmd[0]*np.cos(psi) + acc_cmd[1]*np.sin(psi)) / g, -0.5, 0.5)
# 姿态 PD 控制
u2 = Ixx * (kp_att * (phi_c - phi) + kd_att * (0 - dphi))
u3 = Iyy * (kp_att * (theta_c - theta) + kd_att * (0 - dtheta))
# 简化 3D 动力学更新
cphi, sphi = np.cos(phi), np.sin(phi)
cth, sth = np.cos(theta), np.sin(theta)
cpsi, spsi = np.cos(psi), np.sin(psi)
ax = (u1/m) * (cpsi*sth*cphi + spsi*sphi)
ay = (u1/m) * (spsi*sth*cphi - cpsi*sphi)
az = (u1/m) * cphi*cth - g
vel += np.array([ax, ay, az]) * dt
pos += vel * dt
dphi += (u2 / Ixx) * dt; phi += dphi * dt
dtheta += (u3 / Iyy) * dt; theta += dtheta * dt
hist_pos[i] = pos
# --- 绘图:3D 轨迹对比 ---
fig = plt.figure(figsize=(12, 8))
ax3d = fig.add_subplot(1, 1, 1, projection='3d')
ax3d.plot(x_des, y_des, z_des, 'b--', lw=1.5, alpha=0.6, label='期望轨迹("8"字)')
ax3d.plot(hist_pos[:, 0], hist_pos[:, 1], hist_pos[:, 2],
'r-', lw=1.2, label='实际轨迹')
# 标注起点和终点
ax3d.scatter(*hist_pos[0], c='g', s=100, marker='o', zorder=5, label='起点')
ax3d.scatter(*hist_pos[-1], c='k', s=100, marker='*', zorder=5, label='终点')
# 每隔一段绘制位置标记
step = N // 10
for j in range(0, N, step):
ax3d.plot([x_des[j], hist_pos[j, 0]], [y_des[j], hist_pos[j, 1]],
[z_des[j], hist_pos[j, 2]], 'k-', alpha=0.2, lw=0.8)
ax3d.set_xlabel('X (m)'); ax3d.set_ylabel('Y (m)'); ax3d.set_zlabel('Z (m)')
ax3d.set_title('第六章:3D "8"字轨迹跟踪(PD 控制)', fontsize=14)
ax3d.legend(fontsize=10)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control6.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
个人理解:当欧拉角线性化带来的近似误差不可接受时(例如大角度机动),就需要使用 SE(3) 控制——它直接在旋转矩阵上定义误差,不做任何线性化近似,因此不损失精度。
SE(3) 控制直接在特殊欧几里得群
状态表示:
其中
位置误差:
速度误差:
个人理解:将期望力投影到机体
$z$ 轴上得到总推力标量$u_1$ ,剩余的力方向信息用于构建期望姿态。这样位置控制和姿态控制仍然是解耦的:位置环输出期望力,姿态环跟踪期望姿态。
总推力(投影到机体
从期望力方向构建期望旋转矩阵:
个人理解:从期望力方向构建完整的旋转矩阵
$R_{des}$ 。注意:在 snap 较大的激进机动中,期望力方向可能接近水平,此时$\hat{z}_{B,des}$ 接近与$\hat{x}_C$ 平行,叉乘会退化,需要做奇异性保护。
根据期望偏航角
姿态误差(使用 Vee 映射
角速度误差:
个人理解:SE(3) 控制的优势在于:(1) 避免万向节锁,适用于大角度机动;(2) 姿态误差定义在
$SO(3)$ 上,具有全局收敛性。代价是实现复杂度较高,需要直接操作旋转矩阵。其构建期望姿态矩阵的方法与第三章微分平坦中的推导完全一致。
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
# ========== 对比:欧拉角控制 vs SE(3) 控制在大角度翻转中的表现 ==========
def vee(M):
"""反对称矩阵 → 向量"""
return np.array([M[2,1], M[0,2], M[1,0]])
def Ry(a):
"""绕 Y 轴旋转矩阵"""
c, s = np.cos(a), np.sin(a)
return np.array([[c,0,s],[0,1,0],[-s,0,c]])
# --- 仿真参数(使用单位惯量简化,聚焦控制差异) ---
dt = 0.005; T_sim = 6.0; N = int(T_sim / dt)
t = np.arange(N) * dt
I_mat = np.diag([1.0, 1.0, 1.0]) # 单位惯量
# 目标:绕 Y 轴翻转 170°(接近 pitch ±90° 万向节锁区域)
flip_angle = np.deg2rad(170)
R_des = Ry(flip_angle)
# --- 方法 1:欧拉角 PD 控制 ---
euler = np.zeros(3) # [phi, theta, psi]
deuler = np.zeros(3)
kp_e, kd_e = 4.0, 3.0
# 从 R_des 提取目标欧拉角(ZYX 顺序)
theta_des = np.arctan2(-R_des[2,0], np.sqrt(R_des[0,0]**2 + R_des[1,0]**2))
phi_des = np.arctan2(R_des[2,1], R_des[2,2])
psi_des = np.arctan2(R_des[1,0], R_des[0,0])
euler_des = np.array([phi_des, theta_des, psi_des])
err_euler = np.zeros(N)
for i in range(N):
e_ang = euler_des - euler
err_euler[i] = np.linalg.norm(e_ang)
tau = kp_e * e_ang + kd_e * (-deuler)
deuler += tau * dt
euler += deuler * dt
# --- 方法 2:SE(3) 控制 ---
R_cur = np.eye(3)
omega = np.zeros(3)
kR, kw = 4.0, 3.0
err_se3 = np.zeros(N)
for i in range(N):
eR_mat = R_des.T @ R_cur - R_cur.T @ R_des
eR = 0.5 * vee(eR_mat)
err_se3[i] = np.linalg.norm(eR)
tau_se3 = -kR * eR - kw * omega
omega += tau_se3 * dt
# Rodrigues 公式更新旋转矩阵
wx = omega * dt
ang = np.linalg.norm(wx)
if ang > 1e-12:
K = np.array([[0,-wx[2],wx[1]],[wx[2],0,-wx[0]],[-wx[1],wx[0],0]]) / ang
dR = np.eye(3) + np.sin(ang)*K + (1-np.cos(ang))*(K@K)
else:
dR = np.eye(3)
R_cur = R_cur @ dR
# --- 绘图对比 ---
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(t, err_euler, 'r-', lw=1.5, label='欧拉角 PD 误差')
axes[0].plot(t, err_se3, 'b-', lw=1.5, label='SE(3) 姿态误差')
axes[0].set_xlabel('时间 (s)'); axes[0].set_ylabel('姿态误差范数')
axes[0].set_title('姿态误差对比(目标:绕 Y 轴翻转 170°)')
axes[0].legend(); axes[0].grid(True, alpha=0.3)
axes[1].semilogy(t, np.clip(err_euler, 1e-10, None), 'r-', lw=1.5, label='欧拉角 PD')
axes[1].semilogy(t, np.clip(err_se3, 1e-10, None), 'b-', lw=1.5, label='SE(3)')
axes[1].set_xlabel('时间 (s)'); axes[1].set_ylabel('姿态误差(对数)')
axes[1].set_title('对数尺度对比'); axes[1].legend(); axes[1].grid(True, alpha=0.3)
plt.suptitle('第七章:欧拉角 vs SE(3) 控制 — 大角度翻转机动', fontsize=14)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control7.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
个人理解:Planning 就是外环的 Control,Control 就是内环的 Planning。
个人理解:Planning 基于模型预测系统对输入的响应,提前规划动作序列;Control 则负责在实际执行中弥补模型与现实的 mismatch(建模误差、扰动等)。两者并非对立——MPC 将 Planning 和 Control 融合在一起,在滑动时间窗口内同时做规划和控制。
个人理解:MPC 同时包含了轨迹规划和控制——它需要在预测区间内有完整的轨迹。如果只需要跟踪已有的 P.V.A.(位置、速度、加速度)参考轨迹,可以直接交给 SE(3) 控制器,无需 MPC。MPC 的价值在于能处理非线性约束和在线重规划,但代价是求解复杂度高。
状态向量:
输入向量:
约束条件:
个人理解:约束含义——状态约束限制了速度上下限(如
$|V_x^{(k)}| \leq V_{max}$ ),输入约束限制了 jerk 范围,$C^k$ 约束要求每一步的位置$[x^k, y^k, z^k]$ 在安全区域$\delta^k$ 内(即碰撞规避约束)。
个人理解:CMPC 中的线性化模型将状态间的动态关系用离散时间递推表达($\mathbf{x}^{(k+1)} = A\mathbf{x}^{(k)} + B\mathbf{u}^{(k)}$),包括位置-速度-加速度的积分关系和姿态动态。轨迹跟踪目标和安全约束都在 CMPC 的统一优化框架中处理。
个人理解:MPC 的核心思想是在每个时刻求解一个有限时域的优化问题,只执行第一步控制输入,然后滚动重复(Receding Horizon)。其优势在于可以显式处理约束(速度限制、位置约束、碰撞约束等),但计算量较大,需要在线求解优化问题。MPC 模糊了控制与规划的边界——它在每个控制周期内同时做局部轨迹规划和控制。
import numpy as np
import matplotlib.pyplot as plt
# ========== 简化 1D MPC 高度控制(滚动优化 + 推力约束) ==========
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows 自带黑体
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示为方框的问题
# --- 系统参数 ---
m = 0.5; g = 9.81; dt_ctrl = 0.05 # 控制周期 50ms
N_horizon = 20 # 预测步长
T_sim = 6.0; N_sim = int(T_sim / dt_ctrl)
z_target = 2.0 # 目标高度
# 推力约束(物理限制)
a_min = -g # 最小加速度(自由落体,推力=0)
a_max = 2.0 * g # 最大加速度(推力上限)
# MPC 代价权重
Q_z, Q_v, R_a = 10.0, 5.0, 0.1 # 位置、速度、控制代价
def solve_mpc(z0, v0, z_ref, N_h):
"""
求解 1D MPC:手动构造二次规划并用 numpy 求解
状态:[z, v],输入:a(加速度),离散模型:z+=v*dt, v+=a*dt
"""
# 构造预测矩阵:X = Sx*x0 + Su*U
Sx = np.zeros((2 * N_h, 2)) # 状态传播矩阵
Su = np.zeros((2 * N_h, N_h)) # 输入影响矩阵
A = np.array([[1, dt_ctrl], [0, 1]])
B = np.array([[0.5 * dt_ctrl**2], [dt_ctrl]])
# 逐步构造预测矩阵
A_pow = np.eye(2)
for k in range(N_h):
A_pow = A_pow @ A
Sx[2*k:2*k+2, :] = A_pow
for j in range(k + 1):
A_mid = np.linalg.matrix_power(A, k - j)
Su[2*k:2*k+2, j] = (A_mid @ B).flatten()
# 代价矩阵 Q_bar, R_bar
Q_bar = np.zeros((2 * N_h, 2 * N_h))
for k in range(N_h):
Q_bar[2*k, 2*k] = Q_z # 位置权重
Q_bar[2*k+1, 2*k+1] = Q_v # 速度权重
R_bar = R_a * np.eye(N_h)
# 参考轨迹向量
X_ref = np.zeros(2 * N_h)
for k in range(N_h):
X_ref[2*k] = z_ref
X_ref[2*k+1] = 0.0 # 期望速度为 0
x0 = np.array([z0, v0])
# 无约束最优解:U* = (Su'QSu + R)^{-1} Su'Q(Xref - Sx*x0)
H = Su.T @ Q_bar @ Su + R_bar
f = Su.T @ Q_bar @ (X_ref - Sx @ x0)
U_star = np.linalg.solve(H, f)
# 施加约束(简单裁剪)
U_star = np.clip(U_star, a_min, a_max)
return U_star
# --- 仿真主循环 ---
z, v = 0.0, 0.0 # 初始状态:地面静止
hist_z, hist_v, hist_a = [], [], []
hist_pred_z = [] # 记录每步的预测轨迹(用于可视化)
for i in range(N_sim):
U_opt = solve_mpc(z, v, z_target, N_horizon)
a_cmd = U_opt[0] # 只执行第一步(滚动优化)
# 记录预测轨迹
if i % 20 == 0:
pred_z = [z]
pz, pv = z, v
for k in range(N_horizon):
ak = np.clip(U_opt[k], a_min, a_max)
pv += ak * dt_ctrl
pz += pv * dt_ctrl
pred_z.append(pz)
hist_pred_z.append((i * dt_ctrl, pred_z))
# 状态更新
v += a_cmd * dt_ctrl
z += v * dt_ctrl
hist_z.append(z); hist_v.append(v); hist_a.append(a_cmd)
t_arr = np.arange(N_sim) * dt_ctrl
# --- 绘图 ---
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
# 高度响应 + 预测轨迹
ax = axes[0, 0]
ax.plot(t_arr, hist_z, 'b-', lw=2, label='实际高度')
ax.axhline(y=z_target, color='r', ls='--', lw=1.5, label=f'目标 z={z_target}m')
for (t0, pred) in hist_pred_z:
t_pred = t0 + np.arange(len(pred)) * dt_ctrl
ax.plot(t_pred, pred, 'g-', alpha=0.3, lw=0.8)
ax.plot([], [], 'g-', alpha=0.5, label='预测轨迹(滚动窗口)')
ax.set_xlabel('时间 (s)'); ax.set_ylabel('高度 (m)')
ax.set_title('MPC 高度控制响应'); ax.legend(); ax.grid(True, alpha=0.3)
# 速度
ax = axes[0, 1]
ax.plot(t_arr, hist_v, 'c-', lw=1.5)
ax.set_xlabel('时间 (s)'); ax.set_ylabel('速度 (m/s)')
ax.set_title('垂直速度'); ax.grid(True, alpha=0.3)
# 控制输入(加速度/推力)
ax = axes[1, 0]
ax.plot(t_arr, hist_a, 'm-', lw=1.5, label='MPC 输出 a')
ax.axhline(y=a_max, color='r', ls=':', label=f'最大加速度 {a_max:.1f}')
ax.axhline(y=a_min, color='b', ls=':', label=f'最小加速度 {a_min:.1f}')
ax.axhline(y=0, color='k', ls='-', alpha=0.2)
ax.set_xlabel('时间 (s)'); ax.set_ylabel('加速度 (m/s²)')
ax.set_title('控制输入(含约束裁剪)'); ax.legend(fontsize=8); ax.grid(True, alpha=0.3)
# 对应推力
ax = axes[1, 1]
thrust = [m * (a + g) for a in hist_a]
ax.plot(t_arr, thrust, 'g-', lw=1.5, label='推力 u₁ = m(a+g)')
ax.axhline(y=m * g, color='k', ls='--', alpha=0.4, label=f'悬停推力 {m*g:.2f}N')
ax.axhline(y=m * (a_max + g), color='r', ls=':', alpha=0.6, label='推力上限')
ax.axhline(y=0, color='b', ls=':', alpha=0.6, label='推力下限 (0N)')
ax.set_xlabel('时间 (s)'); ax.set_ylabel('推力 (N)')
ax.set_title('推力约束可视化'); ax.legend(fontsize=8); ax.grid(True, alpha=0.3)
plt.suptitle('第八章:1D MPC 高度控制 — 滚动优化 + 推力约束', fontsize=14)
plt.tight_layout()
import os; os.makedirs('assets', exist_ok=True)
plt.savefig('assets/control8.png', dpi=150, bbox_inches='tight')
plt.show()运行结果:
| 符号 | 含义 |
|---|---|
| 无人机质量 | |
| 重力加速度 | |
| 转动惯量矩阵 | |
| 绕 |
|
| 旋转矩阵(机体→世界) | |
| 滚转角、俯仰角、偏航角 | |
| 机体角速度分量 | |
| Roll/Pitch/Yaw 角速度 | |
| 第 |
|
| 第 |
|
| 总推力 | |
| 控制力矩 | |
| 电机到质心的距离 | |
| 推力/力矩系数 | |
| PID 增益 | |
| 机体/世界坐标系 Z 轴单位向量 | |
| 位置/速度跟踪误差 | |
| 姿态/角速度误差(SE(3)) | |
| 特殊欧几里得群 | |
| 特殊正交群 | |
| 平坦输出 |







