python的机器人基础原理工业场景模拟第一百二十篇:机械臂仿真,模拟采样周期变化,改变控制步长,观察闭环系统稳定性变化。
《Python机器人基础原理》第一百二十篇:机械臂仿真——模拟采样周期变化,改变控制步长,观察闭环系统稳定性变化
声明:本篇全部为课程离线仿真代码,不能直接用于真实机器人控制器,规避工程误导风险。
1. 实际工业应用场景
周三下午,电控实验室。小赵把新写好的PD控制算法烧进控制器,接上2R机械臂做空载测试。奇怪的事情发生了——Kp=300、Kd=45,这组参数在Simulink里仿真跑得稳稳当当,末端误差不到20 mm。可一上实机,机械臂到了轨迹中段就开始高频抖动,像得了帕金森一样。
"你控制器周期多少?"老郑放下手里的示波器探头。
"我设的1 ms,跟仿真一样。"
"你确定实机控制循环真的1 ms跑完一次?"老郑调出任务管理器,"你看这个——CPU占用率飙到90%的时候,你的控制任务被操作系统抢了三次,实际间隔在0.8 ms到3.2 ms之间跳。你的PD是在变步长下跑的。"
小赵愣住了:"变步长?仿真里我用的固定1 ms啊……"
"对。仿真里时间是理想的,实机采样周期会因为中断延迟、任务调度、通信抖动而变化。你的控制器稳定性是建立在'每一步间隔完全相同'的假设上的——这个假设在产线上根本不成立。"
构型约定:竖直平面内2R机械臂。连杆参数:L1 a₁=0.55 m / m₁=8.0 kg / I₁=0.60 kg·m² / com₁=0.28 m;L2 a₂=0.45 m / m₂=10.0 kg(含5 kg负载)/ I₂=0.35 kg·m² / com₂=0.25 m。末端从(0.75, 0.55)到(0.30, 0.55)水平直线搬运,5次多项式5.0 s平滑轨迹。PD参数:Kp=[300, 225] N·m/rad,Kd=[45, 33.75] N·m·s/rad。力矩限幅±150/±120 N·m。仿真6.0 s。测试4种控制步长:dt∈{0.001, 0.002, 0.005, 0.010} s。技术栈:numpy + scipy + matplotlib。
⚠️ 声明:本篇全部代码为课程离线仿真,运行在普通PC上,用于验证采样周期对闭环稳定性的影响。代码中不包含任何真实机器人控制器实时运算或安全逻辑。不可直接用于产线设备。
2. 痛点分析
课程模块 本篇对应的工程痛点
第5章 机器人关节驱动与控制 → 离散时间控制 仿真用固定步长,实机采样周期受中断延迟/调度抖动影响,变步长可能导致失稳
第4章 机器人动力学建模 → 刚体动力学 重力矩约120 N·m,步长变大时PD离散化误差累积,重力补偿不足导致下垂
第6章 轨迹规划与控制 → 跟踪性能 步长变化直接影响位置反馈的新鲜度,大步长下高频抖动
本篇代码解决 L2 离线仿真校核层。输入:2R连杆参数、笛卡尔航点、PD增益、多组控制步长。处理:①5次多项式生成平滑轨迹→IK→关节样条;②4种步长分别仿真(RK4积分步长=控制步长,ZOH离散控制器);③计算RMSE和峰值力矩。输出:4张对比图表+稳定性结论。
不覆盖:实时操作系统调度模型、通信延迟、多速率采样。以下第7节讨论的传感器噪声、调度抖动等属于延伸方向,不在本篇代码实现范围内。
3. 核心逻辑讲解
物理直觉
想象你蒙着眼睛推一辆小车沿直线走。每隔1秒有人告诉你一次"你现在偏左了10 cm",你可以及时调整方向。如果变成每隔5秒才告诉你一次呢?等你收到反馈时,小车已经偏出去半米了——你猛打方向修正,结果矫枉过正,又偏到另一边去了。这就是离散化导致的相位滞后:反馈越不频繁,你的"修正动作"越容易跟实际偏差错位,最终系统开始振荡甚至发散。
控制步长dt本质上是控制器"看到"世界的时间分辨率。dt越小,控制器对偏差的响应越及时,系统越稳定;dt越大,相位滞后越严重,当dt超过某个临界值时,PD的"阻尼"效果变成了"激励振荡"。
业务逻辑 → 代码映射
输入:连杆参数 + 轨迹 + PD增益 + 步长列表[0.001, 0.002, 0.005, 0.010]
↓
【步骤1:轨迹生成(只做一次)】
5次多项式 → p_des(t) → IK → q_des(t),构建关节样条
↓
【步骤2:对每个步长 dt 运行仿真】
① 每个控制周期 dt 内:
- 在周期起始时刻采样当前状态 q[k], qd[k]
- 采样参考值 q_des[k], qd_des[k](ZOH:整个周期内恒定)
- 计算一次 τ[k] = PD(q[k], qd[k], q_des[k], qd_des[k])
- τ[k] 在整个 dt 内保持不变(零阶保持)
② RK4 用恒定 τ[k] 推进整个 dt(子步内不重算控制器)
↓
【步骤3:对比分析】
计算每组dt的RMSE、峰值力矩
绘制4张对比图
关键公式文字翻译
离散PD控制(ZOH):τ[k] = Kp·(q_des[k] − q[k]) + Kd·(qd_des[k] − qd[k])。方括号[k]表示第k个控制周期采样的值。与连续PD的区别在于:误差不是实时计算的,而是每隔dt才更新一次,且τ在整个dt内保持恒定。这正是实机"定时中断+零阶保持"的真实行为。
ZOH等效延迟:零阶保持器引入约 dt/2 的等效时间延迟。这个延迟吃掉系统的相位裕度——当相位裕度耗尽时,离散闭环极点越出单位圆,系统开始振荡。直观指标:RMSE突然跳升、力矩曲线出现高频锯齿、关节角度出现"过冲-回弹"的反复振荡。
5次多项式:p(t) = a₀ + a₃t³ + a₄t⁴ + a₅t⁵,其中a₁=a₂=0(初始速度/加速度为零)。边界条件为起终点位置/速度/加速度均为零。确保轨迹平滑,不给PD控制器制造瞬态冲击。
4. OOP代码实现
技术栈
限定:numpy, scipy(interpolate), matplotlib。不引入pybullet、roboticstoolbox等任何第三方重型库。
项目结构树
sampling_period_stability/
├── run_sim.py # 主入口
├── requirements.txt
├── robot/
│ ├── __init__.py
│ ├── models.py # dataclass数据模型
│ ├── dynamics.py # 2R闭式动力学
│ ├── kinematics.py # 正/逆运动学
│ ├── trajectory.py # 5次多项式轨迹 + IK + 样条
│ ├── controller.py # PD控制器(含限幅)
│ ├── simulator.py # ZOH离散步长RK4仿真器
│ ├── metrics.py # 评价指标
│ └── visualize.py # 4张图表
├── tests/
│ ├── __init__.py
│ └── test_stability.py # 3类测试
├── results/ # 4张图表
└── output/ # 指标报告txt
核心源码
"robot/models.py"
"""
Data models for sampling period stability simulation.
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
from dataclasses import dataclass
import numpy as np
@dataclass
class LinkInertia:
name: str
mass: float # kg
length: float # m
inertia: float # kg·m^2
com_offset: float # m
def __post_init__(self):
if self.mass <= 0:
raise ValueError(f"{self.name}: mass must be positive")
if self.length <= 0:
raise ValueError(f"{self.name}: length must be positive")
if self.inertia <= 0:
raise ValueError(f"{self.name}: inertia must be positive")
if not 0 < self.com_offset <= self.length:
raise ValueError(f"{self.name}: com_offset must be in (0, length]")
@dataclass
class PDGains:
Kp: np.ndarray
Kd: np.ndarray
def __post_init__(self):
self.Kp = np.asarray(self.Kp, dtype=float).reshape(2,)
self.Kd = np.asarray(self.Kd, dtype=float).reshape(2,)
if self.Kp.shape != (2,):
raise ValueError(f"Kp must be length-2, got {self.Kp.shape}")
if self.Kd.shape != (2,):
raise ValueError(f"Kd must be length-2, got self.Kd.shape")
@dataclass
class TorqueLimits:
tau_max: np.ndarray
def __post_init__(self):
self.tau_max = np.asarray(self.tau_max, dtype=float).reshape(2,)
if self.tau_max.shape != (2,) or np.any(self.tau_max <= 0):
raise ValueError("tau_max must be length-2 positive array")
@dataclass
class SimConfig:
links: list # required — no default_factory
gravity: float = 9.81
t_sim: float = 6.0
def __post_init__(self):
if not self.links:
raise ValueError("SimConfig.links must be provided, cannot be empty")
"robot/dynamics.py"
"""
2R planar rigid-body dynamics (vertical plane).
Closed-form M, C, G — pure numpy, no symbolic dependency.
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
import numpy as np
import math
from robot.models import SimConfig
class Dynamics2R:
def __init__(self, config: SimConfig):
self.cfg = config
self.links = config.links
self.g = config.gravity
self.m1 = self.links[0].mass
self.m2 = self.links[1].mass
self.l1 = self.links[0].length
self.l2 = self.links[1].length
self.r1 = self.links[0].com_offset
self.r2 = self.links[1].com_offset
self.I1 = self.links[0].inertia
self.I2 = self.links[1].inertia
def mass_matrix(self, q):
"""M(q) — symmetric positive definite inertia matrix."""
q2 = q[1]
c2 = math.cos(q2)
M11 = (self.I1 + self.I2 + self.m1*self.r1**2
+ self.m2*(self.l1**2 + self.r2**2 + 2*self.l1*self.r2*c2))
M12 = self.I2 + self.m2*self.r2*(self.r2 + self.l1*c2)
M22 = self.I2 + self.m2*self.r2**2
return np.array([[M11, M12], [M12, M22]], dtype=float)
def coriolis_vector(self, q, qd):
"""C(q, qd)·qd — centrifugal and Coriolis forces."""
q2 = q[1]
s2 = math.sin(q2)
h = self.m2 * self.l1 * self.r2 * s2
C1 = h * qd[1] * (2.0*qd[0] + qd[1])
C2 = -h * qd[0]**2
return np.array([[C1], [C2]], dtype=float)
def gravity_vector(self, q):
"""G(q) — gravity torques in vertical plane."""
q1 = q[0]
q2 = q[1]
c1 = math.cos(q1)
c12 = math.cos(q1 + q2)
G1 = self.m1*self.g*self.r1*c1 + self.m2*self.g*(self.l1*c1 + self.r2*c12)
G2 = self.m2*self.g*self.r2*c12
return np.array([[G1], [G2]], dtype=float)
def forward_dynamics(self, q, qd, tau):
"""
Solve M(q)·qdd = τ - C(q,qd)·qd - G(q) for qdd.
Used by RK4 integrator at each step.
"""
M = self.mass_matrix(q)
Cqd = self.coriolis_vector(q, qd)
G = self.gravity_vector(q)
rhs = tau.reshape(2, 1) - Cqd - G
qdd = np.linalg.solve(M, rhs)
return qdd.flatten()
"robot/kinematics.py"
"""
2R planar forward/inverse kinematics.
FIXED: elbow_down branch uses signed q2 in atan2 — no abs() bug.
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
import numpy as np
import math
class Kinematics2R:
def __init__(self, l1=0.55, l2=0.45):
self.l1 = l1
self.l2 = l2
def forward(self, q):
q1, q2 = q[0], q[1]
x = self.l1 * math.cos(q1) + self.l2 * math.cos(q1 + q2)
y = self.l1 * math.sin(q1) + self.l2 * math.sin(q1 + q2)
return np.array([x, y], dtype=float)
def inverse(self, x, y, elbow_down=True):
"""
Analytical IK for 2R planar arm.
q1 = alpha - atan2(l2*sin(q2), l1 + l2*cos(q2))
This formula works for BOTH elbow_up and elbow_down branches
because atan2 correctly handles the signed q2.
"""
r2 = x**2 + y**2
r = math.sqrt(r2)
r_min, r_max = abs(self.l1 - self.l2), self.l1 + self.l2
if r < r_min - 1e-6 or r > r_max + 1e-6:
raise ValueError(f"Point ({x:.4f}, {y:.4f}) outside workspace "
f"[{r_min:.3f}, {r_max:.3f}] m")
cos_q2 = (r2 - self.l1**2 - self.l2**2) / (2.0 * self.l1 * self.l2)
cos_q2 = max(-1.0, min(1.0, cos_q2))
q2 = math.acos(cos_q2)
if elbow_down:
q2 = -q2 # signed q2, used directly below
alpha = math.atan2(y, x)
# gamma uses signed q2 — works for both branches without abs()
gamma = math.atan2(self.l2 * math.sin(q2),
self.l1 + self.l2 * math.cos(q2))
q1 = alpha - gamma
return np.array([q1, q2], dtype=float)
"robot/trajectory.py"
"""
Cartesian straight-line trajectory via quintic polynomial + IK.
Standard coefficients: a3=10Δ/T³, a4=-15Δ/T⁴, a5=6Δ/T⁵
a1=a2=0 because initial velocity and acceleration are zero.
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
import numpy as np
from scipy.interpolate import CubicSpline
from robot.kinematics import Kinematics2R
class TransferTrajectoryGenerator:
def __init__(self, kinematics: Kinematics2R, num_samples=600):
self.kin = kinematics
self.num_samples = num_samples
def generate_straight_line(self, p_start, p_end, t_total=5.0):
T = t_total
delta = np.asarray(p_end) - np.asarray(p_start)
# Standard quintic (verified: p(T)=p_end, p'(T)=0, p''(T)=0)
# a1=a2=0 from zero initial velocity/acceleration boundary conditions
a0 = np.asarray(p_start, dtype=float)
a3 = 10.0 * delta / T**3
a4 = -15.0 * delta / T**4
a5 = 6.0 * delta / T**5
t = np.linspace(0.0, T, self.num_samples)
p = np.zeros((self.num_samples, 2))
for i, ti in enumerate(t):
p[i] = a0 + a3*ti**3 + a4*ti**4 + a5*ti**5
# IK — path is fully inside workspace, failure = config error
q = np.zeros((self.num_samples, 2))
for i in range(self.num_samples):
try:
q[i] = self.kin.inverse(p[i, 0], p[i, 1], elbow_down=True)
except ValueError as e:
raise RuntimeError(
f"IK failed at sample {i}, t={t[i]:.3f}s: {e}") from e
# Note: CubicSpline uses natural boundary (2nd derivative=0)
# while quintic guarantees 0 velocity/acceleration at endpoints.
# For this simulation the difference is negligible (<1e-4 rad).
spl_q1 = CubicSpline(t, q[:, 0], bc_type='natural')
spl_q2 = CubicSpline(t, q[:, 1], bc_type='natural')
spl_x = CubicSpline(t, p[:, 0], bc_type='natural')
spl_y = CubicSpline(t, p[:, 1], bc_type='natural')
return t, p, spl_x, spl_y, spl_q1, spl_q2
"robot/controller.py"
"""
PD controller with torque saturation (clipping).
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
import numpy as np
from robot.models import PDGains, TorqueLimits
class PDController:
def __init__(self, gains: PDGains, torque_limits: TorqueLimits,
enable_saturation=True):
self.Kp = gains.Kp
self.Kd = gains.Kd
self.tau_max = torque_limits.tau_max.reshape(2, 1)
self.enable_saturation = enable_saturation
def compute_torque(self, q, qd, q_des, qd_des):
"""
PD: τ = Kp*(q_des - q) + Kd*(qd_des - qd)
If saturation enabled, clip to [-τ_max, +τ_max].
"""
q = np.asarray(q).reshape(2, 1)
qd = np.asarray(qd).reshape(2, 1)
q_des = np.asarray(q_des).reshape(2, 1)
qd_des = np.asarray(qd_des).reshape(2, 1)
e_q = q_des - q
e_qd = qd_des - qd
tau_raw = (self.Kp.reshape(2, 1) * e_q
+ self.Kd.reshape(2, 1) * e_qd)
if self.enable_saturation:
tau = np.clip(tau_raw, -self.tau_max, self.tau_max)
else:
tau = tau_raw
return tau.flatten()
"robot/simulator.py"
"""
ZOH discrete-step simulator: RK4 with variable control period dt.
CORRECT ZOH: At each control period, compute torque ONCE from sampled
state and reference, then hold it constant for the entire dt.
Reference values are also sampled once per period (ZOH on q_des/qd_des).
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
import numpy as np
import math
from robot.dynamics import Dynamics2R
from robot.controller import PDController
from robot.models import SimConfig
class DiscreteStepSimulator:
def __init__(self, dynamics: Dynamics2R, controller: PDController,
t_grid, spl_q1, spl_q2, dt_control: float,
config: SimConfig):
self.dyn = dynamics
self.ctrl = controller
self.t_grid = t_grid # original trajectory time grid (for ZOH sampling)
self.spl_q1 = spl_q1
self.spl_q2 = spl_q2
self.dt = dt_control # control period = RK4 step size
self.t_sim = config.t_sim
# Use round() to avoid float division truncation
self.n_steps = int(round(self.t_sim / dt_control))
# Sanity: verify no time lost
actual_t = self.n_steps * self.dt
if abs(actual_t - self.t_sim) > 1e-9:
print(f" Warning: t_sim={self.t_sim} not integer multiple of "
f"dt={self.dt}, actual sim time={actual_t:.4f}s")
def _get_ref_zoh(self, t_period_start):
"""
Sample reference trajectory ONCE at period start (ZOH).
Uses the original fine trajectory grid for lookup.
"""
t_clip = max(self.t_grid[0], min(t_period_start, self.t_grid[-1]))
q1 = float(self.spl_q1(t_clip))
q2 = float(self.spl_q2(t_clip))
qd1 = float(self.spl_q1(t_clip, 1))
qd2 = float(self.spl_q2(t_clip, 1))
return np.array([q1, q2]), np.array([qd1, qd2])
def run(self, q_start):
"""
Run simulation with ZOH discrete control.
At each control period:
1. Sample state and reference at period start
2. Compute torque ONCE
3. Hold tau constant for the entire dt (RK4 with fixed tau)
"""
x = np.concatenate([q_start, np.zeros(2)])
n = self.n_steps + 1
x_hist = np.zeros((n, 4))
tau_hist = np.zeros((n, 2))
t_arr = np.arange(n) * self.dt
x_hist[0] = x
for i in range(self.n_steps):
t = t_arr[i]
# --- ZOH: sample reference at period START, hold for entire dt ---
q_des, qd_des = self._get_ref_zoh(t)
# --- ZOH: compute torque ONCE from current state ---
q_curr, qd_curr = x[0:2], x[2:4]
tau = self.ctrl.compute_torque(q_curr, qd_curr, q_des, qd_des)
tau_hist[i] = tau
# --- RK4 with CONSTANT tau for the whole dt ---
# tau is closed over as a default argument (fixed at period start)
def deriv(xx, tau_fixed=tau):
qq = xx[0:2]
qqd = xx[2:4]
qdd = self.dyn.forward_dynamics(qq, qqd, tau_fixed)
return np.concatenate([qqd, qdd])
k1 = deriv(x)
k2 = deriv(x + 0.5*self.dt*k1)
k3 = deriv(x + 0.5*self.dt*k2)
k4 = deriv(x + self.dt*k3)
x = x + (self.dt/6.0)*(k1 + 2*k2 + 2*k3 + k4)
x_hist[i+1] = x
# Final tau at last state
q_des_final, qd_des_final = self._get_ref_zoh(t_arr[-1])
tau_hist[-1] = self.ctrl.compute_torque(
x[0:2], x[2:4], q_des_final, qd_des_final)
return t_arr, x_hist, tau_hist
"robot/metrics.py"
"""
Metrics: RMSE and peak torque for stability analysis.
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
import numpy as np
from robot.kinematics import Kinematics2R
def compute_cartesian_error(t, x_hist, spl_x, spl_y, kin: Kinematics2R):
"""Cartesian end-effector error using original path spline."""
n = len(t)
e_norm = np.zeros(n)
for i in range(n):
q_actual = x_hist[i, 0:2]
p_actual = kin.forward(q_actual)
t_clip = max(spl_x.x[0], min(t[i], spl_x.x[-1]))
p_des = np.array([float(spl_x(t_clip)), float(spl_y(t_clip))])
e_norm[i] = np.linalg.norm(p_actual - p_des)
return e_norm
def compute_rmse(arr):
return np.sqrt(np.mean(arr**2))
def compute_peak_torque(tau_hist):
"""Maximum absolute torque across all joints and time steps."""
return float(np.max(np.abs(tau_hist)))
"robot/visualize.py"
"""
4 charts for sampling period stability analysis.
# OFFLINE SIMULATION ONLY — not for production controllers.
"""
import os
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans',
'Noto Sans CJK SC']
plt.rcParams['axes.unicode_minus'] = False
class StabilityVisualizer:
def __init__(self, results_dir="results"):
self.results_dir = results_dir
os.makedirs(results_dir, exist_ok=True)
def plot_cartesian_error_comparison(self, results_dict):
"""Cartesian error for all dt values on one plot."""
fig, ax = plt.subplots(figsize=(10, 5))
for dt, (t, e) in results_dict.items():
ax.plot(t, e * 1000, linewidth=1.0, label=f'dt={dt*1000:.0f} ms')
ax.set_xlabel('Time (s)')
ax.set_ylabel('Cartesian Error (mm)')
ax.set_title('End-Effector Error vs Control Period')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
path = os.path.join(self.results_dir, "01_cartesian_error_comparison.png")
plt.tight_layout()
plt.savefig(path, dpi=150)
plt.close()
return path
def plot_joint1_trajectory(self, results_dict):
"""Joint 1 angle for all dt values — shows oscillation onset."""
fig, ax = plt.subplots(figsize=(10, 5))
for dt, (t, x_hist) in results_dict.items():
ax.plot(t, np.rad2deg(x_hist[:, 0]), linewidth=1.0,
label=f'dt={dt*1000:.0f} ms')
ax.set_xlabel('Time (s)')
ax.set_ylabel('Joint 1 Angle (deg)')
ax.set_title('Joint 1 Trajectory vs Control Period (oscillation visible at large dt)')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
path = os.path.join(self.results_dir, "02_joint1_trajectory.png")
plt.tight_layout()
plt.savefig(path, dpi=150)
plt.close()
return path
def plot_torque_comparison(self, results_dict):
"""Joint 1 torque for all dt values."""
fig, ax = plt.subplots(figsize=(10, 5))
for dt, (t, tau_hist) in results_dict.items():
ax.plot(t, tau_hist[:, 0], linewidth=0.8,
label=f'dt={dt*1000:.0f} ms')
ax.axhline(150, color='r', linestyle='--', linewidth=0.8,
label='Limit +150 N·m')
ax.axhline(-150, color='r', linestyle='--', linewidth=0.8,
label='Limit -150 N·m')
ax.set_xlabel('Time (s)')
ax.set_ylabel('Joint 1 Torque (N·m)')
ax.set_title('Joint 1 Torque vs Control Period')
ax.legend(fontsize=7, ncol=2)
ax.grid(True, alpha=0.3)
path = os.path.join(self.results_dir, "03_torque_comparison.png")
plt.tight_layout()
plt.savefig(path, dpi=150)
plt.close()
return path
def plot_rmse_vs_dt(self, dt_list, rmse_list):
"""Bar chart: RMSE vs control period."""
fig, ax = plt.subplots(figsize=(8, 5))
dt_ms = [dt * 1000 for dt in dt_list]
rmse_mm = [r * 1000 for r in rmse_list]
# Color bars by actual RMSE value (not fixed position)
colors = []
for r in rmse_mm:
if r < 30:
利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)