ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

python的机器人基础原理工业场景模拟第二篇:装配产线仿真,分解动力学方程,分别提取惯性项,科氏力项,重力项的数值曲线。

python的机器人基础原理工业场景模拟第二篇:装配产线仿真,分解动力学方程,分别提取惯性项,科氏力项,重力项的数值曲线。

《Python机器人基础原理》第二篇:装配产线仿真——分解动力学方程,提取惯性项/科氏力项/重力项数值曲线

1. 实际工业应用场景

周四下午2点,某3C产品装配产线。

一台2-DOF平面装配机械臂正在做节拍优化调试。连杆1长0.6 m、质量8 kg,连杆2长0.4 m、质量5 kg,末端夹持一个1.5 kg的手机中框。节拍要求2 s/循环,定位精度±0.5 mm。

工艺工程师小李盯着节拍曲线发愁。

"这条线理论节拍2秒,实际跑出来要2.4秒。瓶颈在关节1——加速段明显拖尾。"

旁边做仿真的小王调出力矩曲线:"李工,你看这个。总力矩曲线里,重力项占了一大半,但真正影响加速拖尾的是惯性项和科氏力项。你把加速段拉长,惯性项会跟着降下来,但重力项不降——这是两笔不同的账。"

小李追问:"那我怎么知道该优化哪一段?是减速?还是改轨迹?"

小王指着屏幕:"把总力矩拆开看。重力项是'房贷'——只要关节在这个位置,它就固定存在,跟你怎么加速无关。惯性项是'临时开支'——你想加速多快,它就多大。科氏力项是'意外支出'——只有两个关节同时动的时候才出现。你把这三条曲线叠在一起看,瓶颈在哪个项上一目了然。"

这就是本篇要解决的问题。

2. 痛点分析

课程模块 本篇痛点 解决层级

第4章 机器人动力学 → 4.3 动力学方程各项物理意义 总力矩曲线是一条线,工程师看不出瓶颈在惯性还是重力 离线仿真校核

第4章 机器人动力学 → 4.2 拉格朗日方程建模方法 需要把 τ = M·q̈ + C + G 分解为三个独立项分别计算 离线仿真校核

第5章 轨迹规划 → 5.2 轨迹平滑性与加速度约束 加速度曲线决定惯性项大小,进而影响峰值力矩 离线仿真校核

本篇解决 L1 离线仿真校核层。目标是在控制代码上线之前,把动力学方程 τ = M(q)·q̈ + C(q,q̇) + G(q) 分解为三个独立分量,分别输出数值曲线,让工程师看清"力矩花在哪了",从而针对性优化轨迹或调整机械结构。

不覆盖:逆动力学数值解法、牛顿-欧拉递推法、柔性动力学、关节柔性、控制算法、6-DOF空间机械臂、力控制、智能控制(本篇限定2-DOF平面构型讲清原理)。

3. 核心逻辑讲解(大白话)

物理直觉

动力学方程 τ = M·q̈ + C + G 就像一个三口之家的账单:

- 重力项 G(q) 是"房贷"——只要关节在这个位置,它就固定存在,跟你怎么动无关。臂抬得越高,这笔账越大。

- 惯性项 M(q)·q̈ 是"临时开支"——你想加速多快,它就多大。跟负载质量和臂长平方成正比。加速越猛,这笔账越高。

- 科氏力项 C(q,q̇) 是"意外支出"——只有两个关节同时动的时候才出现,单个关节匀速转的时候它为零。

大白话翻译:"把总力矩拆成三笔账:固定的房贷(重力)、临时的加速费(惯性)、意外的交叉费(科氏力),看哪笔是瓶颈。"

业务逻辑 → 代码映射

输入:机械臂构型 + 轨迹(q, dq, ddq)

↓

步骤1:sympy推导 T, V → 解析提取 M(q), C(q,dq), G(q)

↓

步骤2:沿轨迹逐点计算 M, C, G

↓

步骤3:惯性项 = M(q) · ddq (矩阵乘向量)

↓

步骤4:科氏力项 = C(q,dq) (直接读取)

