视频加载失败

串级 PID:云台的角度环和速度环

7262 字
36 分钟
串级 PID:云台的角度环和速度环

RoboMaster 步兵开小陀螺的时候,底盘以每秒几圈的速度原地转,云台要在这个转动的底座上把枪口对准一个不动的装甲板。我的云台代码一直是“角度环套速度环”,两层 PID,我见过的开源工程大多这么写,我也一直照着写,但从来没问过:为什么不用一个 PID 直接管角度? 这一篇我把同一根偏航轴分别接成单环和串级,放进同一份底盘转动数据里,量出来:底盘在 0.5 s 内加速到 6 rad/s,单环的云台角度误差峰值是 7.16 mrad,串级是 0.41 mrad,差 17.5 倍。然后我拆开这个 17.5 是从哪来的,它比我预想的更“无聊”,也更有用。

本篇检查点

这一篇结束时你会有:make ch07 通过,out/ch07/ 下 40 个 CSV(10 个算例,每个 Python、C、C++、Rust 各一份),figures/ch07/ 下 4 张图;以及单环三阶极点配置、串级内外环整定、带宽比分析和内环饱和下外环积分冻结的推导与实验。 新增模块:没有。控制器全部是第 5 篇的 Pid,串级的接线(外环输出变成内环设定值)直接写在每种语言的 ch07 驱动和应用里。对象还是第 1 篇的 GimbalAxis(u = [τ, τ_d, ω_c],第 1 篇里第三个输入恒为 0,这一篇第一次让它非零),相角裕度用第 3 篇的 freq。 前置:第 5 篇和第 6 篇。需要的数学:第 2 篇的二阶系统和极点配置,第 3 篇的相角裕度,第 5 篇的 hold_integral 参数。

数学#

对象:云台和转动的底盘#

云台偏航轴还是第 1 篇的 GimbalAxis,扭矩指令的 GM6020:

Jψ¨=τ−τd−bψ˙−bf (ψ˙−ωc)J\ddot\psi = \tau - \tau_d - b\dot\psi - b_f\,(\dot\psi - \omega_c)

ψ\psi 是云台相对世界的角度,τ\tau 是电机力矩(限幅 ±1\pm1 N·m),ωc\omega_c 是底盘的角速度。最后一项是轴承的粘性耦合:底盘转起来,轴承会把云台往同一个方向拖。参数 J=0.02J = 0.02 kg·m²、b=0.002b = 0.002、bf=0.02b_f = 0.02 全是我取的量级,不是实测。令 ω=ψ˙\omega = \dot\psi,bt=b+bf=0.022b_t = b + b_f = 0.022,速度子系统是

Jω˙=τ−bt ω+bf ωcJ\dot\omega = \tau - b_t\,\omega + b_f\,\omega_c

对云台来说,ωc\omega_c 就是一个力矩扰动 d=bfωcd = b_f\omega_c:底盘到 6 rad/s 时是 0.02×6=0.120.02\times6 = 0.12 N·m,占力矩上限的 12%。这个扰动是一段斜坡:我让 ωc\omega_c 从 1.0 s 到 1.5 s 线性升到 6 rad/s,之后保持。

测量有两路:角度 ψ+na\psi + n_a,σa=10−4\sigma_a = 10^{-4} rad;陀螺 ψ˙+ng\dot\psi + n_g,σg=0.01\sigma_g = 0.01 rad/s。噪声是 Python 用 numpy 种子 7 生成、写进数据 CSV 的,三种原生语言读同一份。这两个数字的出处要交代清楚:卡尔曼系列第 4 篇的陀螺仿真用的是 0.005 rad/s,那是“典型消费级 MEMS 的量级”;我这里取了它的 2 倍,是为了让噪声看得见,不是那一篇的结论。角度的 10−410^{-4} rad(0.0057°)是我假设的姿态估计精度,也不是那一篇测出来的。

单环:三个增益摆三个极点#

先写单环,它是对照组。用第 5 篇的 Pid,只开 P 和 I,D 用陀螺:陀螺读数本身就是角度的导数,不需要对带噪声的角度再差分一次。控制律是

τ=kp(r−ψ)+ki ⁣∫(r−ψ) dt−kdg ψ˙gyro\tau = k_p(r-\psi) + k_i\!\int(r-\psi)\,dt - k_{dg}\,\dot\psi_{gyro}

陀螺项通过 Pid.update 的 ff 参数送进去(ff=−kdgψ˙gyro\mathrm{ff} = -k_{dg}\dot\psi_{gyro}),它进入限幅前的 vv,所以抗积分饱和照样看得见它。代入对象,特征多项式是

Js3+(bt+kdg) s2+kp s+ki=0J s^3 + (b_t + k_{dg})\,s^2 + k_p\,s + k_i = 0

要的极点是一个实极点 −p-p 加一个 ζ=0.7\zeta = 0.7 的共轭对,即 J(s+p)(s2+2ζωs+ω2)J(s+p)(s^2 + 2\zeta\omega s + \omega^2)。对系数得到

kdg=J(p+2ζω)−bt,kp=J(ω2+2ζωp),ki=J p ω2k_{dg} = J(p + 2\zeta\omega) - b_t,\qquad k_p = J(\omega^2 + 2\zeta\omega p),\qquad k_i = J\,p\,\omega^2

取 p=ω=ωo=ωi/5=12p = \omega = \omega_o = \omega_i/5 = 12 rad/s(ωi=60\omega_i = 60 rad/s 是后面串级内环的带宽,这样两边“外环带宽”相同,对比才公平):kdg=0.554k_{dg} = 0.554,kp=6.912k_p = 6.912,ki=34.56k_i = 34.56。极点是 −12-12 和 −8.40±8.57j-8.40\pm8.57\mathrm{j}(report 打印的)。

这个单环有一个第 2 篇已经讲过的毛病:闭环传递函数 T(s)=(kps+ki)/(…)T(s) = (k_ps + k_i)/(\ldots) 有一个零点在 −ki/kp=−5-k_i/k_p = -5 rad/s,比极点慢,所以阶跃会超调,不管阻尼多好。我会在结果里看到这一点。

