Agenda¶
Part A -- Proportional control: servo, regulator and offset (Sec. 12.1-12.2)
Part B -- PI control and measurement lag (Sec. 12.3-12.5)
Part C -- Stability and the Routh test (Ch. 13)
Part D -- Root locus (Ch. 14)
Setup (run once)¶
- The standard imports, as in Lectures 3-4
import numpy as np
import matplotlib.pyplot as plt
import sympy as sp
import control as ct
from scipy.integrate import solve_ivp
from scipy.optimize import curve_fit
plt.rcParams.update({"font.size": 15})
Introduce -- the stirred-tank heater, closed (Fig. 12-1e)¶
- Lecture 4's tank: $\tau = 5$ min, $A = 1/wC = 1/14$ C/kW.
- Unity feedback (fast thermocouple), valve gain 1, P controller $K_c$.
- Single loop (Ch. 11): $\dfrac{C}{R} = \dfrac{G_1G_2}{1 + G_1G_2H}$, $\;\dfrac{C}{U} = \dfrac{G_2}{1 + G_1G_2H}$.
- Lecture 4 found 2.94 C for a 5 C set-point step with $K_c = 20$. Why not 5?
Derive 1/3 -- servo: still first order (Eq. 12.2)¶
$G_1 = K_c$, $G_2 = A/(\tau s + 1)$, $H = 1$:
$$\frac{T'}{T_R'} = \frac{K_cA}{\tau s + 1 + K_cA} = \frac{A_1}{\tau_1 s + 1},\quad \tau_1 = \frac{\tau}{1 + K_cA},\quad A_1 = \frac{K_cA}{1 + K_cA}$$
Feedback makes the response faster ($\tau_1 < \tau$).
Derive 2/3 -- offset¶
Final value for a unit step, minus the request:
$$\text{Offset} = 1 - A_1 = \frac{1}{1 + K_cA} \qquad (12.4)$$
Ex. 12.1 (5 C step): $K_c = 5, 10, 20, 100$ give offsets $3.69, 2.92, 2.06, 0.614$ C.
Derive 3/3 -- regulator: the same lag, the same offset¶
Load $T_i$ with $T_R' = 0$ (Eq. 12.5-12.7):
$$\frac{T'}{T_i'} = \frac{1}{\tau s + 1 + K_cA} = \frac{1/(1+K_cA)}{\tau_1 s + 1},\qquad \text{Offset} = -\frac{1}{1 + K_cA}$$
Code 1/3 -- block algebra, symbolically¶
- Mirrors Derive 1/3: the single-loop formula, simplified
sp.solvereads $\tau_1$ and $A_1$ off the standard form
s, Kc, A, tau = sp.symbols("s K_c A tau", positive=True)
G1, G2, H = Kc, A / (tau*s + 1), 1
servo = sp.simplify(G1*G2 / (1 + G1*G2*H))
load = sp.simplify(G2 / (1 + G1*G2*H) * (tau*s + 1) / A)
print(servo, "|", sp.simplify(load / (tau*s + 1)))
tau1, A1 = sp.symbols("tau_1 A_1")
print(sp.solve([sp.Eq(tau1, tau/(1 + Kc*A)),
sp.Eq(A1, servo.subs(s, 0))], [tau1, A1]))
A*K_c/(A*K_c + s*tau + 1) | 1/(A*K_c + s*tau + 1)
{A_1: A*K_c/(A*K_c + 1), tau_1: tau/(A*K_c + 1)}
Code 2/3 -- Example 12.1's offset table¶
- Mirrors Derive 2/3: offset $= 5/(1 + K_cA)$ for a 5 C step
A_v = 1 / 14 # C per kW
for kc in [5, 10, 20, 100]:
off = 5 / (1 + kc * A_v)
tau1_v = 5 / (1 + kc * A_v)
print(f"Kc = {kc:3}: offset {off:.3f} C, tau1 {tau1_v:.2f} min")
Kc = 5: offset 3.684 C, tau1 3.68 min Kc = 10: offset 2.917 C, tau1 2.92 min Kc = 20: offset 2.059 C, tau1 2.06 min Kc = 100: offset 0.614 C, tau1 0.61 min
Code 3/3 -- simulate both problems (Figs. 12-5, 12-8)¶
ct.feedbackfor the servo; the load path is $G_2/(1+G_1G_2)$
tt = np.linspace(0, 15, 300)
fig, (a1, a2) = plt.subplots(1, 2, figsize=(7, 5.25), sharey=True)
for kc in [5, 10, 20, 100]:
Gp = ct.tf([1/14], [5, 1])
sv = ct.feedback(kc * Gp, 1)
ld = ct.feedback(ct.tf([1], [5, 1]), kc * ct.tf([1/14], [1]))
a1.plot(tt, ct.step_response(5 * sv, tt).outputs)
a2.plot(tt, ct.step_response(5 * ld, tt).outputs, label=kc)
a1.set(title="set point +5 C", xlabel="t (min)", ylabel="T' (C)")
a2.set(title="T_i +5 C", xlabel="t (min)"); a2.legend(title="Kc")
plt.show()
Your Turn (8 min)¶
Ex. 12.2. $T_i$ rises 5 C with the set point fixed, $K_c = 20$.
- By hand: the final $T'$ and the offset.
- Read the code: what does this print, to three decimals?
print(ct.dcgain(ct.feedback(20 * ct.tf([1/14], [5, 1]), 1)))
Check -- cold-call¶
Feedback made the closed-loop time constant smaller than the tank's own $\tau = 5$ min. The tank did not change. Where does the speed come from?
Introduce -- removing offset, and the price¶
- PI: $G_c = K_c(1 + 1/\tau_I s)$. Integral action keeps pushing until the error is zero.
- The closed loop becomes second order, so it can oscillate.
- A lag in the measuring element does the same even with P only (Sec. 12.5).
Derive 1/3 -- PI, load change (Eq. 12.9)¶
Insert $G_c$ in $G_2/(1 + G_cG_2)$ and rearrange:
$$\frac{T'}{T_i'} = \frac{A_1s}{\tau_1^2s^2 + 2\zeta\tau_1s + 1},\; \tau_1 = \sqrt{\frac{\tau\tau_I}{K_cA}},\; \zeta = \frac12\sqrt{\frac{\tau_I}{\tau}}\frac{1 + K_cA}{\sqrt{K_cA}}$$
The $s$ in the numerator: a step in $T_i$ gives zero offset.
Derive 2/3 -- Example 12.3 numbers¶
$K_c = 20$, $\tau_I = 2$ min, $\tau = 5$ min, $A = 1/14$:
$K_cA = 1.429$, $\;\tau_1 = \sqrt{10/1.429} = 2.65$ min, $\;\zeta = \frac12\sqrt{0.4}\,\frac{2.429}{1.195} = 0.64$.
Table: $K_c\uparrow \Rightarrow \zeta\uparrow$; $\;\tau_I\downarrow \Rightarrow \zeta\downarrow$ (more oscillation, smaller peak).
Derive 3/3 -- P control with measurement lag (Eq. 12.17)¶
$H = 1/(\tau_ms + 1)$ makes the denominator quadratic:
$$\frac{T'}{T_R'} = \frac{A_1(\tau_ms+1)}{\tau_2^2s^2 + 2\zeta_2\tau_2s+1}, \quad \zeta_2 = \frac{\tau + \tau_m}{2\sqrt{\tau\tau_m}}\frac{1}{\sqrt{1 + K_cA}}$$
$\zeta_2$ falls as $K_c$ or $\tau_m$ grows: more oscillation.
Code 1/3 -- Eq. 12.9 by SymPy¶
- Mirrors Derive 1/3: PI in the load loop, then match the standard form
tI = sp.symbols("tau_I", positive=True)
Gc = Kc * (1 + 1 / (tI * s))
pi_load = sp.cancel(1 / (tau*s + 1) / (1 + Gc * A / (tau*s + 1)))
num, den = sp.fraction(pi_load)
den = sp.Poly(den, s).all_coeffs() # [a2, a1, a0]
t1 = sp.sqrt(den[0] / den[2])
z = sp.simplify(den[1] / den[2] / (2 * t1))
print(num, "|", den); print("tau1 =", t1, " zeta =", z)
s*tau_I | [tau*tau_I, A*K_c*tau_I + tau_I, A*K_c] tau1 = sqrt(tau)*sqrt(tau_I)/(sqrt(A)*sqrt(K_c)) zeta = sqrt(tau_I)*(A*K_c + 1)/(2*sqrt(A)*sqrt(K_c)*sqrt(tau))
Code 2/3 -- Example 12.3, both sweeps (Figs. 12-12, 12-13)¶
- Left: $\tau_I = 2$, vary $K_c$. Right: $K_c = 20$, vary $\tau_I$
tt = np.linspace(0, 30, 400); load = ct.tf([1], [5, 1])
def pi_ctrl(kc, ti): return ct.tf([kc * ti, kc], [ti, 0])
def resp(kc, ti): # T_i +5 C, PI loop
L = ct.feedback(load, pi_ctrl(kc, ti) * ct.tf([1/14], [1]))
return ct.step_response(5 * L, tt).outputs
fig, (a1, a2) = plt.subplots(1, 2, figsize=(7, 5.25), sharey=True)
for kc in [5, 10, 20, 100]: a1.plot(tt, resp(kc, 2), label=kc)
for ti in [1, 2, 5, 10]: a2.plot(tt, resp(20, ti), label=ti)
a1.legend(title="Kc"); a2.legend(title="tau_I")
a1.set(xlabel="t (min)", ylabel="T' (C)"); a2.set(xlabel="t (min)")
plt.show()
Code 3/3 -- measurement lag hurts (Example 12.5)¶
- PI with $K_c = 20$, $\tau_I = 2$; thermocouple lag $\tau_m$ grows
tt = np.linspace(0, 40, 500)
fig, ax = plt.subplots(figsize=(7, 5.25))
for tm in [0.33, 1.0, 2.0, 5.0]:
fwd = pi_ctrl(20, 2) * ct.tf([1/14], [5, 1])
L = ct.feedback(fwd, ct.tf([1], [tm, 1]))
ax.plot(tt, ct.step_response(5 * L, tt).outputs, label=tm)
ax.axhline(5, ls="--", c="gray")
ax.set(xlabel="t (min)", ylabel="T' (C)")
ax.legend(title="tau_m (min)"); plt.show()
Your Turn (8 min)¶
- By hand: $\zeta$ of Eq. 12.9 for $K_c = 100$, $\tau_I = 2$ min.
- Read the code: in Code 3/3, which value of
tmgives the largest overshoot, and does any curve settle away from 5 C?
Check -- poll¶
You need less oscillation in a PI load response without changing $\tau_I$. You should:
A) raise $K_c$ B) lower $K_c$ C) slow the thermocouple
Introduce -- three lags and a P controller (Fig. 13-1)¶
- Lags $\tau_1 = 1$, $\tau_2 = 1/2$, $\tau_3 = 1/3$ (the last in the measurement); P controller $K_c$.
- Fig. 13-2: beyond some $K_c$ the oscillations grow.
- Stable: every root of $1 + G(s) = 0$ in the left half-plane (BIBO; imaginary axis counts as unstable).
Derive 1/3 -- the characteristic equation (Eq. 13.11)¶
$1 + K_c/[(s+1)(s/2+1)(s/3+1)] = 0$, multiply out by 6:
$$s^3 + 6s^2 + 11s + 6(1 + K_c) = 0$$
Derive 2/3 -- the Routh array (Ex. 13.3)¶
Rows from the coefficients; third row $b_1 = (a_1a_2 - a_0a_3)/a_1$:
| row | ||
|---|---|---|
| 1 | 1 | 11 |
| 2 | 6 | $6(1+K_c)$ |
| 3 | $10 - K_c$ | |
| 4 | $6(1+K_c)$ |
All positive (Theorem 13.1) $\Rightarrow$ stable for $K_c < 10$.
Derive 3/3 -- on the boundary (Theorem 13.3)¶
At $K_c = 10$ row 3 vanishes; row 2 gives the imaginary pair:
$$6s^2 + 66 = 0 \Rightarrow s = \pm j\sqrt{11} = \pm 3.317j, \qquad s_3 = -6$$
Code 1/3 -- a Routh array in nine lines¶
- Mirrors Derive 2/3: each new row from the two rows above it
- Works for numbers and symbols ($K_c$)
def routh(c):
c = [sp.nsimplify(x) for x in c]
n, cols = len(c), (len(c) + 1) // 2
R = [c[0::2] + [0]*(cols - len(c[0::2])),
c[1::2] + [0]*(cols - len(c[1::2]))]
for i in range(2, n):
a, b = R[i - 2], R[i - 1]
R.append([sp.simplify((b[0]*a[j+1] - a[0]*b[j+1]) / b[0])
for j in range(cols - 1)] + [0])
return [r[0] for r in R] # the first column
Code 2/3 -- Example 13.3 with $K_c$ left as a symbol¶
- Mirrors Derive 2/3-3/3: first column, then the boundary
col = routh([1, 6, 11, 6 * (1 + Kc)])
print("first column:", col)
Kcu = sp.solve(sp.Eq(col[2], 0), Kc)[0]
print("boundary Kc =", Kcu)
print(np.roots([1, 6, 11, float(6 * (1 + Kcu))]))
first column: [1, 6, 10 - K_c, 6*K_c + 6] boundary Kc = 10 [-6.00000000e+00+0.j -1.94289029e-16+3.31662479j -1.94289029e-16-3.31662479j]
Code 3/3 -- Examples 13.2 and 13.4: count sign changes¶
- Theorem 13.2: sign changes in the first column = roots in the RHP
for name, c in [("13.2", [1, 3, 5, 4, 2]),
("13.4 (PI)", [1, 6, 11, 36, 120])]:
col = routh(c)
flips = sum(np.sign(float(col[i])) != np.sign(float(col[i+1]))
for i in range(len(col) - 1))
rhp = sum(np.roots(c).real > 0)
print(f"Ex {name}: column {col}, changes {flips}, RHP roots {rhp}")
Build on it -- find the boundary by brute force¶
- No Routh: roots for 1501 gains; the first with a RHP root wins
gains = np.linspace(0, 15, 1501)
worst = np.array([np.roots([1, 6, 11, 6 * (1 + k)]).real.max()
for k in gains])
print(f"first unstable gain: {gains[np.argmax(worst > 0)]:.2f}")
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(gains, worst); ax.axhline(0, c="k", lw=0.8)
ax.axvline(10, ls="--", c="gray")
ax.set(xlabel="Kc", ylabel="largest real part of the roots")
plt.show()
first unstable gain: 10.01
Build on it -- the responses of Fig. 13-2¶
- Unit set-point step for gains either side of 10
tt = np.linspace(0, 12, 600)
G = ct.tf([1], [1, 1]) * ct.tf([1], [0.5, 1]) # process lags
H = ct.tf([1], [1/3, 1]) # measurement
fig, ax = plt.subplots(figsize=(7, 5.25))
for kc in [1, 6, 9, 12]:
y = ct.step_response(ct.feedback(kc * G, H), tt).outputs
ax.plot(tt, y, label=f"Kc = {kc}")
ax.set(xlabel="t", ylabel="C(t)", ylim=(-1, 3))
ax.legend(loc="upper left"); plt.show()
Your Turn (10 min)¶
Prob. 13.11. $s^4 + 4s^3 + 6s^2 + 4s + (1 + K) = 0$.
- By hand: the Routh array; the $K$ above which it is unstable.
- Read the code: what is printed?
print(routh([1, 4, 6, 4, 1 + sp.Symbol("K")]))
Check -- think-pair-share¶
Routh says $K_c = 9$ is stable. The $K_c = 9$ curve rings for a very long time. What information does Routh not give, and which tool in this lecture does?
Introduce -- watch the roots move with the gain¶
- Open loop $G(s) = \dfrac{K}{(s+1)(s+2)(s+3)}$, $K = 6K_c$ (Eq. 14.4): poles at $-1, -2, -3$.
- The root locus plots the roots of $1 + G = 0$ as $K$ goes from 0 up.
- Branches start at the open-loop poles; complex roots come in pairs.
Derive 1/2 -- where the locus crosses the axis¶
Routh with $K$: $b_1 = [66 - (K + 6)]/6 = 0$:
$K = 60$ ($K_c = 10$); substitute $s = ja$: $(66 - 6a^2) + j(11a - a^3) = 0 \Rightarrow a = \pm\sqrt{11} = 3.32$.
Derive 2/2 -- integral action moves the crossing¶
PI with $\tau_I = 1/4$ adds a pole at 0 and a zero at $-4$:
$$s(s+1)(s+2)(s+3) + K(s+4) = 0$$
Crossing at $K = 3.84$ instead of 60: integral action destabilizes (Fig. 14-5).
Code 1/2 -- Table 14.1 from np.roots¶
- Mirrors Derive 1/2: roots of $(s+1)(s+2)(s+3) + K$ for the book's K
base = np.poly([-1, -2, -3]) # (s+1)(s+2)(s+3)
for K in [0, 0.39, 1.58, 6.6, 26.5, 60, 100]:
r = np.roots(base + np.array([0, 0, 0, K]))
print(f"K = {K:5}: {np.round(np.sort_complex(r), 2)}")
K = 0: [-3.+0.j -2.+0.j -1.+0.j] K = 0.39: [-3.16+0.j -1.42-0.05j -1.42+0.05j] K = 1.58: [-3.45+0.j -1.28-0.75j -1.28+0.75j] K = 6.6: [-4.05+0.j -0.97-1.47j -0.97+1.47j] K = 26.5: [-5.09+0.j -0.45-2.49j -0.45+2.49j] K = 60: [-6.+0.j -0.-3.32j -0.+3.32j] K = 100: [-6.71+0.j 0.36-3.96j 0.36+3.96j]
Code 2/2 -- the locus drawn by python-control¶
ct.root_locus_plottraces all branches for $K$ from 0 up
G_ol = ct.tf([1], [1, 6, 11, 6])
G_pi = ct.tf([1, 4], [1, 6, 11, 6, 0]) # PI, tau_I = 1/4
fig, (a1, a2) = plt.subplots(1, 2, figsize=(7, 5.25))
ct.root_locus_plot(G_ol, ax=a1)
ct.root_locus_plot(G_pi, ax=a2)
a1.set_title("P control (Fig. 14-2)", fontsize=11)
a2.set_title("PI control (Fig. 14-5)", fontsize=11)
plt.tight_layout(); plt.show()
Build on it -- Example 14.1, two crossings¶
- PID on a two-tank process with a measuring lag
- Scan $K_c$ on a log grid; report where the largest real part changes sign
num = np.array([2/3, 1, 1/3]) # PID numerator / s
den = np.polymul(np.polymul([20, 1], [10, 1]),
np.polymul([0.5, 1], [1, 0]))
gains = np.logspace(-2, 3, 20000)
worst = np.array([np.roots(np.polyadd(den, k * num)).real.max()
for k in gains])
flip = gains[1:][np.diff(np.sign(worst)) != 0]
print("stability changes at Kc =", np.round(flip, 3))
stability changes at Kc = [ 0.728 204.864]
Your Turn (8 min)¶
- By hand: from Table 14.1, between which two values of $K$ does the response change from non-oscillatory to oscillatory?
- Read the code: what three roots does this print?
print(np.round(np.roots([1, 6, 11, 66]), 3))
Check -- cold-call¶
The book says root locus is "difficult to apply to systems containing transportation lags". Why -- and what did we use in Lecture 4 that would let us try anyway?
Wrap-up¶
Derived, then coded: P control as a first-order closed loop and its offset; PI as a second-order loop with no offset; the cost of measurement lag; the characteristic equation, the Routh array and its boundary; root locus for P and PI, and Example 14.1's two crossings.
Tools: ct.feedback, ct.dcgain, sp.cancel, a routh() function,
np.roots sweeps, ct.root_locus_plot
Next: Ch. 15 -- frequency response and Bode diagrams.