↓

步骤5:重力项 = G(q) (直接读取)

↓

步骤6:校验 τ = 惯性项 + 科氏力项 + 重力项(数值一致性检查)

↓

输出:四条独立曲线 + 堆叠面积图 + 各项峰值/均值/占比统计

为什么选拉格朗日不选牛顿-欧拉

方法 特点 适合场景

拉格朗日方程 系统级建模,代码简洁,推导一次处处可用 离线仿真、教学、参数分析 ← 本篇选这个

牛顿-欧拉递推 从基座递推到末端再反向递推,计算量小,速度快 实时控制、嵌入式部署 → 后续篇目

力矩构成拆解

- 重力项 G(q):跟位置有关,跟速度、加速度无关。

- 惯性项 M(q)·q̈:跟加速度有关,加速越猛越大。

- 科氏力/离心力项 C(q,q̇):跟速度交叉有关,单关节匀速时为零。

关键公式文字翻译

惯性项:M₁₁·q̈₁ + M₁₂·q̈₂

翻译:关节1的惯性力矩 = 惯性矩阵第一行点乘加速度向量。M₁₂不为零意味着"关节2加速也会给关节1带来惯性负载"——这就是耦合。

科氏力项:Σⱼₖ Γᵢⱼₖ · q̇ⱼ · q̇ₖ

翻译:关节i的科氏力 = 三个Christoffel符号分别乘以对应速度对的乘积再求和。只有当 j≠k 时才是真正的科氏力,j=k 时是离心力。

重力项:∂V/∂qᵢ

翻译:关节i的重力矩 = 势能对该关节坐标的偏导数。物理意义是"把这个关节转动一小下,重力势能变化多少"。

4. OOP代码实现

技术栈

严格限定:numpy, scipy, matplotlib, sympy(符号推导+lambdify,仅用于建模阶段)

"requirements.txt"

numpy>=1.24.0

scipy>=1.10.0

matplotlib>=3.7.0

sympy>=1.12.0

项目结构树

robot_dynamics_decomposition/

├── run_dynamics.py

├── requirements.txt

├── dynamics/

│ ├── __init__.py

│ ├── models.py

│ ├── lagrangian.py

│ ├── simulator.py

│ └── visualize.py

├── tests/

│ ├── __init__.py

│ └── test_dynamics.py

└── results/

核心源码

"dynamics/models.py" — dataclass数据模型

"""连杆参数与机器人构型定义"""

from dataclasses import dataclass

from typing import List

@dataclass

class Link:

"""单连杆刚体参数(2-DOF平面臂)"""

name: str

mass: float # kg

length: float # m

com_offset: float # m, 质心距近端关节距离

inertia: float # kg·m², 绕质心z轴转动惯量

def __post_init__(self):

if self.mass <= 0:

raise ValueError(

f"{self.name}: mass must be > 0, got {self.mass}")

if self.length <= 0:

raise ValueError(

f"{self.name}: length must be > 0, got {self.length}")

if not (0 <= self.com_offset <= self.length):

raise ValueError(

f"{self.name}: com_offset must be in [0, {self.length}]")

@dataclass

class RobotParams:

"""2-DOF平面装配机械臂完整参数"""

links: List[Link]

payload_mass: float = 1.5 # kg(装配场景负载轻)

gravity: float = 9.81 # m/s²

dt: float = 0.01 # s

t_end: float = 2.0 # s(2秒节拍)

def __post_init__(self):

if len(self.links) != 2:

raise ValueError(

f"本篇限定2-DOF平面构型, links数量必须为2, got {len(self.links)}")

if self.payload_mass < 0:

raise ValueError("payload_mass must be >= 0")

"dynamics/lagrangian.py" — 拉格朗日建模算法类

"""

拉格朗日动力学建模 + 方程分解

核心:τ = M(q)·q̈ + C(q,q̇) + G(q),分别提取三项

"""

import numpy as np

import sympy as sp

from dynamics.models import RobotParams

class LagrangianDynamics:

"""2-DOF平面机械臂动力学建模与分解"""