串级:内环管速度,外环管角度#

串级把这三个增益拆成两层。内环是速度 PI,设定值是 ωref\omega_{ref},测量是陀螺:

τ=kp,i(ωref−ωgyro)+ki,i ⁣∫(ωref−ωgyro) dt\tau = k_{p,i}(\omega_{ref} - \omega_{gyro}) + k_{i,i}\!\int(\omega_{ref} - \omega_{gyro})\,dt

被控对象 ω/τ=1/(Js+bt)\omega/\tau = 1/(Js + b_t) 是一阶的,和第 2 篇的电机速度环同一个形状,所以直接套那一篇的极点配置,闭环传递函数是

Ti(s)=ωωref=kp,is+ki,iJs2+(bt+kp,i) s+ki,iT_i(s) = \frac{\omega}{\omega_{ref}} = \frac{k_{p,i}s + k_{i,i}}{Js^2 + (b_t + k_{p,i})\,s + k_{i,i}}

令 ζ=0.7\zeta = 0.7、ωi=60\omega_i = 60 rad/s:ki,i=Jωi2=72k_{i,i} = J\omega_i^2 = 72,kp,i=2ζωiJ−bt=1.658k_{p,i} = 2\zeta\omega_iJ - b_t = 1.658。这个 PI 有一个零点在 −ki,i/kp,i=−43.4-k_{i,i}/k_{p,i} = -43.4 rad/s,比 ωi\omega_i 还低一点,稍后会看到它对外环有用。

外环是一个纯 P,输入角度误差,输出内环的速度设定值:ωref=ko(r−ψ)\omega_{ref} = k_o(r - \psi),ko=ωi/比值k_o = \omega_i/\text{比值}。比值就是“内环比外环快多少倍”,是这一篇的主要旋钮。整条链路是:

角度误差

ω_ref

速度误差

ψ'

ψ

inner.saturated

目标角 r

+ −

外环 P(或 PI):k_o

+ −

内环 PI:k_p,i 和 k_i,i

前馈 −b_f ω_c

+

限幅 ±1 N·m

云台:J ψ'' = τ − b_t ψ' + b_f ω_c

陀螺(噪声 0.01 rad/s)

角度(噪声 1e-4 rad)

角度误差

ω_ref

速度误差

ψ'

ψ

inner.saturated

目标角 r

+ −

外环 P(或 PI):k_o

+ −

内环 PI:k_p,i 和 k_i,i

前馈 −b_f ω_c

+

限幅 ±1 N·m

云台:J ψ'' = τ − b_t ψ' + b_f ω_c

陀螺(噪声 0.01 rad/s)

角度(噪声 1e-4 rad)

虚线是这一篇最后一节才用到的:内环把它有没有饱和告诉外环。

外环看到的对象不是云台,而是“内环加一个积分器”:Lo(s)=ko Ti(s)/sL_o(s) = k_o\,T_i(s)/s。如果把 TiT_i 粗略当成一阶的 1/(s/ωi+1)1/(s/\omega_i + 1),外环的特征方程是 s2+ωis+ωiko=0s^2 + \omega_i s + \omega_i k_o = 0,阻尼比 ζo=12ωi/ko=12比值\zeta_o = \tfrac12\sqrt{\omega_i/k_o} = \tfrac12\sqrt{\text{比值}},比值到 4 就是临界阻尼;相角裕度是 90∘−arctan⁡(ωc/ωi)90^\circ - \arctan(\omega_c/\omega_i):内环对外环来说就是一个滞后,比值越大,这个滞后在外环穿越频率处吃掉的相位越少。我不信一个近似,所以 report 用第 3 篇的 freq 在精确的 TiT_i 上也算一遍(延迟取 ZOH 的半个采样周期)。

串级为什么抗扰:等效 PID#

回到 17.5 倍。扰动力矩 dd 到角度 ψ\psi 的传递函数,单环和串级是同一个形状(令 r=0r = 0,把控制律代进去):

ψd=sJs3+(bt+kd) s2+kp s+ki\frac{\psi}{d} = \frac{s}{Js^3 + (b_t + k_d)\,s^2 + k_p\,s + k_i}

单环的 (kd,kp,ki)(k_d, k_p, k_i) 就是 (kdg,kp,ki)(k_{dg}, k_p, k_i);串级的是等效增益

kd=kp,i,kp=kokp,i+ki,i,ki=ko ki,ik_d = k_{p,i},\qquad k_p = k_ok_{p,i} + k_{i,i},\qquad k_i = k_o\,k_{i,i}

代入数字,比值 5(ko=12k_o = 12):

回路kdk_dkpk_pkik_i极点
单环0.5546.91234.56−12.00-12.00,−8.40±8.57j-8.40\pm8.57\mathrm{j}
串级1.65891.896864.0−11.48-11.48,−36.26±49.47j-36.26\pm49.47\mathrm{j}

两者最慢的极点几乎在同一个位置(−12.00-12.00 和 −11.48-11.48),所以“外环带宽”确实是一样的;串级多出来的是一对 60 rad/s 量级的快极点,换来了 13.3 倍的 kpk_p 和 25 倍的 kik_i。对一段斜率为 mm 的力矩斜坡,这个 s/(…)s/(\ldots) 形式的闭环由终值定理给出稳态角度误差 m/kim/k_i:

ψss=mki,m=bf⋅60.5 s=0.24 N⋅m/s\psi_{ss} = \frac{m}{k_i},\qquad m = \frac{b_f\cdot6}{0.5\ \text{s}} = 0.24\ \text{N·m/s}

单环是 0.24/34.56=6.9440.24/34.56 = 6.944 mrad,串级是 0.24/864=0.2780.24/864 = 0.278 mrad。换个说法更直观:内环的 PI 先把速度误差压到 m/ki,i=0.24/72=3.33m/k_{i,i} = 0.24/72 = 3.33 mrad/s,外环再把它除以 ko=12k_o = 12,就是 0.278 mrad。扰动在到达角度之前,已经在速度这一层被消掉了大部分。 类比的话,这像工厂里班组先把小故障在车间内消化,不往上报给调度:车间的响应快,所以故障还没传远就被吃掉。

