How today works¶
- Each idea is introduced, then derived on the board.
- Then the code repeats the derivation, step for step.
- Then the code goes further than hand algebra can.
- Full worked derivations: the companion
Derivationsnotebook.
Agenda¶
Part A -- The thermometer: first-order transfer function (Ch. 4)
Part B -- Reading $\tau$ from data (Ch. 4, Prob. 4.6)
Part C -- The nonlinear tank and linearization (Ch. 5)
Part D -- Tanks in series: interaction and transfer lag (Ch. 6)
Tools: sympy mirrors the algebra; numpy, scipy,
python-control and matplotlib build on it.
Setup (run once)¶
- Every library from the Python Primer, imported once
- In Colab, uncomment the first line
# !pip install control # Colab only
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 mercury thermometer (Sec. 4.1)¶
- Bulb at temperature $y(t)$, sitting in a bath at $x(t)$.
- The bath changes. How does the reading follow it?
- Assumptions: all resistance in the film ($h$), bulb uniform, glass holds no heat, no expansion.
Textbook figure: Coughanowr Fig. 4-1a (cross-section of thermometer bulb).
Derive 1/4 -- unsteady-state energy balance¶
Rate in $-$ rate out $=$ rate of accumulation:
$$hA\,(x-y) \;-\; 0 \;=\; mC\,\frac{dy}{dt}$$
$A$: bulb surface area, $m$: mass of mercury, $C$: heat capacity.
Derive 2/4 -- deviation variables¶
Before $t=0$ the bulb is at steady state: $hA(x_s-y_s)=0$.
Subtract, and define $X = x-x_s$, $\;Y = y-y_s$:
$$hA\,(X-Y) = mC\,\frac{dY}{dt}, \qquad Y(0)=0$$
Derive 3/4 -- Laplace transform¶
Transform both sides, using $\mathcal{L}\{dY/dt\} = sY(s) - Y(0)$:
$$hA\,[X(s)-Y(s)] = mC\,s\,Y(s)$$
Derive 4/4 -- the transfer function¶
Collect $Y(s)$ and divide by $X(s)$:
$$\frac{Y(s)}{X(s)} = \frac{1}{\tau s+1}, \qquad \tau = \frac{mC}{hA}$$
General form: $K_p/(\tau s+1)$, gain $K_p$, time constant $\tau$.
Code it -- the same four steps in SymPy¶
- SymPy does algebra with symbols, as on the board
- Each cell below carries the step number it mirrors
- Nothing new is derived; the computer checks our algebra
Code 1-2/4 -- the balance, in deviation variables¶
sp.Functiondeclares an unknown function of $t$sp.Eq(left, right)is an equation, not an assignment
t, s = sp.symbols("t s", positive=True)
h, A, m, C = sp.symbols("h A m C", positive=True)
X, Y = sp.Function("X"), sp.Function("Y") # deviation vars
# hA (X - Y) = mC dY/dt
balance = sp.Eq(h*A*(X(t) - Y(t)), m*C*Y(t).diff(t))
balance
Code 3/4 -- Laplace transform of each side¶
sp.laplace_transformknows the derivative rule- Look for the $-Y(0)$ term in the output
lhs = sp.laplace_transform(balance.lhs, t, s, noconds=True)
rhs = sp.laplace_transform(balance.rhs, t, s, noconds=True)
rhs # C m (s L{Y} - Y(0))
Code 3/4 (cont.) -- tidy up: $Y(0)=0$¶
- Rename the transforms to plain symbols $X_s$, $Y_s$
- Set $Y(0)=0$, exactly as deviation variables promised
Xs, Ys = sp.symbols("X_s Y_s")
rename = {sp.LaplaceTransform(X(t), t, s): Xs,
sp.LaplaceTransform(Y(t), t, s): Ys,
Y(0): 0} # deviation variable
eq_s = sp.Eq(lhs.subs(rename), rhs.subs(rename))
eq_s # hA (Xs - Ys) = mC s Ys
Code 4/4 -- solve for $Y_s/X_s$¶
sp.solve(eq, Ys)isolates $Y_s$; divide by $X_s$- Then substitute $mC = \tau hA$ to reach standard form
G = sp.solve(eq_s, Ys)[0] / Xs # Y(s) / X(s)
print(sp.simplify(G)) # A h / (A h + C m s)
tau = sp.symbols("tau", positive=True)
sp.simplify(G.subs(m, tau*h*A/C)) # 1 / (tau s + 1)
A*h/(A*h + C*m*s)
Introduce -- the step response¶
- Now use the transfer function: plunge the thermometer into a hotter bath.
- A step of size $A$ in the bath temperature: $X(s) = A/s$.
- Example 4.2: $\tau = 0.1$ min, reading 90 F, bath at 100 F. When does it read 98 F?
Derive 1/3 -- output in the $s$-domain¶
Multiply the input by the transfer function:
$$Y(s) = \frac{A}{s}\cdot\frac{1}{\tau s+1} = \frac{A}{s} - \frac{A\tau}{\tau s+1}$$
(partial fractions, cover-up rule)
Derive 2/3 -- invert¶
Each term is in the table:
$$Y(t) = A\left(1 - e^{-t/\tau}\right) \qquad \text{(Eq. 4.15)}$$
At $t=\tau$: $Y/A = 1-e^{-1} = 0.632$.
Derive 3/3 -- Example 4.2 by hand¶
$A = 10$ F, $\tau = 0.1$ min, want $Y = 98-90 = 8$ F:
$$8 = 10\left(1-e^{-t/0.1}\right) \;\Rightarrow\; t = 0.1\ln 5 = 0.161 \text{ min}$$
Code 1/3 -- $Y(s)$ and partial fractions¶
- Build $Y(s) = X(s)\,G(s)$ with the step $X(s) = A/s$
sp.apartdoes the partial fractions
A = sp.symbols("A", positive=True) # now: step size
Y_s = (A / s) / (tau*s + 1) # X(s) G(s)
sp.apart(Y_s, s) # A/s - A tau/(tau s + 1)
Code 2/3 -- invert to the time domain¶
sp.inverse_laplace_transformreplaces the table- Then ask the formula for the fraction complete at $t = n\tau$
y_step = sp.inverse_laplace_transform(Y_s, s, t)
print(y_step) # A (1 - exp(-t/tau))
for n in [1, 2, 3, 4, 5]:
frac = (y_step / A).subs(t, n*tau)
print(f"t = {n} tau: {float(frac):.3f} of final value")
A*(1 - exp(-t/tau)) t = 1 tau: 0.632 of final value t = 2 tau: 0.865 of final value t = 3 tau: 0.950 of final value t = 4 tau: 0.982 of final value t = 5 tau: 0.993 of final value
Code 3/3 -- Example 4.2 with numbers¶
sp.lambdifyturns the formula into a NumPy function- Evaluate on a time grid; find the first time above 98 F
y_num = sp.lambdify((t, tau, A), y_step, "numpy")
tt = np.linspace(0, 0.6, 601) # min
reading = 90 + y_num(tt, 0.1, 10) # 90 F + deviation
i98 = np.argmax(reading >= 98) # first index at 98 F
print(f"98 F at t = {tt[i98]:.3f} min (hand: 0.161)")
98 F at t = 0.161 min (hand: 0.161)
Build on it -- plotting the response, one piece at a time¶
- The derivation is done. Now we draw it.
- Same recipe as the Primer: curve, then labels, then guides.
Plot 1/3 -- the curve¶
fig, ax = plt.subplots(...)gives a figure we can controlax.plot(x, y)draws one curve
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(tt, reading) # thermometer reading
ax.set_xlabel("t (min)")
ax.set_ylabel("reading (F)")
plt.show()
Plot 2/3 -- mark the numbers we derived¶
axhline/axvline: horizontal and vertical guides- 63.2% of the change at $t = \tau$; 98 F at $t = 0.161$ min
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(tt, reading)
ax.axvline(0.1, ls="--", c="gray") # t = tau
ax.axhline(90 + 6.32, ls="--", c="gray") # 63.2% of 10 F
ax.axhline(98, ls=":", c="k") # target reading
ax.axvline(tt[i98], ls=":", c="k") # time to reach it
ax.set(xlabel="t (min)", ylabel="reading (F)")
plt.show()
Plot 3/3 -- effect of $\tau$ (cf. Fig. 4-13)¶
- Loop over three values of $\tau$; one call per curve
label=plusax.legend()gives the key
fig, ax = plt.subplots(figsize=(7, 5.25))
for tau_i in [0.05, 0.1, 0.2]: # min
ax.plot(tt, 90 + y_num(tt, tau_i, 10),
label=f"tau = {tau_i} min")
ax.axhline(98, ls=":", c="k")
ax.set(xlabel="t (min)", ylabel="reading (F)")
ax.legend(loc="lower right")
plt.show()
Build on it -- the same model in python-control¶
ct.tf(num, den): coefficients, highest power of $s$ firststep_responseneeds no algebra -- and matches ours
G_th = ct.tf([1], [0.1, 1]) # 1 / (0.1 s + 1)
print("pole:", ct.poles(G_th)) # -1/tau = -10
resp = ct.step_response(10 * G_th, tt) # 10 F step
gap = np.abs(90 + resp.outputs - reading).max()
print(f"max |python-control - formula| = {gap:.1e} F")
pole: [-10.+0.j] max |python-control - formula| = 7.1e-14 F
Introduce -- a sinusoidal bath (Example 4.3)¶
- Same thermometer, $\tau = 0.1$ min.
- Bath oscillates $\pm 2$ F about 100 F at $\omega = 20$ rad/min.
- Does the reading show the full swing? Is it in step?
Derive 1/2 -- transform and split¶
$X(t) = A\sin\omega t \;\Rightarrow\; X(s) = A\omega/(s^2+\omega^2)$:
$$Y(s) = \frac{A\omega}{(s^2+\omega^2)(\tau s+1)}$$
Partial fractions: one real pole, one complex pair.
Derive 2/2 -- the ultimate (long-time) response¶
The $e^{-t/\tau}$ term dies out, leaving (Eq. 4.28):
$$Y(t) \to \frac{A}{\sqrt{1+\omega^2\tau^2}}\sin(\omega t+\phi), \quad \phi = -\tan^{-1}(\omega\tau)$$
Here: amplitude ratio $0.894$, $\phi = -63.4^\circ$.
Code 1/2 -- partial fractions and inverse¶
- Same two calls as the step:
apart, then invert - Exact numbers:
sp.Rational(1, 10)keeps $\tau$ as a fraction
w = 20 # rad/min
Y_sin = 2*w / ((s**2 + w**2) * (sp.Rational(1, 10)*s + 1))
print(sp.apart(Y_sin, s)) # three terms
y_sin = sp.inverse_laplace_transform(Y_sin, s, t)
y_sin # decay + sine + cosine
-4*(s - 10)/(5*(s**2 + 400)) + 4/(5*(s + 10))
Code 2/2 -- bath versus thermometer (cf. Fig. 4-18)¶
lambdifyagain, then plot input and output together- Left of the dotted line: transient. Right: the ultimate response
f_sin = sp.lambdify(t, y_sin, "numpy")
tt = np.linspace(0, 1.0, 2001) # min
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(tt, 100 + 2*np.sin(20*tt), label="bath")
ax.plot(tt, 100 + f_sin(tt), label="thermometer")
ax.axvline(0.3, ls=":", c="gray") # ~3 tau
ax.set(xlabel="t (min)", ylabel="T (F)", ylim=(97.5, 103))
ax.legend(loc="upper center", ncol=2)
plt.show()
Build on it -- sweep the frequency¶
- Amplitude ratio for every $\omega$ at once: one NumPy line
- Log axes: a preview of the Bode plot (Ch. 15)
w = np.logspace(-1, 3, 200) # rad/min
AR = 1 / np.sqrt(1 + (w * 0.1)**2) # Eq. 4.28
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.loglog(w, AR)
ax.axvline(20, ls=":", c="k") # Example 4.3
ax.set(xlabel="omega (rad/min)", ylabel="amplitude ratio")
plt.show()
Your Turn (10 min)¶
Probs. 4.9-4.10. Thermometer, $\tau = 1$ min, reads 50 C. At $t=0$ it goes into a 100 C bath; at $t=1.5$ min it is moved to a 75 C bath.
- By hand: the reading at $t=1.2$ min.
- By hand: the maximum reading, and the reading at $t=20$ min.
- Read the code: what does
50 + y_num(1.2, 1.0, 50)print?
Check -- cold-call¶
In Example 4.3, halve $\tau$.
Does the lag in minutes between bath and thermometer peaks go up or down? Answer from the formula -- then rerun the sweep.
Introduce -- $h$ is never known¶
- $\tau = mC/hA$, but in practice $h$ is unknown.
- So: measure the response, and infer $\tau$ from it.
- Prob. 4.6. Thermometer at 75 F plunged into a 400 F oil bath; readings taken over 30 s.
- First time in this course a model is learned from data.
Derive 1/3 -- normalize the data¶
From Part A, with a step of $A = 400-75 = 325$ F:
$$\frac{Y}{A} = \frac{y-75}{325} = 1 - e^{-t/\tau}$$
Every reading becomes a fraction between 0 and 1.
Derive 2/3 -- two ways to read $\tau$¶
(a) At $t=\tau$, $Y/A = 0.632$: interpolate the table.
(b) Take logs -- a straight line through the origin:
$$z \equiv \ln\!\left(1-\frac{Y}{A}\right) = -\frac{t}{\tau}$$
Derive 3/3 -- least squares on the line¶
Minimize $\sum_i (z_i + k\,t_i)^2$ over $k = 1/\tau$:
$$\frac{d}{dk}\sum_i (z_i + k t_i)^2 = 0 \;\Rightarrow\; k = -\frac{\sum_i t_i z_i}{\sum_i t_i^2}$$
Code 1/3 -- the data, normalized¶
- Two arrays straight from the book's table
- One vectorized line per derivation step
t_d = np.array([0, 1, 2.5, 5, 8, 10, 15, 30]) # s
y_d = np.array([75, 107, 140, 205, 244, 282, 328, 385]) # F
frac = (y_d - 75) / 325 # Y/A
for ti, f in zip(t_d, frac):
print(f"t = {ti:5.1f} s Y/A = {f:.3f}")
t = 0.0 s Y/A = 0.000 t = 1.0 s Y/A = 0.098 t = 2.5 s Y/A = 0.200 t = 5.0 s Y/A = 0.400 t = 8.0 s Y/A = 0.520 t = 10.0 s Y/A = 0.637 t = 15.0 s Y/A = 0.778 t = 30.0 s Y/A = 0.954
Code 2/3 -- (a) interpolate the 63.2% crossing¶
np.interp(x, xp, fp): linear interpolation, as by hand- Here we ask: at what $t$ does $Y/A$ reach 0.632?
tau_a = np.interp(0.632, frac, t_d) # frac is increasing
print(f"(a) tau = {tau_a:.1f} s")
(a) tau = 9.9 s
Code 3/3 -- (b) the log line, and its slope¶
np.logon the whole array: every $z_i$ at once- Slope from the formula we just derived
z = np.log(1 - frac) # z = -t / tau
k = -np.sum(t_d * z) / np.sum(t_d**2) # least-squares slope
print(f"(b) tau = {1/k:.1f} s")
(b) tau = 9.9 s
Plot -- is the log line really straight?¶
- If the model is first order, the dots fall on one line
- Curvature would mean the model form is wrong
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(t_d, z, "o", ms=9, label="data")
ax.plot(t_d, -k * t_d, label=f"slope -1/{1/k:.1f}")
ax.set(xlabel="t (s)", ylabel="ln(1 - Y/A)")
ax.legend(loc="lower left")
plt.show()
Build on it -- fit the raw curve with curve_fit¶
- Fit $y = 75 + 325(1-e^{-t/\tau})$ directly: no log trick
curve_fitalso returns an uncertainty for $\tau$
def model(t, tau):
return 75 + 325 * (1 - np.exp(-t / tau))
popt, pcov = curve_fit(model, t_d, y_d, p0=[5.0])
tau_fit, tau_se = popt[0], np.sqrt(pcov[0, 0])
print(f"tau = {tau_fit:.2f} +/- {tau_se:.2f} s")
print("residuals (F):", np.round(y_d - model(t_d, tau_fit), 1))
tau = 10.19 +/- 0.19 s residuals (F): [ 0. 1.6 -5.7 3.9 -7.8 3.8 2.5 2.1]
Plot -- data and fitted model¶
- Dots: the book's measurements. Line: the fitted model
- All eight points used at once
tt = np.linspace(0, 32, 300)
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(t_d, y_d, "o", ms=9, label="Prob. 4.6 data")
ax.plot(tt, model(tt, tau_fit), label=f"fit, tau = {tau_fit:.1f} s")
ax.axhline(400, ls=":", c="gray")
ax.set(xlabel="t (s)", ylabel="reading (F)")
ax.legend(loc="lower right")
plt.show()
Read the output¶
curve_fit minimizes $\sum_i (y_i-\hat y_i)^2$ --
the same objective as regression in machine learning.
- Physics fixes the model's form; only $\tau$ is learned.
- Small residuals, no trend: first order is adequate.
- The $\pm$ uncertainty is something no hand estimate gives.
Your Turn (8 min)¶
- By hand: use only the points at $t=5$ and $t=15$ s to get the slope of the log line, and so $\tau$.
- Read the code: what is
1 / k2(to one decimal)?
k2 = -np.sum(t_d[[3, 6]] * z[[3, 6]]) / np.sum(t_d[[3, 6]]**2)
Does a two-point estimate agree with the full fit?
Check -- think-pair-share¶
The fit gives $\tau \approx 10$ s in oil.
Would you use $\tau = 10$ s for the same thermometer in air? What does your answer say about any model fitted from data?
Introduce -- one transfer function, many processes (Sec. 5.1)¶
Tank with a linear outlet $q_o = h/R$:
$$A\frac{dh}{dt} = q - \frac{h}{R} \;\Rightarrow\; \frac{H(s)}{Q(s)} = \frac{R}{\tau s+1}, \quad \tau = AR$$
Thermometer, tank, mixing tank: same form, same code.
Textbook figure: Coughanowr Fig. 5-1 (liquid-level system).
Introduce -- but a real valve is not linear (Sec. 5.2)¶
- Turbulent outlet: $q_o = C\sqrt{h}$.
- $\sqrt{h}$ has no Laplace transform rule: no transfer function.
- Fix: replace the curve by its tangent at the operating point.
Book example: $A=3$ ft$^2$, $h_s=4$ ft, $q_s=16$ cfm $\Rightarrow C=8$.
Textbook figure: Coughanowr Fig. 5-8 (nonlinear resistance and tangent line).
Derive 1/3 -- Taylor series about $h_s$¶
Keep the first two terms of $q_o(h) = C\sqrt{h}$:
$$q_o \approx C\sqrt{h_s} + \frac{C}{2\sqrt{h_s}}(h-h_s) = q_s + \frac{h-h_s}{R_1}, \qquad R_1 = \frac{2\sqrt{h_s}}{C}$$
Derive 2/3 -- deviation variables¶
Put the tangent into $A\,dh/dt = q - q_o$; subtract steady state:
$$A\frac{dH}{dt} = Q - \frac{H}{R_1}, \qquad H = h-h_s,\; Q = q-q_s$$
Linear again -- the same form as the linear tank.
Derive 3/3 -- transfer function and numbers¶
Laplace transform, as in Part A:
$$\frac{H(s)}{Q(s)} = \frac{R_1}{\tau s+1}, \quad \tau = AR_1$$
Book numbers: $R_1 = 2(2)/8 = 0.5$ ft/cfm, $\tau = 1.5$ min.
Code 1/3 -- the tangent line, symbolically¶
sp.diffgives the slope $dq_o/dh$;.subsevaluates it at $h_s$- Tangent = value + slope $\times$ distance
h, hs, Cv = sp.symbols("h h_s C_v", positive=True)
qo = Cv * sp.sqrt(h) # nonlinear outflow
slope = sp.diff(qo, h).subs(h, hs) # dq_o/dh at h_s
tangent = qo.subs(h, hs) + slope * (h - hs)
tangent # Taylor, first order
Code 2/3 -- the linearized resistance $R_1$¶
- $R_1$ is one over the slope
- SymPy simplifies to the book's form
R1 = sp.simplify(1 / slope)
R1 # 2 sqrt(h_s) / C_v
Code 3/3 -- numbers, and the transfer function¶
- Substitute the book's values with a dictionary
- Build $R_1/(\tau s+1)$ as a
python-controlobject
vals = {Cv: 8, hs: 4} # Sec. 5.2 example
R1_val = float(R1.subs(vals)) # ft/cfm
tau_val = 3 * R1_val # tau = A R1, A = 3 ft^2
G_lin = ct.tf([R1_val], [tau_val, 1])
print(f"R1 = {R1_val} ft/cfm, tau = {tau_val} min")
print(G_lin)
R1 = 0.5 ft/cfm, tau = 1.5 min
<TransferFunction>: sys[4]
Inputs (1): ['u[0]']
Outputs (1): ['y[0]']
0.5
---------
1.5 s + 1
Build on it -- simulate the real tank¶
- The nonlinear ODE has no transfer function -- but
solve_ivpdoes not need one - Step the inflow 16 to 20 cfm; compare with the linear model
def tank(t, h, q):
# A dh/dt = q - C sqrt(h), with A = 3, C = 8
return [(q - 8 * np.sqrt(h[0])) / 3]
tt = np.linspace(0, 10, 500) # min
h_nl = solve_ivp(tank, [0, 10], [4.0], args=(20.0,),
t_eval=tt).y[0]
h_lin = 4 + ct.step_response(4 * G_lin, tt).outputs
print(f"final h: nonlinear {h_nl[-1]:.2f} ft, "
f"linear {h_lin[-1]:.2f} ft")
final h: nonlinear 6.24 ft, linear 6.00 ft
Plot -- nonlinear versus linearized (cf. Fig. 5-9)¶
- Same start, same initial slope
- They part as $h$ moves away from $h_s$
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(tt, h_nl, label="nonlinear, C sqrt(h)")
ax.plot(tt, h_lin, "--", label="linearized, tau = 1.5 min")
ax.set(xlabel="t (min)", ylabel="h (ft)")
ax.legend(loc="lower right")
plt.show()
Build on it -- where is the tangent good enough?¶
- Steady state: true $h = (q/C)^2$; tangent $h = h_s + R_1(q-q_s)$
- Find the inflows where they differ by less than 5%
q = np.linspace(4, 32, 281) # candidate inflows, cfm
h_true = (q / 8) ** 2 # nonlinear steady state
h_tan = 4 + R1_val * (q - 16) # the tangent line
err = 100 * (h_tan - h_true) / h_true # percent error
ok = np.abs(err) < 5
print(f"within 5%: q from {q[ok].min():.1f} "
f"to {q[ok].max():.1f} cfm")
within 5%: q from 13.1 to 20.6 cfm
Plot -- the curve, its tangent, and the validity window¶
- Shaded: error under 5%
- Outside it, the linear model drifts
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(q, h_true, label="nonlinear h = (q/C)^2")
ax.plot(q, h_tan, "--", label="tangent at q_s = 16")
ax.axvspan(q[ok].min(), q[ok].max(), color="gray", alpha=0.2)
ax.plot(16, 4, "ko") # operating point
ax.set(xlabel="q (cfm)", ylabel="steady h (ft)", ylim=(0, 16))
ax.legend(loc="upper left")
plt.show()
Read the output¶
- Exact at $h_s$, good nearby, wrong far away: a validity window.
- Adequate when a controller holds $h$ near $h_s$ (Sec. 5.2).
- Every model -- transfer function, regression, neural net -- has a window. Do you know where its edges are?
Your Turn (8 min)¶
Prob. 5.2. Tank with $A = 3$ ft$^2$ and valve $q = 8\sqrt{h}$.
- By hand: $\tau$ at an operating level of (a) 3 ft, (b) 9 ft.
- Read the code: what does this print?
print(3 * float(R1.subs({Cv: 8, hs: 9})))
Check -- poll¶
Now step the inflow down, 16 to 12 cfm. The linear model's final level is:
A) too high B) too low C) exactly right
Introduce -- two tanks, two ways to connect them¶
- Noninteracting: tank 1 drains freely, $q_1 = h_1/R_1$.
- Interacting: tank 1 drains into tank 2, $q_1 = (h_1-h_2)/R_1$.
- One term differs. Does it change the dynamics?
Textbook figure: Coughanowr Fig. 6-1 ((a) noninteracting, (b) interacting tanks).
Derive 1/3 -- the four equations, in $s$¶
Two balances, two valves (deviation variables, Eqs. 6.20-6.23):
$$Q - Q_1 = A_1 s H_1, \qquad Q_1 - Q_2 = A_2 s H_2$$ $$R_1 Q_1 = H_1 - H_2, \qquad R_2 Q_2 = H_2$$
Noninteracting: the third equation is $R_1 Q_1 = H_1$ instead.
Derive 2/3 -- noninteracting: the blocks multiply¶
Each tank sees only what flows in (Eq. 6.7):
$$\frac{H_2}{Q} = \frac{1}{\tau_1 s+1}\cdot\frac{R_2}{\tau_2 s+1}, \qquad \tau_i = A_i R_i$$
Derive 3/3 -- interacting: eliminate $H_1, Q_1, Q_2$¶
Tank 1 now feels $H_2$; the result does not factor (Eq. 6.24):
$$\frac{H_2}{Q} = \frac{R_2}{\tau_1\tau_2 s^2 + (\tau_1+\tau_2+A_1R_2)\,s + 1}$$
The extra $A_1R_2$ is the interaction.
Code 1/2 -- four equations, solved at once¶
- The four board equations, typed as written
sp.solveeliminates $H_1, Q_1, Q_2$ in one call
Q, Q1, Q2, H1, H2 = sp.symbols("Q Q1 Q2 H1 H2")
A1, A2, R1, R2 = sp.symbols("A1 A2 R1 R2", positive=True)
eqs = [sp.Eq(Q - Q1, A1*s*H1), # tank 1 balance
sp.Eq(Q1 - Q2, A2*s*H2), # tank 2 balance
sp.Eq(R1*Q1, H1 - H2), # interacting valve
sp.Eq(R2*Q2, H2)] # outlet valve
sol = sp.solve(eqs, [Q1, Q2, H1, H2], dict=True)[0]
G_int = sp.simplify(sol[H2] / Q)
G_int
Code 2/2 -- change one line: noninteracting¶
- Replace the valve equation by $R_1Q_1 = H_1$
sp.factorshows the product of two first-order blocks
eqs[2] = sp.Eq(R1*Q1, H1) # noninteracting valve
sol = sp.solve(eqs, [Q1, Q2, H1, H2], dict=True)[0]
G_non = sp.factor(sol[H2] / Q)
G_non # R2 / ((A1R1 s+1)(A2R2 s+1))
Build on it -- which one responds faster?¶
- Identical tanks: $A_1=A_2=R_1=R_2=1$, so $\tau_1=\tau_2=1$.
- Plug in, get numbers, compare poles and step responses.
Poles from the derived transfer functions¶
.subsputs in the numbers;sp.fractionsplits num / densp.Poly(...).all_coeffs()gives the listct.tfwants
one = {A1: 1, A2: 1, R1: 1, R2: 1}
tfs = {}
for name, G in [("non", G_non), ("int", G_int)]:
num, den = sp.fraction(sp.together(G.subs(one)))
c = [float(v) for v in sp.Poly(den, s).all_coeffs()]
tfs[name] = ct.tf([float(num)], c)
print(name, c, "poles:", np.round(ct.poles(tfs[name]).real, 3))
non [1.0, 2.0, 1.0] poles: [-1. -1.] int [1.0, 3.0, 1.0] poles: [-2.618 -0.382]
Simulate the tanks directly, as ODEs¶
- One function, both arrangements: a flag picks the valve law
- Two coupled states in one
solve_ivp: a state-space model
def two_tanks(t, h, interacting):
h1, h2 = h
q1 = (h1 - h2) if interacting else h1 # R1 = 1
return [1.0 - q1, q1 - h2] # A = R2 = 1, q = 1
tt = np.linspace(0, 10, 400)
h2 = {}
for mode in (False, True):
h2[mode] = solve_ivp(two_tanks, [0, 10], [0, 0],
args=(mode,), t_eval=tt).y[1]
Plot -- ODE versus derived transfer functions (cf. Fig. 6-7)¶
- Thick: ODE simulation. Dotted: the transfer functions we derived
- The ODE never saw a transfer function, yet matches both
fig, ax = plt.subplots(figsize=(7, 5.25))
ax.plot(tt, h2[False], lw=3, label="noninteracting (ODE)")
ax.plot(tt, h2[True], lw=3, label="interacting (ODE)")
for G in tfs.values():
ax.plot(tt, ct.step_response(G, tt).outputs, "k:")
ax.set(xlabel="t / tau", ylabel="H2")
ax.legend(loc="lower right")
plt.show()
Build on it -- more tanks in series (cf. Figs. 6-5, 6-6)¶
- Noninteracting: just keep multiplying blocks
- Watch the start of the curve flatten: transfer lag
fig, ax = plt.subplots(figsize=(7, 5.25))
G = ct.tf([1], [1])
for n in range(1, 5):
G = G * ct.tf([1], [1, 1]) # add one tank, tau = 1
ax.plot(tt, ct.step_response(G, tt).outputs, label=f"n = {n}")
ax.set(xlabel="t", ylabel="Y(t)")
ax.legend(loc="lower right")
plt.show()
Your Turn (8 min)¶
Interacting tanks with $\tau_1=\tau_2=\tau$ and $A_1=A_2$ (so $A_1R_2=\tau$).
- By hand: show the denominator of Eq. 6.24 becomes $\tau^2 s^2 + 3\tau s + 1$.
- By hand: its roots, and the two effective time constants.
- Read the code: what do these print?
print(np.roots([1, 3, 1]))
print(-1 / np.roots([1, 3, 1]))
Check -- cold-call¶
Four tanks in series. You only have the measured $h_4(t)$.
What is the simplest model you would fit -- and what would its parameters mean physically?
Wrap-up¶
Derived, then coded: thermometer transfer function and its step and sinusoidal responses (Ch. 4); $\tau$ from data by least squares (Prob. 4.6); linearization and its validity window (Ch. 5); interacting versus noninteracting tanks (Ch. 6).
Tools: sp.laplace_transform, sp.apart, sp.solve, lambdify,
ct.tf, step_response, solve_ivp, curve_fit
Next: Ch. 7 -- transportation lag and second-order systems.