# 二连杆机器人动力学仿真实战:从自由下落到重力补偿(附Python代码)
最近在辅导几位刚入行的机器人工程师时,我发现一个普遍现象:很多朋友啃完了厚厚的理论教材,对拉格朗日方程、牛顿-欧拉法倒背如流,但一旦打开代码编辑器,面对空白的Python脚本,却不知如何将那些矩阵和公式变成屏幕上会动的机械臂。这中间的鸿沟,往往就是“实战”经验的缺失。动力学仿真不是纸上谈兵,它关乎你设计的控制器能否在真实世界中稳定运行,也关乎你能否快速验证一个灵光一现的控制算法。今天,我们就抛开繁复的推导,直接动手,用Python从零构建一个二连杆机器人的动力学仿真世界,亲眼看看它在重力下如何坠落,遇到“弹簧床”如何反弹,又如何通过计算巧妙地悬浮在半空。
这篇文章是为那些已经了解基础运动学、正开始探索动力学奥秘的工程师和学生们准备的。我们将使用最流行的科学计算库,编写清晰、可复现的代码,并着重于**可视化**和**物理直觉**的建立。你会发现,理解动力学,最好的方式就是“看”它发生。
## 1. 环境搭建与核心模型构建
在开始编写任何动画代码之前,我们必须先搭建一个稳固的数学基石——二连杆机器人的动力学模型。别被“模型”二字吓到,我们这里采用一种计算高效且易于编程实现的方法:递归牛顿-欧拉法。与拉格朗日法相比,它更适用于计算机迭代计算,尤其当你想扩展到更多自由度时,其优势会更加明显。
首先,确保你的Python环境已经安装了必要的库。我们主要依赖 `numpy` 进行矩阵运算,`matplotlib` 进行可视化,并使用其动画模块让机器人动起来。
```bash
pip install numpy matplotlib
```
接下来,我们定义机器人的物理参数。一个二连杆机器人由两个刚性杆通过旋转关节连接而成。我们需要为每个连杆定义三个核心物理属性:
* **质量 (m)**: 连杆的惯性来源。
* **长度 (l)**: 从上一个关节到当前连杆末端的距离。
* **转动惯量 (I)**: 绕连杆质心的转动惯性,对于细杆,通常近似为 `(m * l**2) / 12`。
为了代码清晰,我们创建一个Python类来封装整个机器人模型。这个类将存储参数,并包含计算动力学所需的所有方法。
```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
class TwoLinkRobot:
def __init__(self):
# 连杆1参数
self.m1 = 1.0 # 质量 (kg)
self.l1 = 1.0 # 长度 (m)
self.I1 = (self.m1 * self.l1**2) / 12.0 # 绕质心的转动惯量 (kg*m^2)
self.c1 = self.l1 / 2.0 # 质心位置 (距关节距离)
# 连杆2参数
self.m2 = 1.0
self.l2 = 1.0
self.I2 = (self.m2 * self.l2**2) / 12.0
self.c2 = self.l2 / 2.0
# 重力加速度 (m/s^2)
self.g = 9.81
# 机器人状态:关节角度和角速度 [q1, q2, dq1, dq2]
self.state = np.array([0.0, 0.0, 0.0, 0.0])
def forward_kinematics(self, q):
"""计算末端执行器位置 (x, y)"""
q1, q2 = q
x = self.l1 * np.cos(q1) + self.l2 * np.cos(q1 + q2)
y = self.l1 * np.sin(q1) + self.l2 * np.sin(q1 + q2)
return np.array([x, y])
```
这个 `TwoLinkRobot` 类是我们的仿真核心。`forward_kinematics` 函数根据关节角度计算末端位置,这是后续所有可视化工作的基础。有了这个骨架,我们就可以开始注入“灵魂”——动力学计算了。
## 2. 动力学计算与仿真循环
动力学仿真的核心是求解运动方程。对于我们的二连杆机器人,其运动方程可以一般性地表示为:
**M(q) * ddq + C(q, dq) * dq + G(q) = τ**
其中:
* `M(q)` 是 **质量矩阵**,它依赖于机器人的构型(关节角度 `q`),反映了系统的惯性。
* `C(q, dq)` 是 **科里奥利力和离心力矩阵**,由关节运动速度 `dq` 引起。
* `G(q)` 是 **重力向量**,由重力势能产生。
* `τ` 是作用在关节上的 **扭矩向量**。
* `ddq` 是我们要求解的 **关节角加速度**。
我们的目标是,在已知当前状态 `(q, dq)` 和施加的扭矩 `τ` 的情况下,计算出角加速度 `ddq`,然后通过积分来更新机器人的状态。这里我们采用牛顿-欧拉法的逆向递归(计算连杆间的力和力矩)和正向递归(计算加速度)来计算这些项。为了文章简洁,我们直接给出在 `TwoLinkRobot` 类中实现的关键函数:
```python
class TwoLinkRobot:
# ... 初始化代码同上 ...
def dynamics(self, state, tau):
"""
计算给定状态和扭矩下的角加速度 (ddq)。
参数:
state: [q1, q2, dq1, dq2]
tau: [tau1, tau2] 关节扭矩
返回:
dstate: [dq1, dq2, ddq1, ddq2] 状态导数
"""
q1, q2, dq1, dq2 = state
tau1, tau2 = tau
# 这里省略了详细的M, C, G矩阵推导和计算代码
# 实际代码中,需要根据机器人参数和当前状态,展开动力学方程的各项
# 下面是一个高度简化的示意,实际计算涉及大量三角函数和矩阵运算
m1, m2, l1, l2, c1, c2, I1, I2, g = self.m1, self.m2, self.l1, self.l2, self.c1, self.c2, self.I1, self.I2, self.g
# 示例:计算质量矩阵 M 的某些项(非完整代码)
M11 = I1 + I2 + m1*c1**2 + m2*(l1**2 + c2**2 + 2*l1*c2*np.cos(q2))
M12 = I2 + m2*(c2**2 + l1*c2*np.cos(q2))
M21 = M12
M22 = I2 + m2*c2**2
M = np.array([[M11, M12], [M21, M22]])
# 示例:计算科里奥利力/离心力向量(非完整代码)
h = -m2 * l1 * c2 * np.sin(q2)
C = np.array([[h*dq2, h*dq2 + h*dq1],
[-h*dq1, 0]])
Coriolis = C.dot([dq1, dq2])
# 示例:计算重力向量 G(非完整代码)
G1 = (m1*c1 + m2*l1)*g*np.cos(q1) + m2*c2*g*np.cos(q1+q2)
G2 = m2*c2*g*np.cos(q1+q2)
G = np.array([G1, G2])
# 计算角加速度: ddq = M^{-1} * (tau - C - G)
# 注意:这里的 C 是矩阵,实际计算中 C*dq 得到向量
# 为简化,假设 Coriolis 已是计算好的向量
torque_input = np.array([tau1, tau2])
ddq = np.linalg.inv(M).dot(torque_input - Coriolis - G)
# 返回状态导数
dstate = np.array([dq1, dq2, ddq[0], ddq[1]])
return dstate
```
> **注意**:上面的 `dynamics` 函数中的矩阵计算是高度简化的示意,旨在展示结构。一个完整、正确的实现需要严格按照牛顿-欧拉递归公式或拉格朗日方程展开所有项。在实际项目代码中,这部分通常是最核心且稍显复杂的模块。
有了动力学计算函数,我们就可以构建仿真主循环了。这个循环在每个时间步长 `dt` 内:
1. 根据当前状态 `state` 和当前施加的扭矩 `tau`,调用 `dynamics` 函数计算状态导数 `dstate`。
2. 使用数值积分方法(如欧拉法或龙格-库塔法)更新状态:`state = state + dstate * dt`。
3. 更新时间和任何外部环境(如后续要加入的弹簧床)。
4. 将新的机器人姿态绘制出来。
```python
def simulate(self, total_time=5.0, dt=0.01, control_law=None):
"""
运行仿真循环。
control_law: 一个函数,输入(state, t),返回控制扭矩tau。
如果为None,则扭矩始终为零(自由下落)。
"""
n_steps = int(total_time / dt)
time_points = np.linspace(0, total_time, n_steps)
# 初始化记录数组
history_q1 = []
history_q2 = []
history_pos = []
state = self.state.copy()
for i, t in enumerate(time_points):
# 记录状态
history_q1.append(state[0])
history_q2.append(state[1])
history_pos.append(self.forward_kinematics(state[:2]))
# 计算控制扭矩(如果提供了控制律)
if control_law is None:
tau = np.array([0.0, 0.0]) # 自由下落,无扭矩
else:
tau = control_law(state, t)
# 计算动力学
dstate = self.dynamics(state, tau)
# 前向欧拉积分(简单,但精度一般。对于复杂系统建议用RK4)
state = state + dstate * dt
return time_points, np.array(history_q1), np.array(history_q2), np.array(history_pos)
```
这个 `simulate` 函数是仿真的引擎。`control_law` 参数至关重要,它决定了机器人的行为。传入 `None`,就是自由下落;传入一个计算重力补偿扭矩的函数,机器人就能悬停;传入一个更复杂的控制器,就能跟踪轨迹。接下来,我们就用它来创造几个有趣的场景。
## 3. 场景一:自由下落与能量验证
让我们先看一个最简单的场景:将机器人摆成一个初始姿势(例如,两个连杆都水平伸展),然后释放它,不施加任何关节扭矩(`tau = [0, 0]`)。在重力作用下,机器人将开始运动。
运行仿真后,我们可以绘制关节角度随时间变化的曲线,以及机器人运动的动画。但更有趣的是,我们可以验证仿真的**物理正确性**。在一个保守力场(如重力)中,且无外部扭矩做功时,系统的机械能(动能+势能)应该近似守恒(数值积分会引入微小误差)。这是检验我们动力学模型和仿真代码是否正确的一个重要手段。
我们在 `TwoLinkRobot` 类中添加一个计算总机械能的方法:
```python
def total_energy(self, state):
"""计算系统总机械能(动能+势能)"""
q1, q2, dq1, dq2 = state
# 计算动能 (1/2 * dq^T * M(q) * dq)
# 需要用到之前动力学计算中的质量矩阵M
M = self._compute_mass_matrix([q1, q2]) # 假设有一个计算M的私有方法
kinetic = 0.5 * np.dot([dq1, dq2], M.dot([dq1, dq2]))
# 计算势能(以y=0为参考面)
# 连杆1质心高度
y1 = self.c1 * np.sin(q1)
# 连杆2质心高度
y2 = self.l1 * np.sin(q1) + self.c2 * np.sin(q1 + q2)
potential = self.m1 * self.g * y1 + self.m2 * self.g * y2
return kinetic + potential
```
在自由下落仿真过程中,记录每一步的总能量。绘制能量-时间图,如果它是一条围绕某个均值轻微波动的水平线,而不是持续下降或上升,那就说明我们的模型在能量上是基本守恒的,仿真可信度很高。
| 仿真参数 | 值 | 说明 |
| :--- | :--- | :--- |
| 初始角度 (q1, q2) | (0.0, 0.0) rad | 两连杆水平向右 |
| 初始角速度 | (0.0, 0.0) rad/s | 静止释放 |
| 仿真时长 | 5.0 s | |
| 时间步长 (dt) | 0.001 s | 步长越小,精度越高,计算越慢 |
| 关节扭矩 (τ1, τ2) | (0.0, 0.0) N·m | 自由下落 |
运行这个仿真,你会看到机器人像一根松弛的双节棍一样,在重力作用下开始旋转、摆动。动画能直观展示运动,而能量验证图则从物理原理层面给你信心。这是所有后续高级仿真和控制的基础。
## 4. 场景二:引入交互——模拟“跳床”效应
单纯的自由下落还不够。现实中,机器人会与环境交互,比如触地、抓取、碰撞。我们在仿真中引入一个简单的交互环境:一个位于 `y = -1.2` 米处的虚拟“跳床”(实际上是一个线性弹簧阻尼系统)。当机器人末端或任何一个连杆的某点低于这个高度时,就会受到一个向上的弹力。
这个力的计算需要被整合到动力学方程中。一种方法是将环境作用力折算到关节空间,作为额外的扭矩 `τ_env` 加到控制扭矩 `τ` 上。对于末端点接触,我们可以利用机器人的雅可比矩阵将末端受到的力映射到关节扭矩:`τ_env = J(q)^T * F_env`,其中 `F_env` 是末端受到的环境力。
为了简化并增加视觉效果,我们假设当连杆1的中点(质心)位置 `yc1` 低于地面时,会受到一个弹簧-阻尼力:
```python
def simulate_with_spring_bed(robot, total_time=5.0, dt=0.01):
"""包含弹簧床交互的仿真"""
# 弹簧床参数
ground_height = -1.2
spring_k = 500.0 # 弹簧刚度 (N/m)
damping_b = 20.0 # 阻尼系数 (N·s/m)
n_steps = int(total_time / dt)
state = robot.state.copy()
history_contact_force = []
for i in range(n_steps):
q1, q2, dq1, dq2 = state
# 计算连杆1质心的y坐标
yc1 = robot.c1 * np.sin(q1)
# 计算连杆2质心的y坐标(可选,增加交互点)
yc2 = robot.l1 * np.sin(q1) + robot.c2 * np.sin(q1 + q2)
tau_env1, tau_env2 = 0.0, 0.0
contact_force = 0.0
# 检查连杆1质心是否接触“跳床”
if yc1 < ground_height:
penetration = ground_height - yc1
# 接触点速度(垂直分量)
v_contact = robot.c1 * np.cos(q1) * dq1 # dyc1/dt
# 弹簧-阻尼力 (向上为正)
F_spring = spring_k * penetration
F_damping = damping_b * (-v_contact) # 阻尼力与速度方向相反
F_env = F_spring - F_damping
if F_env < 0: F_env = 0 # 只产生向上的力
contact_force = F_env
# 将力映射到关节1扭矩(简化处理,假设力垂直作用于质心)
# 更精确的做法需计算力对关节的力矩臂
tau_env1 = F_env * robot.c1 * np.cos(q1) # 近似力矩
# 将环境扭矩加入控制输入
tau = np.array([0.0, 0.0]) + np.array([tau_env1, tau_env2])
# 记录接触力
history_contact_force.append(contact_force)
# 计算动力学并积分(同上)
dstate = robot.dynamics(state, tau)
state = state + dstate * dt
# ... 返回记录的数据 ...
```
在这个仿真中,机器人自由下落后会撞击“跳床”,被弹起,可能再落下、再弹起,能量在机器人本体和弹簧系统间转换和耗散(阻尼导致)。通过绘制接触力随时间变化的曲线,你可以清晰地看到冲击和振荡的过程。这个场景生动地演示了如何将**环境交互模型**集成到动力学仿真框架中,这是迈向更复杂仿真(如接触、摩擦、抓取)的关键一步。
## 5. 场景三:重力补偿与静态平衡控制
现在,我们来到体现控制思想的部分:重力补偿。我们的目标是让机器人**静止地保持**在某个期望的姿势 `q_desired = [q1_d, q2_d]`,而不掉下来。在自由下落场景中,重力会把机器人拉向地面。为了抗衡重力,我们需要在关节处施加恰好抵消重力效应的扭矩,即重力补偿扭矩 `τ_gravity`。
重力向量 `G(q)` 已经在我们的动力学方程中定义了。神奇的是,**重力补偿扭矩正是这个重力向量的负值**:`τ_gravity = -G(q)`。当机器人处于期望位置 `q_d` 时,我们计算 `G(q_d)`,然后施加 `-G(q_d)` 的扭矩,理论上机器人就能在无运动状态下平衡。
让我们在仿真中实现它。我们需要修改控制律函数 `control_law`,使其计算重力补偿扭矩:
```python
def gravity_compensation_control(state, t, robot, desired_q):
"""
重力补偿控制器。
参数:
state: 当前状态 [q1, q2, dq1, dq2]
t: 当前时间(本例中未使用)
robot: 机器人实例,用于调用动力学参数
desired_q: 期望的关节角度 [q1_d, q2_d]
返回:
tau: 计算出的关节扭矩
"""
q1, q2, _, _ = state
q1_d, q2_d = desired_q
# 这里需要计算在 desired_q 位置下的重力向量 G。
# 我们假设 robot 类有一个方法 compute_gravity_vector(q)
G = robot.compute_gravity_vector(desired_q)
# 重力补偿扭矩
tau_comp = -G
# 可以额外添加一个简单的PD反馈来控制位置,以消除模型误差和积分漂移
Kp = 10.0 # 比例增益
Kd = 2.0 # 微分增益
q_error = np.array([q1_d - q1, q2_d - q2])
dq_error = np.array([0.0 - state[2], 0.0 - state[3]]) # 期望速度为零
tau_feedback = Kp * q_error + Kd * dq_error
# 总扭矩 = 前馈重力补偿 + 反馈修正
tau = tau_comp + tau_feedback
return tau
```
然后,在调用 `simulate` 函数时,将这个控制器传入:
```python
desired_pose = [np.pi/4, -np.pi/6] # 期望保持 45° 和 -30°
def control_func(state, t):
return gravity_compensation_control(state, t, my_robot, desired_pose)
time, q1_hist, q2_hist, pos_hist = my_robot.simulate(control_law=control_func)
```
运行这个仿真,你会看到机器人不再坠落,而是迅速调整并稳定在期望的姿势附近。绘制关节角度曲线,它们应该收敛到 `desired_pose` 附近的一个小范围内波动。这个简单的例子揭示了高级机器人控制中的一个核心思想:**前馈补偿**。通过模型计算出已知的干扰力(这里是重力)并主动抵消它,可以极大地减轻反馈控制器的负担,让系统响应更快、更平稳。
> **提示**:纯重力补偿 (`tau = -G(q_d)`) 是开环控制,对模型精度要求极高。在实际中,由于模型误差和外部扰动,几乎总是需要结合一个闭环的PD或PID反馈控制器(如上面代码中的 `tau_feedback`)来维持稳定和精度。这种“前馈+反馈”的结构在工业机器人控制中非常普遍。
通过这三个循序渐进的场景——从被动的自由下落,到与环境交互的弹跳,再到主动的重力补偿平衡——我们完成了一个完整的、从动力学建模到基础控制的小型项目。代码虽然精简,但框架是通用的。你可以在此基础上,尝试改变机器人的参数(如做成一个长臂、一个短臂,或质量不同),修改控制律(尝试做轨迹跟踪),或者增加更复杂的环境模型。动手去改参数、看结果、分析现象,是理解机器人动力学最有效的方式。