要把话说准:这不是说单环做不到。串级的 (kd,kp,ki)=(1.658,91.9,864)(k_d, k_p, k_i) = (1.658, 91.9, 864) 直接塞进一个单环 PID,特征多项式和扰动响应完全一样。差别是单环用的是我选的“三个极点都放在 12 rad/s”的配方,三个增益被这个配方锁死了;串级的配方是“先把内环做到 60 rad/s,外环按比值再降”,它用同样的三个增益自由度,摆出了更刚的一组极点,并且让整定变成两个有物理含义的旋钮:内环带宽和比值。设定值通道也不同(外环 P 没有积分,没有那个 −5-5 rad/s 的零点)。

底盘角速度前馈。 底盘陀螺(底盘上本来就有)给出 ωc\omega_c,所以扰动 bfωcb_f\omega_c 是已知的,可以直接在内环的输出上抵消。符号要小心:云台静止(ψ˙=0\dot\psi = 0)时,耦合项对云台的力矩是 +bfωc+b_f\omega_c,所以前馈是 ff=−bfωc\mathrm{ff} = -b_f\omega_c。

带宽比:多快才算“快得够”#

比值太小,内环在外环穿越频率处的滞后就大,相角裕度掉;比值太大,外环慢得没必要,而且你把整条链路的响应速度扔掉了。我在 {1.5,3,5,10}\{1.5, 3, 5, 10\} 四个比值上扫:ko=40,20,12,6k_o = 40, 20, 12, 6 rad/s,内环都不动。

内环饱和:外环怎么知道#

云台要转一个大角度,比如 1.5 rad,外环给出 ko×1.5=18k_o\times1.5 = 18 rad/s 的速度设定值。力矩上限 1 N·m 对应的最大角加速度是 1/0.02=501/0.02 = 50 rad/s²,加速到 18 rad/s 至少要 0.36 s。这段时间里内环的力矩一直贴在上限,内环饱和了,而外环自己并没有饱和:外环输出的是速度设定值,我没有给它限幅。所以第 5 篇的条件积分(CLAMP)在外环看不出任何问题,如果外环带积分(ki,o=0.2 ko2=28.8k_{i,o} = 0.2\,k_o^2 = 28.8),它会一直在 1.5 rad 的误差上累加,最后速度设定值积出来一大块,角度冲过头。

解法在第 5 篇的 hold_integral 里:驱动把内环上一个采样的 saturated 标志传给外环,外环这一拍不更新积分:

holdk=inner.saturatedk−1\text{hold}_k = \text{inner.saturated}_{k-1}

延迟一拍是因为内环要在外环之后才更新;我猜 1 kHz 下一拍的差别可以忽略,但没有单独测。这个策略很粗:内环一饱和就整个冻结外环积分,不看误差方向;第 5 篇的条件积分只在“继续往饱和方向积”时才跳过。我这一篇没有比较两种策略。

Python:增益、闭环和裕度分析#

不新增库模块,所以这一篇的 Python 全部在驱动 python/ch07_cascade.py 里:算例表、增益、一个闭环仿真、用 freq 的裕度分析和 report。先是两组增益,和上面的公式一一对应:

def inner_gains() -> tuple[float, float]:
"""Rate PI on J s + (b + bf) placed at zeta, WN_I."""
g = GimbalAxis()
kp = 2.0 * ZETA * WN_I * g.j - (g.b + g.bf)
ki = WN_I * WN_I * g.j
return kp, ki
def single_gains() -> tuple[float, float, float]:
"""kp, ki and the gyro damping kdg for poles (s + wo)(s^2 + ... wo^2)."""
g = GimbalAxis()
wo = WN_I / RATIO
p = wo
kdg = g.j * (p + 2.0 * ZETA * wo) - (g.b + g.bf)
kp = g.j * (wo * wo + 2.0 * ZETA * wo * p)
ki = g.j * p * wo * wo
return kp, ki, kdg

Case 里的五个字段决定一个算例:kind(单环或串级)、ratio、ff(内环前馈)、outer_i(外环是 PI 还是 P)和 hold(冻结外环积分)。十个算例是:single_step,cascade_r{1.5,3,5,10}_step(0.2 rad 阶跃),single_spin、cascade_spin、cascade_spin_ff(底盘转动),outer_aw_off、outer_aw_on(1.5 rad 大阶跃,外环 PI)。

数据由 write_data 生成:ch07_step.csv(2000 行,0.05 s 时 r=0.2r = 0.2 rad)、ch07_spin.csv(3000 行,r=0r = 0,ωc\omega_c 从 1.0 s 到 1.5 s 线性升到 6 rad/s)、ch07_aw.csv(3000 行,0.05 s 时 r=1.5r = 1.5 rad),每个文件都带 n_gyro 和 n_angle 两列噪声,numpy 种子 7。

闭环仿真是这一篇的核心,串级的接线就是这几行:

def run_case(case: Case, data=None) -> dict[str, np.ndarray]:
"""One closed loop; `data` overrides the CSV (used by the linear check)."""
plant = GimbalAxis()
if data is None:
data = read_csv(DATA / f"{case.data}.csv")
u_lim = plant.u_max
if case.kind == "single":
kp, ki, kdg = single_gains()
outer = Pid(PidConfig(kp, ki, 0.0, TS, u_min=-u_lim, u_max=u_lim))
inner = None
else:
kpi, kii = inner_gains()
ko = WN_I / case.ratio
kio = OUTER_KI * ko * ko if case.outer_i else 0.0
outer = Pid(PidConfig(ko, kio, 0.0, TS))
inner = Pid(
PidConfig(
kpi,
kii,
0.0,
TS,
u_min=-u_lim,
u_max=u_lim,
windup=Windup.CLAMP,
)
)
x = [0.0, 0.0]
rows = []
for k in range(len(data["t"])):
r, wc = data["r"][k], data["wc"][k]
angle = x[0] + data["n_angle"][k] # the angle estimate
gyro = x[1] + data["n_gyro"][k]
if inner is None:
wref = 0.0
u = outer.update(r, angle, -kdg * gyro)
sat = outer.saturated
else:
hold = case.hold and inner.saturated # last sample's flag
wref = outer.update(r, angle, 0.0, hold)
ff = -plant.bf * wc if case.ff else 0.0
u = inner.update(wref, gyro, ff)
sat = inner.saturated
rows.append(
[data["t"][k], r, wc, x[0], x[1], wref, u, outer.integral, sat]
)
x = advance(plant.deriv, x, [u, 0.0, wc], TS, SUBSTEPS)
return dict(zip(COLUMNS, np.array(rows, dtype=float).T))