def __init__(self, params: RobotParams):

self.params = params

self._build_symbolic()

self._lambdify()

def _build_symbolic(self):

"""符号推导:T, V → M, C, G"""

q1, q2 = sp.symbols('q1 q2', real=True)

dq1, dq2 = sp.symbols('dq1 dq2', real=True)

ddq1, ddq2 = sp.symbols('ddq1 ddq2', real=True)

g = self.params.gravity

l1 = self.params.links[0].length

l2 = self.params.links[1].length

m1 = self.params.links[0].mass

m2 = self.params.links[1].mass

lc1 = self.params.links[0].com_offset

lc2 = self.params.links[1].com_offset

I1 = self.params.links[0].inertia

I2 = self.params.links[1].inertia

mp = self.params.payload_mass

# 动能 T

T1 = 0.5 * m1 * (lc1 * dq1)**2 + 0.5 * I1 * dq1**2

T2 = (0.5 * m2 * (l1**2 * dq1**2

+ lc2**2 * (dq1 + dq2)**2

+ 2 * l1 * lc2 * dq1 * (dq1 + dq2) * sp.cos(q2))

+ 0.5 * I2 * (dq1 + dq2)**2)

Tp = 0.5 * mp * (l1**2 * dq1**2

+ l2**2 * (dq1 + dq2)**2

+ 2 * l1 * l2 * dq1 * (dq1 + dq2) * sp.cos(q2))

T = T1 + T2 + Tp

# 势能 V

V = (m1 * g * lc1 * sp.sin(q1)

+ m2 * g * (l1 * sp.sin(q1) + lc2 * sp.sin(q1 + q2))

+ mp * g * (l1 * sp.sin(q1) + l2 * sp.sin(q1 + q2)))

# 惯性矩阵 M(q) = ∂²T/∂dqᵢ∂dqⱼ

M11 = sp.diff(sp.diff(T, dq1), dq1)

M12 = sp.diff(sp.diff(T, dq1), dq2)

M21 = sp.diff(sp.diff(T, dq2), dq1)

M22 = sp.diff(sp.diff(T, dq2), dq2)

M = sp.Matrix([[M11, M12], [M21, M22]])

# 重力项 G(q) = ∂V/∂q

G1 = sp.diff(V, q1)

G2 = sp.diff(V, q2)

G = sp.Matrix([G1, G2])

# 科氏力项 C(q,dq) via Christoffel 符号

q = [q1, q2]

dq = [dq1, dq2]

C_exprs = []

for i in range(2):

C_i = 0

for j in range(2):

for k in range(2):

dMij_dqk = sp.diff(M[i, j], q[k])

dMik_dqj = sp.diff(M[i, k], q[j])

dMjk_dqi = sp.diff(M[j, k], q[i])

gamma = 0.5 * (dMij_dqk + dMik_dqj - dMjk_dqi)

C_i += gamma * dq[j] * dq[k]

C_exprs.append(C_i)

C = sp.Matrix(C_exprs)

# 总力矩 τ = M*ddq + C + G

ddq = sp.Matrix([ddq1, ddq2])

tau = M * ddq + C + G

self._q1, self._q2 = q1, q2

self._dq1, self._dq2 = dq1, dq2

self._ddq1, self._ddq2 = ddq1, ddq2

self._M, self._C, self._G, self._tau = M, C, G, tau

self._T_expr, self._V_expr = T, V

def _lambdify(self):

"""编译为numpy数值函数"""

vars_vec = (self._q1, self._q2, self._dq1, self._dq2,

self._ddq1, self._ddq2)

self._M_func = sp.lambdify(vars_vec, self._M, 'numpy')

self._C_func = sp.lambdify(vars_vec, self._C, 'numpy')

self._G_func = sp.lambdify((self._q1, self._q2), self._G, 'numpy')

self._tau_func = sp.lambdify(vars_vec, self._tau, 'numpy')

self._T_func = sp.lambdify(

(self._q1, self._q2, self._dq1, self._dq2), self._T_expr, 'numpy')

