串级 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:
是云台相对世界的角度, 是电机力矩(限幅 N·m), 是底盘的角速度。最后一项是轴承的粘性耦合:底盘转起来,轴承会把云台往同一个方向拖。参数 kg·m²、、 全是我取的量级,不是实测。令 ,,速度子系统是
对云台来说, 就是一个力矩扰动 :底盘到 6 rad/s 时是 N·m,占力矩上限的 12%。这个扰动是一段斜坡:我让 从 1.0 s 到 1.5 s 线性升到 6 rad/s,之后保持。
测量有两路:角度 , rad;陀螺 , rad/s。噪声是 Python 用 numpy 种子 7 生成、写进数据 CSV 的,三种原生语言读同一份。这两个数字的出处要交代清楚:卡尔曼系列第 4 篇的陀螺仿真用的是 0.005 rad/s,那是“典型消费级 MEMS 的量级”;我这里取了它的 2 倍,是为了让噪声看得见,不是那一篇的结论。角度的 rad(0.0057°)是我假设的姿态估计精度,也不是那一篇测出来的。
单环:三个增益摆三个极点
先写单环,它是对照组。用第 5 篇的 Pid,只开 P 和 I,D 用陀螺:陀螺读数本身就是角度的导数,不需要对带噪声的角度再差分一次。控制律是
陀螺项通过 Pid.update 的 ff 参数送进去(),它进入限幅前的 ,所以抗积分饱和照样看得见它。代入对象,特征多项式是
要的极点是一个实极点 加一个 的共轭对,即 。对系数得到
取 rad/s( rad/s 是后面串级内环的带宽,这样两边“外环带宽”相同,对比才公平):,,。极点是 和 (report 打印的)。
这个单环有一个第 2 篇已经讲过的毛病:闭环传递函数 有一个零点在 rad/s,比极点慢,所以阶跃会超调,不管阻尼多好。我会在结果里看到这一点。
串级:内环管速度,外环管角度
串级把这三个增益拆成两层。内环是速度 PI,设定值是 ,测量是陀螺:
被控对象 是一阶的,和第 2 篇的电机速度环同一个形状,所以直接套那一篇的极点配置,闭环传递函数是
令 、 rad/s:,。这个 PI 有一个零点在 rad/s,比 还低一点,稍后会看到它对外环有用。
外环是一个纯 P,输入角度误差,输出内环的速度设定值:,。比值就是“内环比外环快多少倍”,是这一篇的主要旋钮。整条链路是:
虚线是这一篇最后一节才用到的:内环把它有没有饱和告诉外环。
外环看到的对象不是云台,而是“内环加一个积分器”:。如果把 粗略当成一阶的 ,外环的特征方程是 ,阻尼比 ,比值到 4 就是临界阻尼;相角裕度是 :内环对外环来说就是一个滞后,比值越大,这个滞后在外环穿越频率处吃掉的相位越少。我不信一个近似,所以 report 用第 3 篇的 freq 在精确的 上也算一遍(延迟取 ZOH 的半个采样周期)。
串级为什么抗扰:等效 PID
回到 17.5 倍。扰动力矩 到角度 的传递函数,单环和串级是同一个形状(令 ,把控制律代进去):
单环的 就是 ;串级的是等效增益
代入数字,比值 5():
| 回路 | 极点 | |||
|---|---|---|---|---|
| 单环 | 0.554 | 6.912 | 34.56 | , |
| 串级 | 1.658 | 91.896 | 864.0 | , |
两者最慢的极点几乎在同一个位置( 和 ),所以“外环带宽”确实是一样的;串级多出来的是一对 60 rad/s 量级的快极点,换来了 13.3 倍的 和 25 倍的 。对一段斜率为 的力矩斜坡,这个 形式的闭环由终值定理给出稳态角度误差 :
单环是 mrad,串级是 mrad。换个说法更直观:内环的 PI 先把速度误差压到 mrad/s,外环再把它除以 ,就是 0.278 mrad。扰动在到达角度之前,已经在速度这一层被消掉了大部分。 类比的话,这像工厂里班组先把小故障在车间内消化,不往上报给调度:车间的响应快,所以故障还没传远就被吃掉。
要把话说准:这不是说单环做不到。串级的 直接塞进一个单环 PID,特征多项式和扰动响应完全一样。差别是单环用的是我选的“三个极点都放在 12 rad/s”的配方,三个增益被这个配方锁死了;串级的配方是“先把内环做到 60 rad/s,外环按比值再降”,它用同样的三个增益自由度,摆出了更刚的一组极点,并且让整定变成两个有物理含义的旋钮:内环带宽和比值。设定值通道也不同(外环 P 没有积分,没有那个 rad/s 的零点)。
底盘角速度前馈。 底盘陀螺(底盘上本来就有)给出 ,所以扰动 是已知的,可以直接在内环的输出上抵消。符号要小心:云台静止()时,耦合项对云台的力矩是 ,所以前馈是 。
带宽比:多快才算“快得够”
比值太小,内环在外环穿越频率处的滞后就大,相角裕度掉;比值太大,外环慢得没必要,而且你把整条链路的响应速度扔掉了。我在 四个比值上扫: rad/s,内环都不动。
内环饱和:外环怎么知道
云台要转一个大角度,比如 1.5 rad,外环给出 rad/s 的速度设定值。力矩上限 1 N·m 对应的最大角加速度是 rad/s²,加速到 18 rad/s 至少要 0.36 s。这段时间里内环的力矩一直贴在上限,内环饱和了,而外环自己并没有饱和:外环输出的是速度设定值,我没有给它限幅。所以第 5 篇的条件积分(CLAMP)在外环看不出任何问题,如果外环带积分(),它会一直在 1.5 rad 的误差上累加,最后速度设定值积出来一大块,角度冲过头。
解法在第 5 篇的 hold_integral 里:驱动把内环上一个采样的 saturated 标志传给外环,外环这一拍不更新积分:
延迟一拍是因为内环要在外环之后才更新;我猜 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, kdgCase 里的五个字段决定一个算例: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 时 rad)、ch07_spin.csv(3000 行,, 从 1.0 s 到 1.5 s 线性升到 6 rad/s)、ch07_aw.csv(3000 行,0.05 s 时 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,此时它还是上一拍的值,这就是上面公式里的 。
裕度分析用第 3 篇的 freq。外环的开环是 ,写成多项式比:
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 是“把内环当一阶滞后”的粗近似。单环的开环 (断在对象输入处)也有一个 single_margins:开环有双积分器,freq 的主值相位在起点落在 的支路上,所以我把算出来的相角裕度折回 ,在源码里留了注释。
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++
外环配置先按单环填好(带 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 ch07ch07: C wrote 10 cases to out/ch07ch07: C++ wrote 10 cases to out/ch07ch07: Rust wrote 10 cases to out/ch07ok cascade_r1.5_step_c.csv: max diff 0.000e+00 <= 1.0e-09ok cascade_r1.5_step_cpp.csv: max diff 0.000e+00 <= 1.0e-09ok 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-09ok single_step_cpp.csv: max diff 0.000e+00 <= 1.0e-09ok single_step_rs.csv: max diff 0.000e+00 <= 1.0e-09inner rate PI at zeta 0.7, wn 60.0: kp = 1.6580, ki = 72.000single loop (poles at 12 rad/s): kp = 6.9120, ki = 34.5600, kd_gyro = 0.5540outer 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.00jtorque 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.12116cascade 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.329sfigure: figures/ch07/single-vs-cascade.svgfigure: figures/ch07/bandwidth-ratio.svgfigure: figures/ch07/spin-disturbance.svgfigure: figures/ch07/inner-saturation.svg
ch07 OK30 个文件的最大差异都是严格的 0.000e+00,不是“在 以内”。这一篇的循环里没有 sin、cos、exp,只有加减乘除和比较,IEEE 754 的基本运算在三种语言里是同一个结果,只要运算顺序一样(我照着 Python 的顺序写的),比特都一样。前面并不总是这样干净:第 6 篇的最大差异是 ,我没有追查原因,猜是 libm 函数的末位差别。
单环对串级:阶跃
0.2 rad 的阶跃,单环超调 29.89%,上升时间 0.095 s,调节时间 0.513 s;串级(比值 5)超调 0.08%,上升时间 0.186 s,调节时间 0.354 s。单环的超调主要来自上面说的 rad/s 零点,不是阻尼不足:把阶跃缩小到 5 mrad,不碰力矩限幅,report 另算了一遍,单环超调是 33.95%,比限幅下的还大 4 个百分点(限幅砍掉了一点峰值)。相角裕度 65.9° 一点也没预报这个超调:相角裕度说的是回路的鲁棒性,不是设定值响应的形状,第 2 篇的教训。
下图的下半部分是力矩:两种回路的第一拍都撞上了 N·m 限幅,因为 0.2 rad 对单环是 N·m,对串级是 N·m。单环贴了 14 ms,串级(比值 5)贴了 31 ms。这也意味着这一篇所有 0.2 rad 的阶跃响应都包含了饱和,线性分析要靠小阶跃单独验证。
底盘转动
这是实验的主角。三条曲线的峰值:
| 回路 | 峰值误差 | 区间 RMS |
|---|---|---|
| 单环 | 7.16 mrad | 3.13 mrad |
| 串级(比值 5) | 0.41 mrad | 0.14 mrad |
| 串级 + 前馈 | 0.16 mrad | 0.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 的斜坡对它的两个快极点够长,但角度测量噪声( rad,也就是 0.1 mrad)已经占了峰值的相当一部分,我没有把噪声和斜坡响应拆开验证。两个回路的公式比值是 25 倍( 之比),仿真峰值的比值是 17.5 倍,差的那部分我猜是噪声,加上 0.5 s 的斜坡对串级的快极点并没有长到稳态。
代价。 稳态时三个回路都要用约 0.12 N·m 的力矩顶住 ,所以最后 0.5 s 力矩的 RMS 差不多:单环 0.12008、串级 0.12116 N·m。把 0.12 在平方和意义下减掉,粗算噪声分量是单环约 0.004、串级约 0.017 N·m,差 3.8 倍。这是我对打印数字的心算,report 没有直接打噪声分量。串级的内环增益 把陀螺噪声放大了 3 倍(对比单环的 ),这部分是实打实的代价。
带宽比
数字来自 report 的两张表。先是 0.2 rad 阶跃(含饱和)和线性分析:
| 回路 | 上升 (s) | 超调 | 调节 (s) | 饱和 (ms) | PM 精确 | PM 一阶 | (rad/s) |
|---|---|---|---|---|---|---|---|
| 单环 | 0.095 | 29.89% | 0.513 | 14 | 65.9° | 无 | 28.29 |
| 串级,比值 1.5 | 0.063 | 15.62% | 0.181 | 138 | 61.7° | 59.0° | 50.54 |
| 串级,比值 3 | 0.123 | 0.07% | 0.241 | 48 | 85.4° | 71.8° | 22.34 |
| 串级,比值 5 | 0.186 | 0.08% | 0.354 | 31 | 88.7° | 78.6° | 12.49 |
| 串级,比值 10 | 0.363 | 0.08% | 0.662 | 12 | 89.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 的零点( rad/s)在外环穿越频率附近带来了超前,一阶近似里没有它,我没有把零点拿掉重算来验证。这是我没想到的:粗近似不是“偏乐观”而是“偏悲观”。
- 大阶跃下比值 5 到 10 的超调都是 0.07% 到 0.08%,差别在噪声量级,
report没有在这上面断言。
内环饱和
外环 PI,、,1.5 rad 阶跃:
| 算例 | 峰值 ψ (rad) | 超调 | 外环积分峰值 (rad/s) | 饱和 (ms) | 调节 (s) |
|---|---|---|---|---|---|
outer_aw_off | 2.1962 | 46.41% | 7.329 | 467 | 1.240 |
outer_aw_on | 1.5238 | 1.59% | 0.379 | 302 | 0.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 先贴着 N·m 229 ms,再贴着 N·m 反向刹车 238 ms;outer_aw_on 是 182 ms 和 120 ms,刹车的那一段短了一半。
这些数字的边界
上面的比较有几处要打折,我逐条列出来:
- 前馈的 44 倍是上限。 前馈用的是精确的 ,没有给底盘陀螺加噪声和延迟。真车上的底盘陀螺有噪声,而且和云台陀螺不是同一颗,这 44 倍应当打折,打多少折我没测。
- 单环不是“最好的单环”。 我选的是三个极点都在 12 rad/s 的配方,它被锁在低增益里;前面已经推过,串级的等效增益塞进单环就是同一个扰动响应。这一篇比较的是“两种整定配方”,不是“单环这种结构”对“串级这种结构”。
- 单环和串级的 不能直接比。 单环的开环断在对象输入,串级的断在速度设定值,两者的穿越频率是两个不同回路的量。表里放在一起只是为了省地方。
- 饱和混进了线性分析。 0.2 rad 的阶跃对所有回路都撞限幅,所以阶跃响应的比较要和 5 mrad 的线性表一起看。
- 冻结策略没有和条件积分比较。 见前面“内环饱和:外环怎么知道”一节。
- 噪声和参数是假设。 、、 和两个噪声标准差都是我取的量级,没有测过。一台真的云台, 很可能比这小一个量级,那样底盘转动的扰动就没有这里这么大。
检查点
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.py | python3 python/ch07_cascade.py ref,再 report | 10 个算例的 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/include/pl/pid.h,c/src/pid.c | 第 5 篇的 struct pl_pid | 本篇不改 |
c/apps/ch07.c | make -s ch07 | 写 10 个 *_c.csv,与 Python 最大差异 0 |
cpp/include/pidlab/pid.h | 第 5 篇的 Pid<T> | 本篇不改 |
cpp/apps/ch07.cc | make -s ch07 | 写 10 个 *_cpp.csv,最大差异 0 |
rust/src/pid.rs | 第 5 篇的 Pid | 本篇不改 |
rust/src/bin/ch07.rs | make -s ch07 | 写 10 个 *_rs.csv,最大差异 0 |
out/ch07/,figures/ch07/(构建产物,不入库) | make -s ch07 | 40 个 CSV,4 张 SVG(single-vs-cascade、bandwidth-ratio、spin-disturbance、inner-saturation) |
下一篇
这一篇我把云台的偏航轴接成了单环和串级,用同一份带噪声的底盘转动数据去比:串级的角度误差峰值是 0.41 mrad,单环 7.16 mrad(17.5 倍),加底盘角速度前馈是 0.16 mrad;原因不是串级这个结构有什么魔法,而是它把三个增益放成了 ,单环的配方放成了 ,两者最慢的极点都在 12 rad/s 附近。带宽比 1.5 时相角裕度 61.7°,3 以上超过 85°;大阶跃让内环饱和时,外环积分冻结把超调从 46.41% 降到 1.59%。四种语言 30 个对比文件最大差异是 0。
第 8 篇:多变量:耦合、RGA、解耦与麦轮底盘离开云台,去看一个有四个轮子、三个自由度的底盘:四个电机的电流不是互相独立的,一个轮子的力会让整个底盘的速度和转动一起变。我会用 RGA 判断该怎么配对,用解耦器把耦合拆开,并且看到麦轮的零空间电流:四个轮子互相较劲,不产生任何底盘运动,却白白耗电。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!