三处值得看:

  • 测量。 angle 和 gyro 是真值加噪声列,控制器永远看不到 x。输出 CSV 里的 psi、psid 是真值,所以后面算超调和峰值不受噪声污染。
  • 接线。 串级先算外环:wref = outer.update(r, angle, 0.0, hold),再把 wref 当设定值、陀螺当测量喂给内环:inner.update(wref, gyro, ff)。单环只有一次 update,陀螺项走 ff。
  • hold。 在内环更新之前读 inner.saturated,此时它还是上一拍的值,这就是上面公式里的 k−1k-1。

裕度分析用第 3 篇的 freq。外环的开环是 Lo=koTi/sL_o = k_oT_i/s,写成多项式比:

def outer_margins(ratio: float, first_order: bool = False):
"""Margins of L_o = k_o T_i(s) / s, the loop opened at the rate setpoint.
T_i is the exact inner closed loop (PI on J s + b + bf), or the
first-order stand-in 1 / (s / wn_i + 1). The delay is the ZOH's ts / 2.
"""
g = GimbalAxis()
kpi, kii = inner_gains()
ko = WN_I / ratio
if first_order:
num, den = [ko], poly_mul([1.0, 0.0], [1.0 / WN_I, 1.0])
else:
num = [ko * kpi, ko * kii]
den = poly_mul([1.0, 0.0], [g.j, g.b + g.bf + kpi, kii])
return margins(num, den, 0.5 * TS, log_grid())
def disturbance_polys() -> dict[str, list[float]]:
"""Denominator of psi / torque disturbance, highest power first.
Both loops reduce to J s^3 + (b + bf + kd) s^2 + kp s + ki, with the
cascade's equivalent gains kd = kpi, kp = ko kpi + kii, ki = ko kii.
"""
g = GimbalAxis()
bt = g.b + g.bf
kp, ki, kdg = single_gains()
kpi, kii = inner_gains()
ko = WN_I / RATIO
return {
"single": [g.j, bt + kdg, kp, ki],
"cascade": [g.j, bt + kpi, ko * kpi + kii, ko * kii],
}

disturbance_polys 就是上一节的等效增益表,report 用它求根、算斜坡误差公式。outer_margins 里 first_order=True 是“把内环当一阶滞后”的粗近似。单环的开环 LL(断在对象输入处)也有一个 single_margins:开环有双积分器,freq 的主值相位在起点落在 +180∘+180^\circ 的支路上,所以我把算出来的相角裕度折回 (−180∘,180∘](-180^\circ, 180^\circ],在源码里留了注释。

report 的断言是这一篇的“实验结论”:相角裕度随比值单调增;上升时间随比值单调增;小阶跃(5 mrad,不会碰到力矩限幅)的超调随比值单调降;单环的 ψ 峰值大于串级大于串级加前馈;outer_aw_on 的超调小于 outer_aw_off;斜坡公式的预测值和仿真峰值之比落在 0.9 到 2.0 之间。

完整驱动(含 report 和画图):python/ch07_cascade.py。

同一套接线,三种语言#

没有新的库模块,三个版本的 ch07 应用都是 Python 驱动的逐行翻译:同样的算例表、同样的增益公式、同样的接线,读同一份数据。区别在这几处:

  • 两个控制器的放法。 C 用 struct pl_pid pid[2],下标 0 是外环(或单环),下标 1 是内环,初始化放在 make_loops 里,用 pl_pid_init 返回的 -EINVAL 做错误传递;C++ 是两个 std::optional<Pid<double>>,Create 失败就返回 false;Rust 是两个 Pid,Pid::new 返回 Result,用 ? 向上传。三种语言都没有堆分配(csv.c 除外)。
  • “没有限幅”。 Python 的 ±math.inf(PidConfig 的默认值)、C 的 INFINITY(math.h)、C++ 的 std::numeric_limits<double>::infinity()、Rust 的 PidConfig::new 默认值,是同一个意思。外环不给限幅,所以外环自己的 saturated 永远是 false,这正是需要 hold_integral 的原因。
  • 读标志。 C 是结构体字段 pid[1].saturated,C++ 是访问器 inner->saturated(),Rust 是 inner.saturated(),Python 是属性。四种写法读到的都是上一拍的值,因为内环在这一拍还没更新。
  • 标量类型。 这一篇仍是 double(pl_real、T、Real),不过没有写死,第 10 篇换 float32。

C#

构造放在 make_loops,下标约定是 pid[0] 外环(或单环),pid[1] 内环。增益公式和 Python 一行对一行,这里只看内环那一段:

kpi = 2.0 * ZETA * WN_I * g->j - (g->b + g->bf);
kii = WN_I * WN_I * g->j;
ko = WN_I / c->ratio;
kio = c->outer_i ? OUTER_KI * ko * ko : 0.0;
set_cfg(&cfg, ko, kio);
ret = pl_pid_init(&pid[0], &cfg);
if (ret)
return ret;
set_cfg(&cfg, kpi, kii);
cfg.u_min = -g->u_max;
cfg.u_max = g->u_max;
return pl_pid_init(&pid[1], &cfg);

闭环里的接线,hold 在内环更新之前读:

if (c->kind == LOOP_SINGLE) {
u = pl_pid_update(&pid[0], row[cr], angle, -kdg * gyro,
false);
sat = pid[0].saturated;
} else {
/* the inner flag still holds the last sample's value */
bool hold = c->hold && pid[1].saturated;
pl_real ff = c->ff ? -g.bf * row[cw] : 0.0;
wref = pl_pid_update(&pid[0], row[cr], angle, 0.0,
hold);
u = pl_pid_update(&pid[1], wref, gyro, ff, false);
sat = pid[1].saturated;
}

完整应用:c/apps/ch07.c。

C++#

外环配置先按单环填好(带 ±1\pm1 N·m 限幅),串级时整个换成无限幅的新配置;Create 返回 std::optional,inner->saturated() 是访问器:

if (c.cascade) {
const double ko = kWnI / c.ratio;
outer_cfg = {ko, c.outer_i ? kOuterKi * ko * ko : 0.0, 0.0, kTs};
outer_cfg.u_min = -inf;
outer_cfg.u_max = inf;
}
auto outer = pidlab::Pid<double>::Create(outer_cfg);
auto inner = pidlab::Pid<double>::Create(inner_cfg);
if (!outer || !inner) return false;
// ...
} else {
// the inner flag still holds the last sample's value
const bool hold = c.hold && inner->saturated();
const double ff = c.ff ? -g.bf * (*wc)[k] : 0.0;
wref = outer->Update((*r)[k], angle, 0.0, hold);
u = inner->Update(wref, gyro, ff);
sat = inner->saturated();
}

完整应用:cpp/apps/ch07.cc。

Rust#

outer_cfg 和 C++ 一样先按单环填限幅,串级时用一个默认无限幅的 PidConfig::new 整个替换:

if c.cascade {
let ko = WN_I / c.ratio;
let ki = if c.outer_i { OUTER_KI * ko * ko } else { 0.0 };
outer_cfg = PidConfig::new(ko, ki, 0.0, TS);
}
let mut outer = Pid::new(outer_cfg).map_err(|e| format!("{e:?}"))?;
let mut inner = Pid::new(inner_cfg).map_err(|e| format!("{e:?}"))?;
// ...
if c.cascade {
// the inner flag still holds the last sample's value
let hold = c.hold && inner.saturated();
let ff = if c.ff { -g.bf * wc[k] } else { 0.0 };
wref = outer.update(r[k], angle, 0.0, hold);
u = inner.update(wref, gyro, ff, false);
sat = inner.saturated();
}

完整应用:rust/src/bin/ch07.rs。

结果#

make ch07 一次跑完。10 个算例,每种语言 10 个文件,30 个对比文件。下面的输出把中间 24 行 ok 裁掉了,其余的 max diff 也全是 0:

$ make -s ch07
ch07: C wrote 10 cases to out/ch07
ch07: C++ wrote 10 cases to out/ch07
ch07: Rust wrote 10 cases to out/ch07
ok cascade_r1.5_step_c.csv: max diff 0.000e+00 <= 1.0e-09
ok cascade_r1.5_step_cpp.csv: max diff 0.000e+00 <= 1.0e-09
ok cascade_r1.5_step_rs.csv: max diff 0.000e+00 <= 1.0e-09
... (中间 24 行 ok 省略)
ok single_step_c.csv: max diff 0.000e+00 <= 1.0e-09
ok single_step_cpp.csv: max diff 0.000e+00 <= 1.0e-09
ok single_step_rs.csv: max diff 0.000e+00 <= 1.0e-09
inner rate PI at zeta 0.7, wn 60.0: kp = 1.6580, ki = 72.000
single loop (poles at 12 rad/s): kp = 6.9120, ki = 34.5600, kd_gyro = 0.5540
outer k_o = 40 (ratio 1.5), 20 (ratio 3), 12 (ratio 5), 6 (ratio 10)
case rise overshoot settle max|u| sat ms PM PM 1st wc
single_step 0.095 29.89% 0.513s 1.000 14 65.9d - 28.29
cascade_r1.5_step 0.063 15.62% 0.181s 1.000 138 61.7d 59.0d 50.54
cascade_r3_step 0.123 0.07% 0.241s 1.000 48 85.4d 71.8d 22.34
cascade_r5_step 0.186 0.08% 0.354s 1.000 31 88.7d 78.6d 12.49
cascade_r10_step 0.363 0.08% 0.662s 1.000 12 89.6d 84.1d 6.06
loop PM unclipped overshoot (5 mrad)
single 65.9d 33.946%
r = 1.5 61.7d 4.608%
r = 3 85.4d 0.000%
r = 5 88.7d 0.000%
r = 10 89.6d 0.000%
chassis ramp to 6.0 rad/s: disturbance torque bf wc = 0.120 N*m
single: J s^3 + 0.576 s^2 + 6.912 s + 34.560; poles -12.00+0.00j, -8.40-8.57j, -8.40+8.57j
cascade: J s^3 + 1.680 s^2 + 91.896 s + 864.000; poles -36.26-49.47j, -36.26+49.47j, -11.48+0.00j
torque ramp slope 0.240 N*m/s over ki_eq: single 6.944 mrad, cascade 0.278 mrad
case peak |psi| rms |psi| final rms u (last 0.5 s)
single_spin 0.00716 0.00313 -0.000005 0.12008
cascade_spin 0.00041 0.00014 0.000029 0.12116
cascade_spin_ff 0.00016 0.00006 0.000029 0.12116
cascade cuts the peak by 17.51x, with feedforward by 44.07x
single: simulated peak / ramp formula = 1.03
cascade: simulated peak / ramp formula = 1.47
case peak psi overshoot max I_out sat ms at +1 at -1 settle
outer_aw_off 2.1962 46.41% 7.329 467 229 238 1.240s
outer_aw_on 1.5238 1.59% 0.379 302 182 120 0.329s
figure: figures/ch07/single-vs-cascade.svg
figure: figures/ch07/bandwidth-ratio.svg
figure: figures/ch07/spin-disturbance.svg
figure: figures/ch07/inner-saturation.svg
ch07 OK