self._V_func = sp.lambdify(

(self._q1, self._q2), self._V_expr, 'numpy')

def get_M(self, q):

"""

惯性矩阵 M(q), 形状 (2,2), 单位 kg·m²

修复:签名只接受 q,M 只依赖位置不依赖速度

"""

return np.array(

self._M_func(q[0], q[1], 0.0, 0.0, 0.0, 0.0), dtype=float)

def get_C(self, q, dq):

"""科氏力/离心力项 C(q,dq), 形状 (2,), 单位 N·m"""

return np.array(

self._C_func(q[0], q[1], dq[0], dq[1], 0.0, 0.0),

dtype=float).reshape(2)

def get_G(self, q):

"""重力项 G(q), 形状 (2,), 单位 N·m"""

return np.array(self._G_func(q[0], q[1]), dtype=float).reshape(2)

def get_inertia_term(self, q, dq, ddq):

"""惯性项 M(q)·ddq, 形状 (2,), 单位 N·m"""

M = self.get_M(q)

return M @ np.asarray(ddq, dtype=float).reshape(2)

def inverse_dynamics(self, q, dq, ddq):

"""总力矩 τ = M·ddq + C + G, 形状 (2,), 单位 N·m"""

tau = self._tau_func(q[0], q[1], dq[0], dq[1], ddq[0], ddq[1])

return np.array(tau, dtype=float).flatten()

def forward_dynamics(self, q, dq, tau):

"""正向动力学:ddq = M⁻¹(τ - C - G)"""

M = self.get_M(q)

C = self.get_C(q, dq)

G = self.get_G(q)

rhs = np.asarray(tau, dtype=float).reshape(2) - C - G

try:

ddq = np.linalg.solve(M, rhs)

except np.linalg.LinAlgError:

ddq = np.linalg.pinv(M) @ rhs

return ddq

def compute_energy(self, q, dq):

"""返回 (动能, 势能, 总能量) 单位 J"""

T = float(self._T_func(q[0], q[1], dq[0], dq[1]))

V = float(self._V_func(q[0], q[1]))

return T, V, T + V

"dynamics/simulator.py" — 仿真计算类

"""

仿真引擎:轨迹生成 + 动力学三项分解 + 正向积分验证

"""

import numpy as np

from scipy.integrate import odeint

from dynamics.models import RobotParams

from dynamics.lagrangian import LagrangianDynamics

class Simulator:

"""轨迹生成 + 沿轨迹分解动力学方程"""

def __init__(self, params: RobotParams, dynamics: LagrangianDynamics):

self.params = params

self.dynamics = dynamics

self.t = np.arange(0, params.t_end + params.dt, params.dt)

self.N = len(self.t)

def generate_trajectory(self):

"""

生成平滑关节空间轨迹(2s周期装配节拍)

关节1:±0.6 rad, 峰值角加速度 ≈ 5.9 rad/s²

关节2:±0.4 rad, 峰值角加速度 ≈ 3.9 rad/s²

"""

period = 2.0

q1 = 0.6 * np.sin(2 * np.pi * self.t / period)

dq1 = 0.6 * (2 * np.pi / period) * np.cos(2 * np.pi * self.t / period)

ddq1 = -0.6 * (2 * np.pi / period)**2 * np.sin(2 * np.pi * self.t / period)

q2 = 0.4 * np.sin(2 * np.pi * self.t / period + np.pi / 3)

dq2 = 0.4 * (2 * np.pi / period) * np.cos(2 * np.pi * self.t / period + np.pi / 3)

ddq2 = -0.4 * (2 * np.pi / period)**2 * np.sin(2 * np.pi * self.t / period + np.pi / 3)

return (np.column_stack([q1, q2]),

np.column_stack([dq1, dq2]),

np.column_stack([ddq1, ddq2]))

def decompose_trajectory(self, q_traj, dq_traj, ddq_traj):

"""

沿轨迹逐点分解动力学方程为三项

返回:inertia (N,2), coriolis (N,2), gravity (N,2), total (N,2), energies (N,3)

"""

inertia = np.zeros((self.N, 2))

