How to use this notebook¶
Every code cell is live: select it, press
Shift+EnterCells share memory, so run them top to bottom
Change a number, re-run, watch what moves
One running example the whole way through:
a first-order process, $\tau \dfrac{dy}{dt} + y = K\,x$
Agenda¶
- Python basics -- variables, lists, loops, functions
- NumPy -- arrays of numbers
- Matplotlib -- plotting
- SymPy -- symbolic algebra and Laplace transforms
- SciPy -- numerical ODEs and curve fitting
- python-control -- transfer functions and responses
- Putting it together -- one problem, three ways
Our running example¶
Lecture 2's mixing tank:
$$5\,\frac{dC_A}{dt} + C_A = 2, \qquad C_A(0) = 3$$
- Time constant $\tau = 5$ min
- Final value $C_A(\infty) = 2$ g/L
- Exact answer: $C_A(t) = 2 + e^{-t/5}$
Each library will get us to this same curve in its own way.
Variables and comments¶
name = valuestores a value under a name#starts a comment -- Python ignores the rest of the lineprint(...)shows values
In [1]:
# time constant and gain of a first-order process
tau = 5.0 # time constant, min
K = 1.0 # steady-state gain
print(tau, K) # print shows the values
5.0 1.0
Arithmetic and formatted output¶
+ - * /as usual,**for powers- An f-string drops values into text:
f"... {x} ..." :.2finside the braces means two decimals
In [2]:
t = 10.0 # min
ratio = t / tau # how many time constants?
print(f"t/tau = {ratio}")
print(f"t/tau = {ratio:.2f}") # two decimal places
print(f"tau squared = {tau**2}") # ** is a power
t/tau = 2.0 t/tau = 2.00 tau squared = 25.0
Lists and loops¶
- A list holds several values:
[a, b, c] forrepeats the indented lines once per item
In [3]:
times = [0, 5, 10, 15] # a list of times, min
for x in times: # x takes each value in turn
print(x, x / tau) # indented = inside the loop
# times / tau # <- uncomment: TypeError. Lists can't do math.
0 0.0 5 1.0 10 2.0 15 3.0
Functions¶
def name(inputs):packages a calculation for reusereturnsends the answer backK=1.0gives an input a default value
In [4]:
import math # standard-library math
def step_resp(t, tau, K=1.0):
# unit-step response of a first-order system
return K * (1 - math.exp(-t / tau))
print(step_resp(5.0, tau=5.0)) # t = tau -> 63.2 %
0.6321205588285577
Where plain Python runs out¶
math.exphandles one number at a time- We want a whole curve: hundreds of times at once
- Answer: NumPy arrays
In [5]:
# step_resp([0, 5, 10], tau=5.0) # <- uncomment: TypeError
for x in [0, 5, 10]: # workable, but clumsy
print(x, round(step_resp(x, 5.0), 3))
0 0.0 5 0.632 10 0.865
Import and the first array¶
import numpy as np-- everyone abbreviates itnp- An array is a list that can do math
In [6]:
import numpy as np
t = np.array([0, 5, 10, 15]) # array of times, min
print(t / 5) # math applies to every element
print(t.shape) # (4,) -> four numbers
[0. 1. 2. 3.] (4,)
Making time grids¶
np.linspace(start, stop, n)--nevenly spaced points, including the endnp.arange(start, stop, step)-- fixed step, excluding the end
In [7]:
print(np.linspace(0, 25, 6)) # 6 points from 0 to 25
print(np.arange(0, 25, 5)) # step 5, stops before 25
t = np.linspace(0, 25, 251) # fine grid: dt = 0.1 min
print(len(t), t[:3]) # count, first three values
[ 0. 5. 10. 15. 20. 25.] [ 0 5 10 15 20] 251 [0. 0.1 0.2]
Vectorized math¶
np.exp,np.sin,np.sqrt, ... work on whole arrays- No loop needed: the function of every element at once
In [8]:
tau = 5.0
y = 1 - np.exp(-t / tau) # step response, all 251 points
print(np.round(y[:5], 4)) # peek at the first five
print(f"y at t = 25: {y[-1]:.4f}") # index -1 is the last element
[0. 0.0198 0.0392 0.0582 0.0769] y at t = 25: 0.9933
Indexing and slicing¶
- Counting starts at 0:
y[0]is the first element y[a:b]-- elementsaup to (not including)by[-1]-- the last element
In [9]:
t5 = np.linspace(0, 25, 6) # 0, 5, 10, ...
y5 = 1 - np.exp(-t5 / 5)
print(y5[1]) # t = 5 = tau: 63.2 %
print(y5[1:4]) # elements 1, 2, 3
print(y5[::2]) # every second element
0.6321205588285577 [0.63212056 0.86466472 0.95021293] [0. 0.86466472 0.98168436]
Asking questions of an array¶
y >= 0.98gives an array of True/Falsenp.argmax(mask)-- index of the first True- Result: how long until 98 % complete?
In [10]:
mask = y >= 0.98 # True once we are within 2 %
i = np.argmax(mask) # first index where True
print(f"98% at t = {t[i]:.1f} min (4 tau = {4*tau} min)")
98% at t = 19.6 min (4 tau = 20.0 min)
Matrices¶
- A list of lists becomes a 2-D array
@is matrix multiplicationnp.linalg.eigvals-- eigenvalues, i.e. the poles of $\dot{x} = Ax$
In [11]:
A = np.array([[0, 1],
[-2, -3]]) # 2 x 2 matrix
x = np.array([1, 0])
print(A @ x) # matrix-vector product
print(np.linalg.eigvals(A)) # poles at -1 and -2
[ 0 -2] [-1.+0.j -2.+0.j]
The one-line plot¶
plt.plot(x, y)draws,plt.show()displays- Fine for a quick look, not for a report
In [12]:
import matplotlib.pyplot as plt
plt.plot(t, y) # y = 1 - exp(-t/5) from before
plt.show() # display the figure
Labels make it a figure¶
- Always: axis labels with units, and a grid
- Fonts are small by default; raise them for the classroom
In [13]:
plt.rcParams.update({"font.size": 15}) # bigger text everywhere
plt.plot(t, y)
plt.xlabel("t (min)")
plt.ylabel("y (fraction of final value)")
plt.title("First-order step response, tau = 5 min")
plt.grid(True)
plt.show()
Figure and axes objects¶
fig, ax = plt.subplots()gives you handles to control- Several curves: call
ax.plotrepeatedly, addlabel= ax.legend()draws the key
In [14]:
fig, ax = plt.subplots(figsize=(7, 4.5))
for tau_i in [2, 5, 10]: # three time constants
ax.plot(t, 1 - np.exp(-t / tau_i), label=f"tau = {tau_i}")
ax.set_xlabel("t (min)"); ax.set_ylabel("y")
ax.grid(True); ax.legend()
plt.show()
Reference lines and annotations¶
ax.axhline/ax.axvline-- horizontal / vertical guidesls="--"dashed,color="k"black- Mark the 63.2 % point at $t = \tau$
In [15]:
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(t, 1 - np.exp(-t / 5))
ax.axhline(0.632, ls="--", color="k") # 63.2 % level
ax.axvline(5, ls="--", color="k") # t = tau
ax.set_xlabel("t (min)"); ax.set_ylabel("y")
ax.grid(True)
plt.show()
Two panels: input and output¶
plt.subplots(2, 1, sharex=True)stacks two plots on one time axisax.stepdraws a piecewise-constant signal
In [16]:
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(7, 5))
ax1.step(t, np.ones_like(t), where="post") # unit step input
ax1.set_ylabel("x (input)")
ax2.plot(t, 1 - np.exp(-t / 5)) # response
ax2.set_ylabel("y (output)"); ax2.set_xlabel("t (min)")
plt.show()
Symbols and expressions¶
sp.symbols("s t")creates unknowns, not numbers- The last line of a cell is displayed, typeset
In [17]:
import sympy as sp
s, t = sp.symbols("s t") # symbols, not numbers
G = 1 / (5*s + 1) # a transfer function
G # last line is displayed
Out[17]:
$\displaystyle \frac{1}{5 s + 1}$
Algebra¶
sp.factor-- factor a polynomial (find the poles)sp.expand-- multiply outsp.simplify-- tidy an expression
In [18]:
den = s**2 + 3*s + 2
print(sp.factor(den)) # (s + 1)(s + 2)
print(sp.expand((s + 1) * (s + 2))) # back again
print(sp.solve(den, s)) # roots: the poles
(s + 1)*(s + 2) s**2 + 3*s + 2 [-2, -1]
Calculus¶
sp.diff(f, x)-- derivativesp.integrate(f, x)-- integral
In [19]:
y_t = 1 - sp.exp(-t / 5) # step response
print(sp.diff(y_t, t)) # slope of the curve
print(sp.diff(y_t, t).subs(t, 0)) # initial slope = 1/tau
print(sp.integrate(y_t, (t, 0, 5))) # area from 0 to 5
exp(-t/5)/5 1/5 5*exp(-1)
Laplace transforms¶
sp.laplace_transform(f, t, s)-- time domain to $s$noconds=Truehides convergence conditions
In [20]:
f = sp.exp(-t / 5)
F = sp.laplace_transform(f, t, s, noconds=True)
F # 1 / (s + 1/5)
Out[20]:
$\displaystyle \frac{1}{s + \frac{1}{5}}$
Partial fractions and the inverse¶
sp.apart-- partial fractions, by hand no moresp.inverse_laplace_transform-- back to timeHeaviside(t)is just the unit step: read it as 1 for $t > 0$
In [21]:
Y = 1 / (s * (5*s + 1)) # unit step through 1/(5s+1)
print(sp.apart(Y, s)) # partial fractions
sp.inverse_laplace_transform(Y, s, t)
-5/(5*s + 1) + 1/s
Out[21]:
$\displaystyle \theta\left(t\right) - e^{- \frac{t}{5}} \theta\left(t\right)$
Solving an ODE symbolically¶
- Declare an unknown function:
sp.Function("y") sp.dsolve(ode, y(t), ics={...})with the initial condition- Our mixing tank: $5\dot{C} + C = 2$, $C(0) = 3$
In [22]:
C = sp.Function("C")
ode = sp.Eq(5 * C(t).diff(t) + C(t), 2)
sol = sp.dsolve(ode, C(t), ics={C(0): 3})
sol # C(t) = 2 + exp(-t/5)
Out[22]:
$\displaystyle C{\left(t \right)} = 2 + e^{- \frac{t}{5}}$
Bridge to NumPy: lambdify¶
- A symbolic answer is not plottable yet
sp.lambdifyturns it into a fast NumPy function
In [23]:
C_exact = sp.lambdify(t, sol.rhs, "numpy") # symbolic -> function
tt = np.array([0, 5, 10, 25])
print(C_exact(tt)) # 3.0, 2.37, 2.14, 2.007
[3. 2.36787944 2.13533528 2.00673795]
Integrating an ODE numerically¶
- Write the ODE as $\dot{y} = f(t, y)$ in a Python function
solve_ivp(f, [t0, tf], [y0])steps it forward- Our tank: $\dot{C} = (2 - C)/5$
In [24]:
from scipy.integrate import solve_ivp
def mixing(t, C): # C is a list of states
return [(2 - C[0]) / 5] # dC/dt
tt = np.linspace(0, 25, 251)
sol_n = solve_ivp(mixing, [0, 25], [3.0], t_eval=tt)
print(sol_n.y[0][:3]) # first three values
[3. 2.98019867 2.96078943]
Check against the exact answer¶
sol_n.ttimes,sol_n.y[0]first state- Numerical and exact should agree to within tolerance
In [25]:
C_num = sol_n.y[0]
C_ex = 2 + np.exp(-tt / 5) # from SymPy earlier
err = np.abs(C_num - C_ex).max()
print(f"max |numerical - exact| = {err:.1e} g/L")
max |numerical - exact| = 1.1e-03 g/L
Plot: numerical vs exact¶
- Dots are the solver, line is the formula
- On top of each other: the solver is doing its job
In [26]:
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(tt, C_ex, label="exact")
ax.plot(tt[::15], C_num[::15], "o", label="solve_ivp")
ax.set_xlabel("t (min)"); ax.set_ylabel("C_A (g/L)")
ax.grid(True); ax.legend()
plt.show()
Passing parameters¶
- Extra values go in
args=(...)after $t$ and the states - One function then serves any $\tau$, $K$ and input
In [27]:
def first_order(t, y, tau, K, x):
return [(K * x - y[0]) / tau] # tau y' + y = K x
r = solve_ivp(first_order, [0, 25], [0.0], t_eval=tt,
args=(5.0, 2.0, 1.0)) # tau=5, K=2, step of 1
print(f"final value: {r.y[0][-1]:.3f} (K*x = 2, after 5 tau)")
final value: 1.986 (K*x = 2, after 5 tau)
Fitting a model to data¶
curve_fit(model, x, y, p0=guess)finds the best parameters- Real plant data are noisy; here we fake some
- Recover $K$ and $\tau$ from the noisy step test
In [28]:
from scipy.optimize import curve_fit
rng = np.random.default_rng(0) # repeatable noise
y_meas = 2*(1 - np.exp(-tt/5)) + rng.normal(0, 0.05, tt.size)
def model(t, K, tau):
return K * (1 - np.exp(-t / tau))
(K_fit, tau_fit), _ = curve_fit(model, tt, y_meas, p0=[1, 1])
print(f"K = {K_fit:.2f} (true 2), tau = {tau_fit:.2f} (true 5)")
K = 1.99 (true 2), tau = 4.92 (true 5)
Plot: data and fitted model¶
- Dots: the noisy "measurements"
- Line: the fitted first-order model
In [29]:
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(tt, y_meas, ".", alpha=0.5, label="data")
ax.plot(tt, model(tt, K_fit, tau_fit), lw=2, label="fit")
ax.set_xlabel("t (min)"); ax.set_ylabel("y")
ax.grid(True); ax.legend()
plt.show()
A transfer function object¶
ct.tf(num, den)-- coefficient lists, highest power first- $G(s) = \dfrac{1}{5s + 1}$ is
tf([1], [5, 1])
In [30]:
# pip install control (Colab only)
import control as ct
G = ct.tf([1], [5, 1]) # G(s) = 1 / (5 s + 1)
print(G)
<TransferFunction>: sys[0]
Inputs (1): ['u[0]']
Outputs (1): ['y[0]']
1
-------
5 s + 1
Poles, zeros, gain¶
- Poles: where the denominator is zero
- Steady-state gain: $G(0)$
- Time constant: $\tau = -1/\text{pole}$
In [31]:
print("poles:", ct.poles(G)) # -0.2 -> tau = 5
print("zeros:", ct.zeros(G)) # none
print("gain :", ct.dcgain(G)) # G(0) = 1
poles: [-0.2+0.j] zeros: [] gain : 1.0
Step response¶
ct.step_response(G, t)returns an object.timeand.outputshold the numbers- Compare with $1 - e^{-t/5}$
In [32]:
t = np.linspace(0, 25, 251)
resp = ct.step_response(G, t)
print(resp.outputs[:3]) # first three values
print(np.abs(resp.outputs - (1 - np.exp(-t/5))).max())
[0. 0.01980133 0.03921056] 2.9976021664879227e-15
Plot: step response¶
- Same curve as the NumPy formula, no algebra needed
- Works for any transfer function
In [33]:
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(resp.time, resp.outputs)
ax.set_xlabel("t (min)"); ax.set_ylabel("y")
ax.grid(True)
plt.show()
Combining blocks¶
G1 * G2-- blocks in series (multiply)- Two tanks in series: a second-order system
- Poles are the roots of the combined denominator
In [34]:
G1 = ct.tf([1], [2, 1]) # tank 1, tau = 2
G2 = ct.tf([1], [3, 1]) # tank 2, tau = 3
G12 = G1 * G2 # series connection
print(G12)
print("poles:", ct.poles(G12)) # -0.5 and -0.33
<TransferFunction>: sys[4]
Inputs (1): ['u[0]']
Outputs (1): ['y[0]']
1
---------------
6 s^2 + 5 s + 1
poles: [-0.5 +0.j -0.33333333+0.j]
Second-order behavior¶
- $G(s) = \dfrac{1}{\tau^2 s^2 + 2\zeta\tau s + 1}$
- Damping ratio $\zeta$ sets the shape
- Underdamped $\zeta < 1$, critical $\zeta = 1$, overdamped $\zeta > 1$
In [35]:
fig, ax = plt.subplots(figsize=(7, 4.5))
for zeta in [0.3, 1.0, 2.0]:
G2nd = ct.tf([1], [1, 2*zeta, 1]) # tau = 1
r = ct.step_response(G2nd, np.linspace(0, 12, 400))
ax.plot(r.time, r.outputs, label=f"zeta = {zeta}")
ax.set_xlabel("t"); ax.set_ylabel("y"); ax.grid(True); ax.legend()
plt.show()
Any input signal¶
ct.forced_response(G, t, u)-- response to an input array $u(t)$- Try a sine wave: the output is a lagged, smaller sine
In [36]:
t = np.linspace(0, 30, 601)
u = np.sin(1.0 * t) # input, 1 rad/min
out = ct.forced_response(G, t, u) # G = 1/(5s+1)
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(t, u, "--", label="input")
ax.plot(t, out.outputs, label="output")
ax.set_xlabel("t (min)"); ax.grid(True); ax.legend()
plt.show()
The same answer three ways¶
- Formula (NumPy), ODE solver (SciPy), transfer function (python-control)
- If all three agree, we can trust each one
In [37]:
tau, K = 5.0, 2.0
t = np.linspace(0, 25, 251)
exact = K * (1 - np.exp(-t / tau))
ode = solve_ivp(lambda t, y: [(K - y[0]) / tau],
[0, 25], [0.0], t_eval=t).y[0]
tf_out = ct.step_response(ct.tf([K], [tau, 1]), t).outputs
# largest differences: small numbers mean the routes agree
print(np.abs(exact - ode).max(), np.abs(exact - tf_out).max())
0.0002730024462485048 5.995204332975845e-15
Cheat sheet¶
| Library | Job in this course | Calls to remember |
|---|---|---|
| NumPy | arrays, time grids | np.linspace, np.exp, np.argmax |
| Matplotlib | figures | plt.subplots, ax.plot, ax.legend |
| SymPy | algebra, Laplace | sp.apart, sp.dsolve, sp.laplace_transform |
| SciPy | ODEs, fitting | solve_ivp, curve_fit |
| python-control | transfer functions | ct.tf, ct.step_response, ct.poles |
Common stumbles¶
NameError: you skipped a cell. Run from the top, or Kernel > Restart & Run AllIndentationError: lines inside a loop or function must be indented equally- Unbalanced brackets: count
(against) - Coefficient order in
ct.tfis highest power first - Read the last line of an error message first
Coming later in the course¶
- GEKKO -- optimization and Model Predictive Control (not needed today)
ct.bode_plot,ct.margin-- frequency response and stabilityct.ss-- state-space models, using the matrix tools from Part 2
Wrap-up¶
- Six tools: Python, NumPy, Matplotlib, SymPy, SciPy, python-control
- Every lecture from now on runs on these
- Change a number and re-run: that is how you learn them
Next: Lecture 3 -- first-order systems, by simulation