30 个文件的最大差异都是严格的 0.000e+00,不是“在 10−910^{-9} 以内”。这一篇的循环里没有 sin、cos、exp,只有加减乘除和比较,IEEE 754 的基本运算在三种语言里是同一个结果,只要运算顺序一样(我照着 Python 的顺序写的),比特都一样。前面并不总是这样干净:第 6 篇的最大差异是 2.2×10−162.2\times10^{-16},我没有追查原因,猜是 libm 函数的末位差别。

单环对串级:阶跃#

0.2 rad 阶跃:单环(三阶极点配置)上冲到约 0.26 rad,串级(比值 5)几乎没有超调,上升慢一倍
0.2 rad 阶跃:单环(三阶极点配置)上冲到约 0.26 rad,串级(比值 5)几乎没有超调,上升慢一倍

0.2 rad 的阶跃,单环超调 29.89%,上升时间 0.095 s,调节时间 0.513 s;串级(比值 5)超调 0.08%,上升时间 0.186 s,调节时间 0.354 s。单环的超调主要来自上面说的 −5-5 rad/s 零点,不是阻尼不足:把阶跃缩小到 5 mrad,不碰力矩限幅,report 另算了一遍,单环超调是 33.95%,比限幅下的还大 4 个百分点(限幅砍掉了一点峰值)。相角裕度 65.9° 一点也没预报这个超调:相角裕度说的是回路的鲁棒性,不是设定值响应的形状,第 2 篇的教训。

下图的下半部分是力矩:两种回路的第一拍都撞上了 ±1\pm1 N·m 限幅,因为 0.2 rad 对单环是 6.912×0.2=1.386.912\times0.2 = 1.38 N·m,对串级是 12×0.2×1.658=3.9812\times0.2\times1.658 = 3.98 N·m。单环贴了 14 ms,串级(比值 5)贴了 31 ms。这也意味着这一篇所有 0.2 rad 的阶跃响应都包含了饱和,线性分析要靠小阶跃单独验证。

底盘转动#

底盘角速度在 1.0 到 1.5 s 从 0 升到 6 rad/s:单环的云台角度误差鼓到约 7 mrad,串级不到 0.5 mrad,加前馈后基本是噪声
底盘角速度在 1.0 到 1.5 s 从 0 升到 6 rad/s:单环的云台角度误差鼓到约 7 mrad,串级不到 0.5 mrad,加前馈后基本是噪声

这是实验的主角。三条曲线的峰值:

回路峰值误差区间 RMS
单环7.16 mrad3.13 mrad
串级(比值 5)0.41 mrad0.14 mrad
串级 + 前馈0.16 mrad0.06 mrad

串级把峰值砍到 1/17.5,加上底盘角速度前馈砍到 1/44.1。“区间”是 1.0 s 到 3.0 s。斜坡误差公式的预测:单环 6.944 mrad,仿真峰值是它的 1.03 倍;串级预测 0.278 mrad,仿真是 1.47 倍。串级这边偏大,我猜是因为 0.5 s 的斜坡对它的两个快极点够长,但角度测量噪声(10−410^{-4} rad,也就是 0.1 mrad)已经占了峰值的相当一部分,我没有把噪声和斜坡响应拆开验证。两个回路的公式比值是 25 倍(kik_i 之比),仿真峰值的比值是 17.5 倍,差的那部分我猜是噪声,加上 0.5 s 的斜坡对串级的快极点并没有长到稳态。

代价。 稳态时三个回路都要用约 0.12 N·m 的力矩顶住 bfωcb_f\omega_c,所以最后 0.5 s 力矩的 RMS 差不多:单环 0.12008、串级 0.12116 N·m。把 0.12 在平方和意义下减掉,粗算噪声分量是单环约 0.004、串级约 0.017 N·m,差 3.8 倍。这是我对打印数字的心算,report 没有直接打噪声分量。串级的内环增益 kp,i=1.658k_{p,i} = 1.658 把陀螺噪声放大了 3 倍(对比单环的 kdg=0.554k_{dg} = 0.554),这部分是实打实的代价。

带宽比#

比值越大外环越慢也越稳:比值 1.5 的相角裕度只有 61.7° 并且上冲,比值 3 以上超过 85° 且没有超调;精确内环(实线)的相角裕度比一阶近似(虚线)高
比值越大外环越慢也越稳:比值 1.5 的相角裕度只有 61.7° 并且上冲,比值 3 以上超过 85° 且没有超调;精确内环(实线)的相角裕度比一阶近似(虚线)高

数字来自 report 的两张表。先是 0.2 rad 阶跃(含饱和)和线性分析:

回路上升 (s)超调调节 (s)饱和 (ms)PM 精确PM 一阶ωc\omega_c (rad/s)
单环0.09529.89%0.5131465.9°无28.29
串级,比值 1.50.06315.62%0.18113861.7°59.0°50.54
串级,比值 30.1230.07%0.2414885.4°71.8°22.34
串级,比值 50.1860.08%0.3543188.7°78.6°12.49
串级,比值 100.3630.08%0.6621289.6°84.1°6.06

再是 5 mrad 的小阶跃(outer_margins 的相角裕度配上一次不饱和的仿真,这一张表只有 Python 跑,没有跨语言对比):单环 33.946%,比值 1.5 是 4.608%,比值 3、5、10 都是 0.000%。

几个结论:

  • 比值 1.5 是唯一出问题的。 相角裕度 61.7°,不饱和时超调 4.6%,被限幅打断之后(138 ms 贴着上限)超调 15.6%,三倍多。比值 3 以上,线性超调是 0,相角裕度 85° 以上。
  • 比值 3 以后是在用速度换不必要的裕度。 相角裕度从 85.4° 到 88.7° 到 89.6°,只多了 4.2°,上升时间从 0.123 s 到 0.186 s 到 0.363 s,慢了 2.95 倍。在这些假设下,这个对象用比值 3 到 5 就够了;“内环快十倍”的口诀在这里是浪费。
  • 一阶近似偏保守。 比值 1.5 时低 2.7°,比值 3 时低 13.6°。我猜是因为内环 PI 的零点(−43.4-43.4 rad/s)在外环穿越频率附近带来了超前,一阶近似里没有它,我没有把零点拿掉重算来验证。这是我没想到的:粗近似不是“偏乐观”而是“偏悲观”。
  • 大阶跃下比值 5 到 10 的超调都是 0.07% 到 0.08%,差别在噪声量级,report 没有在这上面断言。