coriolis = np.zeros((self.N, 2))

gravity = np.zeros((self.N, 2))

total = np.zeros((self.N, 2))

energies = np.zeros((self.N, 3))

for i in range(self.N):

q, dq, ddq = q_traj[i], dq_traj[i], ddq_traj[i]

inertia[i] = self.dynamics.get_inertia_term(q, dq, ddq)

coriolis[i] = self.dynamics.get_C(q, dq)

gravity[i] = self.dynamics.get_G(q)

total[i] = self.dynamics.inverse_dynamics(q, dq, ddq)

energies[i] = self.dynamics.compute_energy(q, dq)

return inertia, coriolis, gravity, total, energies

def simulate_forward(self, tau_func, q0, dq0):

"""正向动力学积分验证"""

def rhs(state, t):

q1, q2, dq1, dq2 = state

q = np.array([q1, q2])

dq = np.array([dq1, dq2])

tau = np.asarray(tau_func(t), dtype=float).flatten()

ddq = self.dynamics.forward_dynamics(q, dq, tau)

return np.concatenate([dq, ddq])

state0 = np.concatenate([q0, dq0])

return odeint(rhs, state0, self.t, rtol=1e-10, atol=1e-12)

"dynamics/visualize.py" — 可视化模块

"""四张核心图表 + 三项独立曲线"""

import numpy as np

import matplotlib.pyplot as plt

from dynamics.models import RobotParams

plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']

plt.rcParams['axes.unicode_minus'] = False

class Visualizer:

def __init__(self, params: RobotParams, results_dir="results"):

self.params = params

self.results_dir = results_dir

self.N = int(params.t_end / params.dt) + 1

import os

os.makedirs(results_dir, exist_ok=True)

def plot_torque_curves(self, t, total):

"""① 各关节总力矩 vs 时间"""

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 7), sharex=True)

ax1.plot(t, total[:, 0], 'b-', linewidth=2)

ax1.axhline(y=np.max(np.abs(total[:, 0])), color='r',

linestyle='--', alpha=0.5,

label=f'Peak {np.max(np.abs(total[:, 0])):.2f} N·m')

ax1.set_ylabel('Torque tau1 (N·m)')

ax1.set_title('Joint 1 Total Torque vs Time')

ax1.legend()

ax1.grid(True, alpha=0.3)

ax2.plot(t, total[:, 1], 'r-', linewidth=2)

ax2.axhline(y=np.max(np.abs(total[:, 1])), color='b',

linestyle='--', alpha=0.5,

label=f'Peak {np.max(np.abs(total[:, 1])):.2f} N·m')

ax2.set_xlabel('Time (s)')

ax2.set_ylabel('Torque tau2 (N·m)')

ax2.set_title('Joint 2 Total Torque vs Time')

ax2.legend()

ax2.grid(True, alpha=0.3)

plt.tight_layout()

path = f"{self.results_dir}/01_torque_vs_time.png"

plt.savefig(path, dpi=150)

plt.close()

return path

def plot_decomposition(self, t, inertia, coriolis, gravity, total):

"""② 力矩分量堆叠面积图(核心:三笔账拆开看)"""

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 7), sharex=True)

ax1.fill_between(t, 0, gravity[:, 0], alpha=0.6,

color='green', label='Gravity')

ax1.fill_between(t, gravity[:, 0], gravity[:, 0] + coriolis[:, 0],

alpha=0.6, color='orange', label='Coriolis/Centrifugal')

ax1.fill_between(t, gravity[:, 0] + coriolis[:, 0],

gravity[:, 0] + coriolis[:, 0] + inertia[:, 0],

alpha=0.6, color='purple', label='Inertia')

ax1.plot(t, total[:, 0], 'k-', linewidth=1.5)

ax1.set_title('Joint 1 Torque Decomposition: G + C + M·ddq')

ax1.legend(fontsize=8)

ax1.grid(True, alpha=0.3)

ax2.fill_between(t, 0, gravity[:, 1], alpha=0.6,

color='green', label='Gravity')

