Agenda¶
Part A -- The substitution rule: $s \to j\omega$ (Sec. 15.1)
Part B -- Bode diagrams: lags in series, resonance (Sec. 15.2)
Part C -- Controllers and dead time on the Bode diagram; reading a frequency response from data (Sec. 15.2)
Setup (run once)¶
- The standard imports
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 -- a fortunate circumstance¶
- Lecture 3: the thermometer in a sinusoidal bath (Ex. 4.3) ends up oscillating at the same frequency, smaller and later.
- Amplitude ratio $AR = 1/\sqrt{1 + \omega^2\tau^2}$, phase $\phi = -\tan^{-1}\omega\tau$ -- found by partial fractions.
- Claim (Sec. 15.1): put $s = j\omega$ in $G(s)$; then $AR = |G(j\omega)|$ and $\phi = \angle G(j\omega)$.
- Valid for stable $G$ only: the transients must die out.
Derive 1/3 -- first order (Eqs. 15.2-15.3)¶
$G(j\omega) = 1/(j\omega\tau + 1)$; multiply by the conjugate:
$$G(j\omega) = \frac{1}{1+\omega^2\tau^2} - j\frac{\omega\tau}{1+\omega^2\tau^2} \;\Rightarrow\; |G| = \frac{1}{\sqrt{1+\omega^2\tau^2}},\;\; \angle G = \tan^{-1}(-\omega\tau)$$
Exactly Eq. 4.28.
Derive 2/3 -- second order (Ex. 15.2, Eq. 15.4)¶
$s = j\omega$ in $1/(\tau^2s^2 + 2\zeta\tau s + 1)$:
$$AR = \frac{1}{\sqrt{(1-\omega^2\tau^2)^2 + (2\zeta\omega\tau)^2}},\quad \phi = -\tan^{-1}\frac{2\zeta\omega\tau}{1-\omega^2\tau^2}$$
Derive 3/3 -- series and dead time¶
Polar form: magnitudes multiply, angles add:
$$|G_1G_2\cdots| = |G_1||G_2|\cdots,\qquad \angle G_1G_2\cdots = \angle G_1 + \angle G_2 + \cdots$$
Dead time $e^{-j\omega\tau}$: $AR = 1$, $\phi = -\omega\tau$ (Eq. 15.6).
Code 1/3 -- the substitution, symbolically¶
- Mirrors Derive 1/3: put $s = j\omega$, take modulus and argument
sp.Iis $j$; modulus withsp.Abs, angle from Im/Re
w, tau = sp.symbols("omega tau", positive=True)
Gjw = 1 / (sp.I * w * tau + 1)
print(sp.simplify(sp.Abs(Gjw))) # AR
print(sp.atan(sp.simplify(sp.im(Gjw) / sp.re(Gjw)))) # phase
print(sp.simplify(sp.re(Gjw)), "|", sp.simplify(sp.im(Gjw)))
1/sqrt(omega**2*tau**2 + 1) -atan(omega*tau) 1/(omega**2*tau**2 + 1) | -omega*tau/(omega**2*tau**2 + 1)
Code 2/3 -- Python complex numbers do it numerically¶
- Ex. 15.1: thermometer $\tau = 0.1$ min at $\omega = 20$ rad/min
absandnp.angleon a complex number;20jis $20j$
G = 1 / (0.1 * 20j + 1)
print(f"G(20j) = {G:.3f}")
print(f"AR = {abs(G):.3f}, phase = {np.degrees(np.angle(G)):.1f} deg")
G(20j) = 0.200-0.400j AR = 0.447, phase = -63.4 deg
Code 3/3 -- Example 15.3 by the formula¶
- Mirrors Derive 2/3 with $\tau = 1$, $\zeta = 0.8$, input $3\sin 0.5t$
tau_v, zeta, w_v = 1.0, 0.8, 0.5
G = 1 / ((1j * w_v * tau_v)**2 + 2 * zeta * tau_v * 1j * w_v + 1)
AR, phi = abs(G), np.angle(G)
print(f"AR = {AR:.3f}, phase = {np.degrees(phi):.1f} deg")
print(f"ultimate output: {3*AR:.2f} sin(0.5 t {phi:+.3f})")
AR = 0.912, phase = -46.8 deg ultimate output: 2.74 sin(0.5 t -0.818)
Build on it -- measure AR and phase from a simulation¶
- Run the sine through $G$; fit $y \approx a\sin\omega t + b\cos\omega t$
- Then $AR = \sqrt{a^2 + b^2}/3$, phase $= \text{atan2}(b, a)$
G2 = ct.tf([1], [1, 2 * 0.8, 1])
t = np.linspace(0, 80, 8001)
y = ct.forced_response(G2, t, 3 * np.sin(0.5 * t)).outputs
late = t > 40 # transients gone
X = np.column_stack([np.sin(0.5 * t[late]), np.cos(0.5 * t[late])])
a, b = np.linalg.lstsq(X, y[late], rcond=None)[0]
print(f"measured AR = {np.hypot(a, b) / 3:.3f}, "
f"phase = {np.degrees(np.arctan2(b, a)):.1f} deg")
measured AR = 0.912, phase = -46.8 deg
Your Turn (8 min)¶
Ex. 15.4. Tank ($\tau_1 = 0.203$ min) then 2 ft of pipe ($\tau_2 = 0.0397$ min dead time); $T_i = 75 + 5\sin 46t$.
- By hand: AR and phase of $T_m'/T_i'$ at $\omega = 46$ rad/min.
- Read the code: what does this print (two decimals)?
G = np.exp(-46j * 0.0397) / (1 + 46j * 0.203)
print(round(abs(G), 3), round(np.degrees(np.angle(G)), 1))
Check -- cold-call¶
Why does the substitution rule fail for an unstable $G(s)$, even though $G(j\omega)$ can still be computed?
Introduce -- one picture for every frequency (Fig. 15-12)¶
- $\log AR$ against $\log\omega$, and $\phi$ against $\log\omega$.
- Log scale turns products into sums: lags in series just add.
- Decibels: $20\log_{10} AR$ ($AR = 0.1$ is $-20$ dB).
Derive 1/3 -- first-order asymptotes (Fig. 15-13)¶
$\log AR = -\frac12\log[(\omega\tau)^2 + 1]$:
$\omega\tau \ll 1$: $AR \to 1$ (slope 0). $\;\omega\tau \gg 1$: $\log AR \to -\log\omega\tau$ (slope $-1$). They meet at the corner $\omega_c = 1/\tau$, where the true $AR = 0.707$ and $\phi = -45^\circ$.
Derive 2/3 -- lags in series (Ex. 15.6)¶
$G = \frac{1}{(s+1)(s+5)} = \frac{1/5}{(s+1)(0.2s+1)}$:
$$\log AR = \log\tfrac15 + \log AR_1 + \log AR_2$$
Asymptote slopes add: 0 below $\omega = 1$, $-1$ between 1 and 5, $-2$ above 5. Phase: $0 \to -180^\circ$.
Derive 3/3 -- second-order resonance (Eqs. 15.15-15.16)¶
Minimize the denominator of $AR^2$ over $\omega\tau$:
$$(\omega\tau)_{max} = \sqrt{1 - 2\zeta^2},\qquad AR_{max} = \frac{1}{2\zeta\sqrt{1-\zeta^2}}\qquad (\zeta < 0.707)$$
$AR > 1$ is possible -- never for a first-order system.
Code 1/3 -- resonance by differentiation¶
- Mirrors Derive 3/3: $AR$ is largest where its denominator is smallest
z, x = sp.symbols("zeta x", positive=True) # x = omega tau
D = (1 - x**2)**2 + (2 * z * x)**2 # 1 / AR^2
x_max = sp.solve(sp.diff(D, x), x)
print("omega tau at max:", x_max)
print("AR max:", sp.simplify(1 / sp.sqrt(D.subs(x, x_max[0]))))
omega tau at max: [sqrt(1 - 2*zeta**2)] AR max: 1/(2*zeta*sqrt(1 - zeta**2))
Code 2/3 -- Example 15.6: check the asymptote slopes¶
- Mirrors Derive 2/3; slope = change in $\log AR$ per decade
G6 = ct.tf([1], [1, 6, 5]) # 1/((s+1)(s+5))
for lo, hi in [(0.01, 0.1), (1.5, 3.0), (50, 500)]:
a_lo = abs(G6(1j * lo)); a_hi = abs(G6(1j * hi))
slope = np.log10(a_hi / a_lo) / np.log10(hi / lo)
print(f"omega {lo:5} to {hi:5}: slope {slope:+.2f}")
print(f"low-frequency AR = {abs(G6(1e-4j)):.3f} (gain 1/5)")
omega 0.01 to 0.1: slope -0.00 omega 1.5 to 3.0: slope -0.97 omega 50 to 500: slope -2.00 low-frequency AR = 0.200 (gain 1/5)
Code 3/3 -- the second-order family (Fig. 15-17)¶
- One curve per $\zeta$; peaks appear for $\zeta < 0.707$
x = np.logspace(-1, 1, 400) # omega tau
fig, (a1, a2) = plt.subplots(2, 1, figsize=(7, 5.25), sharex=True)
for zv in [0.1, 0.3, 0.5, 0.707, 1.0, 2.0]:
G = 1 / ((1j * x)**2 + 2 * zv * 1j * x + 1)
a1.loglog(x, abs(G), label=zv)
a2.semilogx(x, np.degrees(np.unwrap(np.angle(G))))
a1.set_ylabel("AR"); a2.set(xlabel="omega tau", ylabel="phase (deg)")
a1.legend(title="zeta", fontsize=9, ncol=2); plt.show()
Build on it -- a Bode diagram measured from experiments¶
- Repeat Part A's sine experiment at 12 frequencies for Ex. 15.6
- Dots: measured. Line: $|G(j\omega)|$ from the transfer function
def measure(G, w, cycles=30, n=4000):
t = np.linspace(0, cycles * 2 * np.pi / w, n)
y = ct.forced_response(G, t, np.sin(w * t)).outputs
late = t > t[-1] / 2
X = np.column_stack([np.sin(w*t[late]), np.cos(w*t[late])])
a, b = np.linalg.lstsq(X, y[late], rcond=None)[0]
return np.hypot(a, b), np.degrees(np.arctan2(b, a))
w_exp = np.logspace(-1, 1.5, 12)
data = np.array([measure(G6, w) for w in w_exp])
print(np.round(data[:4], 4))
[[ 0.1989 -6.8564] [ 0.1971 -11.5122] [ 0.192 -19.1572] [ 0.1794 -31.1616]]
Plot -- measured dots on the calculated curve¶
- If the dots sit on the line, $s = j\omega$ is confirmed by experiment
ww = np.logspace(-1, 1.5, 300)
Gw = 1 / ((1j * ww + 1) * (1j * ww + 5))
fig, (a1, a2) = plt.subplots(2, 1, figsize=(7, 5.25), sharex=True)
a1.loglog(ww, abs(Gw)); a1.loglog(w_exp, data[:, 0], "o")
a2.semilogx(ww, np.degrees(np.unwrap(np.angle(Gw))))
a2.semilogx(w_exp, data[:, 1], "o")
a1.set_ylabel("AR"); a2.set(xlabel="omega", ylabel="phase (deg)")
plt.show()
Your Turn (8 min)¶
Prob. 15.1(a). $G = 100/[(10s + 1)(s + 1)]$.
- By hand: the asymptote slopes and the corners; the AR and phase at $\omega = 10$.
- Read the code: what does this print?
print(20 * np.log10(0.1), 20 * np.log10(abs(1 / (1 + 1j))))
Check -- poll¶
Three first-order lags in series. At high frequency the AR asymptote has slope ... and the phase approaches ...
A) $-1$, $-90^\circ$ B) $-3$, $-270^\circ$ C) $-3$, $-180^\circ$
Introduce -- the controller is a block too¶
- P: $AR = K_c$, $\phi = 0$.
- PI: low-frequency AR grows without bound -- that is what removes offset; it costs phase lag.
- PD: phase lead; can push the $-180^\circ$ point to higher frequency (stabilizing).
- Dead time: $AR = 1$, but phase lag grows without bound.
Derive 1/2 -- PI and PD frequency responses¶
$s = j\omega$ in $K_c(1 + 1/\tau_Is)$ and $K_c(1 + \tau_Ds)$:
$$PI:\; AR = K_c\sqrt{1 + \frac{1}{(\omega\tau_I)^2}},\; \phi = -\tan^{-1}\frac{1}{\omega\tau_I}\qquad PD:\; AR = K_c\sqrt{1 + (\omega\tau_D)^2},\; \phi = +\tan^{-1}\omega\tau_D$$
Derive 2/2 -- Example 15.7, piece by piece¶
PD control of three lags, dead time in the measurement:
$$G = \frac{10(0.5s+1)e^{-s/10}}{(s+1)^2(0.1s+1)}:\quad \phi = \tan^{-1}0.5\omega - 2\tan^{-1}\omega - \tan^{-1}0.1\omega - 0.1\omega$$
Each factor's phase is computed alone, then summed.
Code 1/2 -- PI and PD at their corners¶
- Mirrors Derive 1/2 at $\omega\tau = 1$ with $K_c = 1$
for name, G in [("PI", 1 + 1 / (1j * 1.0)), # omega tau_I = 1
("PD", 1 + 1j * 1.0)]: # omega tau_D = 1
print(f"{name}: AR = {abs(G):.3f}, "
f"phase = {np.degrees(np.angle(G)):+.0f} deg")
PI: AR = 1.414, phase = -45 deg PD: AR = 1.414, phase = +45 deg
Code 2/2 -- Example 15.7 with and without derivative action¶
- Mirrors Derive 2/2; find where the total phase reaches $-180^\circ$
ww = np.logspace(-1, 2, 20000)
def G7(w, pd=True):
s = 1j * w
g = 10 * np.exp(-s / 10) / ((s + 1)**2 * (0.1 * s + 1))
return g * (0.5 * s + 1) if pd else g
for pd in [True, False]:
ph = np.unwrap(np.angle(G7(ww, pd)))
i = np.argmax(ph < -np.pi) # first below -180
print(f"PD={pd}: -180 deg at omega = {ww[i]:.2f}, "
f"AR there = {abs(G7(ww[i], pd)):.2f}")
PD=True: -180 deg at omega = 8.62, AR there = 0.44 PD=False: -180 deg at omega = 3.14, AR there = 0.88
Plot -- Example 15.7 (Fig. 15-24)¶
- Overall curves with and without the PD factor
fig, (a1, a2) = plt.subplots(2, 1, figsize=(7, 5.25), sharex=True)
for pd, lab in [(True, "PD"), (False, "P only")]:
a1.loglog(ww, abs(G7(ww, pd)), label=lab)
a2.semilogx(ww, np.degrees(np.unwrap(np.angle(G7(ww, pd)))))
a2.axhline(-180, ls="--", c="gray"); a2.set_ylim(-400, 30)
a1.set_ylabel("AR"); a2.set(xlabel="omega", ylabel="phase (deg)")
a1.legend(); plt.show()
Build on it -- identify a sensor from phase data (Prob. 15.2)¶
- Element B lags reference thermocouple A ($\tau_A = 0.1$ min)
- Model: lag $= \tan^{-1}\omega\tau_B - \tan^{-1}0.1\omega$; fit $\tau_B$
f = np.array([0.1, 0.2, 0.4, 0.8, 1.0, 1.5, 2.0, 3.0, 4.0]) # cyc/min
lag = np.array([7.1, 12.9, 21.8, 28.2, 29.8, 26.0, 23.6, 18.0, 14.2])
wf = 2 * np.pi * f # rad/min
def phase_lag(w, tauB):
return np.degrees(np.arctan(w * tauB) - np.arctan(0.1 * w))
p, cov = curve_fit(phase_lag, wf, lag, p0=[0.5])
print(f"tau_B = {p[0]:.3f} +/- {np.sqrt(cov[0, 0]):.3f} min")
print("residuals (deg):", np.round(lag - phase_lag(wf, *p), 1))
tau_B = 0.290 +/- 0.003 min residuals (deg): [ 0.4 0. -0.2 -0.7 0.7 -0.6 0.4 0.4 0.3]
Your Turn (8 min)¶
- By hand: a PI controller with $K_c = 2$, $\tau_I = 5$ min. AR and phase at $\omega = 0.2$ rad/min.
- Read the code: which version of Ex. 15.7 would tolerate a larger controller gain before the loop goes unstable, and why (one line)?
Check -- think-pair-share¶
In Ex. 15.4 the thermocouple reading lags the inlet temperature by $188^\circ$ at $\omega = 46$ rad/min. Explain, without equations, why a proportional controller acting on that reading could make the tank temperature swing more at that frequency.
Wrap-up¶
Derived, then coded: the substitution rule $s \to j\omega$; AR and phase of first-order, second-order, dead-time, PI and PD blocks; series rules; resonance; Bode diagrams computed and measured from simulated sine tests; a sensor time constant fitted from phase data.
Tools: Python complex numbers, np.angle, np.unwrap,
ct.forced_response, np.linalg.lstsq, curve_fit
Next: Ch. 16 -- the Bode stability criterion, gain and phase margins, and Ziegler-Nichols tuning.