内环饱和#

1.5 rad 大阶跃:外环一直积分时角度冲到 2.2 rad(超调 46%),外环积分项涨到 7.3 rad/s;内环饱和时冻结外环积分,只超调 1.6%
1.5 rad 大阶跃:外环一直积分时角度冲到 2.2 rad(超调 46%),外环积分项涨到 7.3 rad/s;内环饱和时冻结外环积分,只超调 1.6%

外环 PI,ko=12k_o = 12、ki,o=28.8k_{i,o} = 28.8,1.5 rad 阶跃:

算例峰值 ψ (rad)超调外环积分峰值 (rad/s)饱和 (ms)调节 (s)
outer_aw_off2.196246.41%7.3294671.240
outer_aw_on1.52381.59%0.3793020.329

冻结外环积分把超调从 46.41% 降到 1.59%(29 倍),积分峰值从 7.329 降到 0.379 rad/s(19 倍),调节时间从 1.240 s 缩到 0.329 s(3.8 倍)。内环饱和的时间也短了,467 ms 对 302 ms:外环不再把速度设定值积到离谱的高度,少饱和了 165 ms。report 把两种饱和分开数了:outer_aw_off 先贴着 +1+1 N·m 229 ms,再贴着 −1-1 N·m 反向刹车 238 ms;outer_aw_on 是 182 ms 和 120 ms,刹车的那一段短了一半。

这些数字的边界#

上面的比较有几处要打折,我逐条列出来:

  • 前馈的 44 倍是上限。 前馈用的是精确的 ωc\omega_c,没有给底盘陀螺加噪声和延迟。真车上的底盘陀螺有噪声,而且和云台陀螺不是同一颗,这 44 倍应当打折,打多少折我没测。
  • 单环不是“最好的单环”。 我选的是三个极点都在 12 rad/s 的配方,它被锁在低增益里;前面已经推过,串级的等效增益塞进单环就是同一个扰动响应。这一篇比较的是“两种整定配方”,不是“单环这种结构”对“串级这种结构”。
  • 单环和串级的 ωc\omega_c 不能直接比。 单环的开环断在对象输入,串级的断在速度设定值,两者的穿越频率是两个不同回路的量。表里放在一起只是为了省地方。
  • 饱和混进了线性分析。 0.2 rad 的阶跃对所有回路都撞限幅,所以阶跃响应的比较要和 5 mrad 的线性表一起看。
  • 冻结策略没有和条件积分比较。 见前面“内环饱和:外环怎么知道”一节。
  • 噪声和参数是假设。 JJ、bb、bfb_f 和两个噪声标准差都是我取的量级,没有测过。一台真的云台,bfb_f 很可能比这小一个量级,那样底盘转动的扰动就没有这里这么大。

检查点#

Terminal window
make -s ch07

通过的标志是 30 个 ok 行最大差异都是 0,report 的断言全过,末尾打印 ch07 OK。编译参数和前几篇一样:C 是 gcc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror(15.3.0),C++ 是 g++ -std=c++20 加同样的警告,Rust 是 rustc 1.95.0、edition 2024,cargo clippy --release -- -D warnings。风格检查:C 用内核的 checkpatch.pl(--no-tree,忽略 SPDX 和 typedef 两项),0 errors、0 warnings;C++ 用 clang-format 的 Google 风格(-Werror);Python 用 ruff format --check 和 ruff check,行宽 79;Rust 用 cargo fmt --check。四项都在提交前过了。

实验清单#

文件命令结果
python/ch07_cascade.pypython3 python/ch07_cascade.py ref,再 report10 个算例的 Python 参考输出;report 断言相角裕度、上升时间、小阶跃超调随比值单调,峰值单环 > 串级 > 串级加前馈,outer_aw_on 超调小于 outer_aw_off
python/pidlab/pid.py第 5 篇的 Pid,本篇不改单环 1 个实例,串级 2 个实例
python/pidlab/freq.py第 3 篇的 margins外环相角裕度:比值 1.5 是 61.7°,3 以上超过 85°
python/pidlab/plants.py,sim.py第 1 篇的 GimbalAxis 和 RK4第三个输入 ωc\omega_c 第一次非零
c/include/pl/pid.h,c/src/pid.c第 5 篇的 struct pl_pid本篇不改
c/apps/ch07.cmake -s ch07写 10 个 *_c.csv,与 Python 最大差异 0
cpp/include/pidlab/pid.h第 5 篇的 Pid<T>本篇不改
cpp/apps/ch07.ccmake -s ch07写 10 个 *_cpp.csv,最大差异 0
rust/src/pid.rs第 5 篇的 Pid本篇不改
rust/src/bin/ch07.rsmake -s ch07写 10 个 *_rs.csv,最大差异 0
out/ch07/,figures/ch07/(构建产物,不入库)make -s ch0740 个 CSV,4 张 SVG(single-vs-cascade、bandwidth-ratio、spin-disturbance、inner-saturation)

下一篇#

这一篇我把云台的偏航轴接成了单环和串级,用同一份带噪声的底盘转动数据去比:串级的角度误差峰值是 0.41 mrad,单环 7.16 mrad(17.5 倍),加底盘角速度前馈是 0.16 mrad;原因不是串级这个结构有什么魔法,而是它把三个增益放成了 (1.658,91.9,864)(1.658, 91.9, 864),单环的配方放成了 (0.554,6.9,34.6)(0.554, 6.9, 34.6),两者最慢的极点都在 12 rad/s 附近。带宽比 1.5 时相角裕度 61.7°,3 以上超过 85°;大阶跃让内环饱和时,外环积分冻结把超调从 46.41% 降到 1.59%。四种语言 30 个对比文件最大差异是 0。