ax2.fill_between(t, gravity[:, 1], gravity[:, 1] + coriolis[:, 1],

alpha=0.6, color='orange', label='Coriolis/Centrifugal')

ax2.fill_between(t, gravity[:, 1] + coriolis[:, 1],

gravity[:, 1] + coriolis[:, 1] + inertia[:, 1],

alpha=0.6, color='purple', label='Inertia')

ax2.plot(t, total[:, 1], 'k-', linewidth=1.5)

ax2.set_xlabel('Time (s)')

ax2.set_title('Joint 2 Torque Decomposition: G + C + M·ddq')

ax2.legend(fontsize=8)

ax2.grid(True, alpha=0.3)

plt.tight_layout()

path = f"{self.results_dir}/02_torque_decomposition.png"

plt.savefig(path, dpi=150)

plt.close()

return path

def plot_individual_terms(self, t, inertia, coriolis, gravity):

"""②-b 三项独立曲线对比(看清每项波形和量级)"""

fig, axes = plt.subplots(2, 3, figsize=(15, 7), sharex=True)

terms = [('Inertia M·ddq', inertia, 'purple'),

('Coriolis/Centrifugal C', coriolis, 'orange'),

('Gravity G', gravity, 'green')]

for col, (title, data, color) in enumerate(terms):

axes[0, col].plot(t, data[:, 0], color=color, linewidth=2)

axes[0, col].set_title(f'Joint 1 — {title}')

axes[0, col].grid(True, alpha=0.3)

axes[0, col].axhline(y=0, color='k', linewidth=0.5)

axes[1, col].plot(t, data[:, 1], color=color, linewidth=2)

axes[1, col].set_title(f'Joint 2 — {title}')

axes[1, col].set_xlabel('Time (s)')

axes[1, col].grid(True, alpha=0.3)

axes[1, col].axhline(y=0, color='k', linewidth=0.5)

plt.tight_layout()

path = f"{self.results_dir}/02b_individual_terms.png"

plt.savefig(path, dpi=150)

plt.close()

return path

def plot_arm_keyframes(self, q_traj):

"""③ 机械臂运动关键帧"""

idxs = [0, self.N // 4, self.N // 2, 3 * self.N // 4, -1]

l1 = self.params.links[0].length

l2 = self.params.links[1].length

fig, axes = plt.subplots(1, 5, figsize=(20, 4))

for ax, i in zip(axes, idxs):

q1, q2 = q_traj[i, 0], q_traj[i, 1]

x1, y1 = l1 * np.cos(q1), l1 * np.sin(q1)

x2, y2 = x1 + l2 * np.cos(q1 + q2), y1 + l2 * np.sin(q1 + q2)

ax.plot([0, x1], [0, y1], 'b-', linewidth=4)

ax.plot([x1, x2], [y1, y2], 'r-', linewidth=4)

ax.plot(x1, y1, 'go', markersize=6)

ax.plot(x2, y2, 'ro', markersize=6)

ax.set_xlim(-1.2, 1.2)

ax.set_ylim(-1.2, 1.2)

ax.set_aspect('equal')

ax.set_title(f'Frame {i}')

ax.grid(True, alpha=0.3)

plt.tight_layout()

path = f"{self.results_dir}/03_arm_keyframes.png"

plt.savefig(path, dpi=150)

plt.close()

return path

def plot_energy(self, t, energies):

"""④ 能量变化曲线"""

fig, ax = plt.subplots(figsize=(10, 5))

ax.plot(t, energies[:, 0], 'b-', label='Kinetic T (J)')

ax.plot(t, energies[:, 1], 'r-', label='Potential V (J)')

ax.plot(t, energies[:, 2], 'k--', linewidth=2, label='Total E (J)')

ax.set_xlabel('Time (s)')

ax.set_ylabel('Energy (J)')

ax.set_title('Energy Variation (Driven Trajectory)')

ax.legend()

ax.grid(True, alpha=0.3)

plt.tight_layout()

path = f"{self.results_dir}/04_energy_curves.png"

plt.savefig(path, dpi=150)

plt.close()

return path

利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进