第 8 篇:多变量:耦合、RGA、解耦与麦轮底盘离开云台,去看一个有四个轮子、三个自由度的底盘:四个电机的电流不是互相独立的,一个轮子的力会让整个底盘的速度和转动一起变。我会用 RGA 判断该怎么配对,用解耦器把耦合拆开,并且看到麦轮的零空间电流:四个轮子互相较劲,不产生任何底盘运动,却白白耗电。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
串级 PID:云台的角度环和速度环
https://bfmhno3.github.io/posts/pid-control-07-cascade/
作者
bfmhno3
发布于
2026-10-16
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
工程上的坑:积分饱和、微分冲击与前馈
TutorialPID 控制系列第 5 篇:我用 Python 3.13、gcc 15.3、g++ 15.3 和 rustc 1.95 把四种抗饱和、设定值权重、滤波微分、前馈和无扰动切换收成一个 PID 库,写了四遍。40 rad/s 的阶跃上,不处理饱和的积分让超调到 77.4%,条件积分只有 2.2%;D 项直接看误差,一次 0.1 rad 的设定值阶跃就产生 10.43 N·m 的微分冲击,是 1 N·m 限幅的 10 倍;微分滤波时间常数从 0 加到 10 ms,编码器量化引起的力矩抖动小 13.7 倍;重力前馈把正弦跟踪的 rms 误差缩小 1.84 倍;切换增益时调用 reset,输出跳变从 0.162 N·m 降到 0.0267 N·m。
2
上车之前:float32、定点数与 CAN 量化
TutorialPID 控制系列第 10 篇,也是最后一篇:我用 Python 3.13、gcc 15.3、g++ 15.3、rustc 1.95 和 arm-none-eabi-gcc 15.2,把第 5 篇的 PID 搬到 float32 和 Q16.16 定点,再让它在三种上车会遇到的麻烦里各跑一遍:积分器停滞、CAN 的整数量化、采样周期抖动。Q16.16 在三种语言里和 Python 逐位一致(差异 0),C、C++、Rust 的 float32 也逐位一致;float32 的积分器确实会停,但在 M3508 的速度环上只停在 1.04e-3 rpm 的误差上;CAN 量化把 0.8e-3 rpm 的稳态误差变成 0.298 rpm 的锯齿;±20% 的周期抖动用实测周期补偿,IAE 只差 0.53%。M4F 上 PID 的 float32 版是 556 字节。
3
状态反馈:LQR、LQI 与平衡步兵
TutorialPID 控制系列第 9 篇:我用 Python 3.13、gcc 15.3、g++ 15.3 和 rustc 1.95,把小矩阵运算、矩阵指数(缩放平方加 12 阶泰勒)、ZOH 离散化、Riccati 迭代和 LQI 增广各写了四遍,再加一个新对象:平衡步兵的平面拉格朗日模型。同一份仿真里我比较了 LQR、LQI 和“位置环套速度环套直立环”的串级 PID(五个增益交给 Nelder–Mead 整定)。拉格朗日模型的能量漂移是 1.06e-13;LQR 在 10 N 的推力下稳态偏离 0.826 m,LQI 能把它拉回 0,但 5 秒内没拉完,阶跃的超调还是 38.8%(LQR 是 2.3%);整定好的串级把积分项整定成了 1.5e-12,成了一个 LQR 的 0.87 倍缩放版,代价 6.162 对 6.163;LQI 的增益向量落在串级能表达的范围之外(判别式 −0.0618)。四种语言 42 个对比文件最大差异 5.06e-13。
4
多变量:耦合、RGA、解耦与麦轮底盘
TutorialPID 控制系列第 8 篇:我用 Python 3.13、gcc 15.3、g++ 15.3 和 rustc 1.95,把 2×2 的相对增益阵列(RGA)和静态解耦器、麦轮的运动学(最小范数电流分配、零空间分量)、Wood–Berry 精馏塔和麦轮底盘两个新对象各写了四遍,控制器全用第 5 篇的 Pid。Wood–Berry 的 λ11 = 2.0094,按对角配对的两个 SIMC PI 能用,配成非对角(λ12 = −1.0094)则发散,|y2| 每 13.9 分钟翻一倍;静态解耦把 y2 阶跃的 IAE 从 11.87 降到 7.29,却把 y1 的超调从 17.2% 推到 65.6%。麦轮底盘撞上轮速上限时,逐轮截断让零空间电流 nᵀi 的均方根达到 28.54 A,(vx, vy) 的方向偏了 11.98°,2 s 末位置偏离理想路径 2.18 m;逐轮等比缩放和车体系 PI 的零空间电流只有 3e-13 A 以下,方向偏差 0.001° 以内。四种语言 21 个对比文件最大差异不超过 2.2e-16。
5
整定:Ziegler–Nichols、继电器自整定与 SIMC
TutorialPID 控制系列第 6 篇:我用 Python 3.13、gcc 15.3、g++ 15.3 和 rustc 1.95 把两点阶跃拟合、继电器自整定、两套 Ziegler–Nichols 规则和 SIMC 各写了四遍,在摩擦轮和 1/(s+1)^4 上比较。两点法在摩擦轮上把 3.00 ms 的等效延迟拟合成 1.39 ms,Ziegler–Nichols 阶跃法因此给出相位裕度 -48.7° 的增益;带 2 rad/s 滞环的继电器读到的 Ku 是 4.81,真值是 12.87,因为它测的不是 -180° 那一点;四阶对象上继电器的 Ku 只比真值低 1.7%;SIMC 的超调 6.8%,Ziegler–Nichols 继电器法 36.2%,代价是负载 IAE 3.75 对 1.38。
随机文章随机推荐

评论区

Profile Image of the Author
bfmhno3
Hello, I'm bfmhno3.
公告
本站曾使用 Jekyll + Minimal Mistakes 主题,现已迁移至 Astro + Firefly。博客内容未变,但文章链接有所调整,请自行查找需要的文章。
分类
标签
最新动态
站点统计
文章
123
分类
2
标签
239
总字数
753,253
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录