Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
Ok-landscape
GitHub Repository: Ok-landscape/computational-pipeline
Path: blob/main/latex-templates/templates/electrical-engineering/control_systems.tex
51 views
unlisted
1
\documentclass[a4paper, 11pt]{article}
2
\usepackage[utf8]{inputenc}
3
\usepackage[T1]{fontenc}
4
\usepackage{amsmath, amssymb, amsthm}
5
\usepackage{graphicx}
6
\usepackage{siunitx}
7
\usepackage{booktabs}
8
\usepackage{float}
9
\usepackage{geometry}
10
\geometry{margin=1in}
11
\usepackage[makestderr]{pythontex}
12
13
% Theorem environments
14
\newtheorem{theorem}{Theorem}[section]
15
\newtheorem{definition}[theorem]{Definition}
16
\newtheorem{property}[theorem]{Property}
17
18
\title{Control Systems Analysis: A Comprehensive Tutorial on\\Classical Feedback Control Design}
19
\author{Control Systems Engineering Laboratory}
20
\date{\today}
21
22
\begin{document}
23
\maketitle
24
25
\begin{abstract}
26
This tutorial provides a comprehensive analysis of classical control system design techniques. We examine transfer function modeling, frequency response analysis through Bode and Nyquist plots, root locus methods for stability analysis, and PID controller tuning strategies. All computations are performed dynamically using Python's control systems toolbox, demonstrating reproducible engineering analysis workflows.
27
\end{abstract}
28
29
\section{Introduction to Feedback Control}
30
31
Feedback control systems are ubiquitous in modern engineering, from industrial process control to aerospace guidance systems. The fundamental objective is to maintain a desired output despite disturbances and uncertainties.
32
33
\begin{definition}[Closed-Loop Transfer Function]
34
For a unity feedback system with plant $G(s)$ and controller $C(s)$, the closed-loop transfer function is:
35
\begin{equation}
36
T(s) = \frac{C(s)G(s)}{1 + C(s)G(s)} = \frac{L(s)}{1 + L(s)}
37
\end{equation}
38
where $L(s) = C(s)G(s)$ is the loop transfer function.
39
\end{definition}
40
41
\section{System Modeling and Transfer Functions}
42
43
\begin{pycode}
44
import numpy as np
45
import matplotlib.pyplot as plt
46
from scipy import signal
47
from scipy.optimize import brentq
48
49
plt.rc('text', usetex=True)
50
plt.rc('font', family='serif', size=9)
51
np.random.seed(42)
52
53
# Define the plant: Second-order system with delay approximation
54
# G(s) = K / (s^2 + 2*zeta*wn*s + wn^2)
55
K_plant = 10.0
56
wn = 5.0 # Natural frequency (rad/s)
57
zeta = 0.3 # Damping ratio
58
59
num_plant = [K_plant * wn**2]
60
den_plant = [1, 2*zeta*wn, wn**2]
61
plant = signal.TransferFunction(num_plant, den_plant)
62
63
# Plant characteristics
64
poles_plant = np.roots(den_plant)
65
dc_gain_plant = K_plant
66
67
# Time vector for simulations
68
t = np.linspace(0, 4, 1000)
69
\end{pycode}
70
71
We consider a second-order plant with transfer function:
72
\begin{equation}
73
G(s) = \frac{K\omega_n^2}{s^2 + 2\zeta\omega_n s + \omega_n^2}
74
\end{equation}
75
76
\noindent\textbf{Plant Parameters:}
77
\begin{itemize}
78
\item DC Gain: $K = \py{K_plant}$
79
\item Natural Frequency: $\omega_n = \py{wn}$ rad/s
80
\item Damping Ratio: $\zeta = \py{zeta}$
81
\item Plant Poles: $s = \py{f"{poles_plant[0]:.2f}"}$, $\py{f"{poles_plant[1]:.2f}"}$
82
\end{itemize}
83
84
\section{Open-Loop Step Response Analysis}
85
86
\begin{pycode}
87
# Open-loop step response
88
t_ol, y_ol = signal.step(plant, T=t)
89
90
# Calculate open-loop characteristics
91
peak_idx = np.argmax(y_ol)
92
peak_time = t_ol[peak_idx]
93
peak_val = y_ol[peak_idx]
94
overshoot_ol = (peak_val / K_plant - 1) * 100
95
96
# Settling time (2% criterion)
97
steady_state = K_plant
98
tolerance = 0.02 * steady_state
99
settled_indices = np.where(np.abs(y_ol - steady_state) <= tolerance)[0]
100
if len(settled_indices) > 0:
101
# Find first time it stays within tolerance
102
for i in range(len(settled_indices)):
103
if np.all(np.abs(y_ol[settled_indices[i]:] - steady_state) <= tolerance):
104
settling_time_ol = t_ol[settled_indices[i]]
105
break
106
else:
107
settling_time_ol = t_ol[-1]
108
else:
109
settling_time_ol = t_ol[-1]
110
111
# Rise time (10% to 90%)
112
y_10 = 0.1 * steady_state
113
y_90 = 0.9 * steady_state
114
t_10 = t_ol[np.where(y_ol >= y_10)[0][0]]
115
t_90 = t_ol[np.where(y_ol >= y_90)[0][0]]
116
rise_time_ol = t_90 - t_10
117
118
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
119
120
# Step response
121
axes[0].plot(t_ol, y_ol, 'b-', linewidth=1.5, label='Response')
122
axes[0].axhline(y=K_plant, color='r', linestyle='--', alpha=0.7, label='Steady State')
123
axes[0].axhline(y=K_plant*1.02, color='gray', linestyle=':', alpha=0.5)
124
axes[0].axhline(y=K_plant*0.98, color='gray', linestyle=':', alpha=0.5)
125
axes[0].plot(peak_time, peak_val, 'ro', markersize=6)
126
axes[0].annotate(f'Peak: {peak_val:.2f}', xy=(peak_time, peak_val),
127
xytext=(peak_time+0.3, peak_val+0.5), fontsize=8,
128
arrowprops=dict(arrowstyle='->', color='black', lw=0.5))
129
axes[0].set_xlabel('Time (s)')
130
axes[0].set_ylabel('Output')
131
axes[0].set_title('Open-Loop Step Response')
132
axes[0].legend(loc='right')
133
axes[0].grid(True, alpha=0.3)
134
135
# Impulse response
136
t_imp, y_imp = signal.impulse(plant, T=t)
137
axes[1].plot(t_imp, y_imp, 'g-', linewidth=1.5)
138
axes[1].set_xlabel('Time (s)')
139
axes[1].set_ylabel('Output')
140
axes[1].set_title('Open-Loop Impulse Response')
141
axes[1].grid(True, alpha=0.3)
142
143
plt.tight_layout()
144
plt.savefig('control_systems_plot1.pdf', bbox_inches='tight', dpi=150)
145
plt.close()
146
\end{pycode}
147
148
\begin{figure}[H]
149
\centering
150
\includegraphics[width=0.95\textwidth]{control_systems_plot1.pdf}
151
\caption{Open-loop time domain responses of the plant.}
152
\end{figure}
153
154
\begin{table}[H]
155
\centering
156
\caption{Open-Loop Performance Metrics}
157
\begin{tabular}{lcc}
158
\toprule
159
\textbf{Metric} & \textbf{Value} & \textbf{Unit} \\
160
\midrule
161
Peak Time & \py{f"{peak_time:.3f}"} & s \\
162
Peak Overshoot & \py{f"{overshoot_ol:.1f}"} & \% \\
163
Rise Time (10-90\%) & \py{f"{rise_time_ol:.3f}"} & s \\
164
Settling Time (2\%) & \py{f"{settling_time_ol:.3f}"} & s \\
165
DC Gain & \py{f"{dc_gain_plant:.1f}"} & -- \\
166
\bottomrule
167
\end{tabular}
168
\end{table}
169
170
\section{Frequency Response Analysis}
171
172
\subsection{Bode Plot Analysis}
173
174
\begin{theorem}[Bode Gain-Phase Relationship]
175
For minimum-phase systems, the phase is uniquely determined by the magnitude characteristic through the Hilbert transform relationship.
176
\end{theorem}
177
178
\begin{pycode}
179
# Frequency response analysis
180
w = np.logspace(-1, 2, 1000)
181
w_bode, mag_plant, phase_plant = signal.bode(plant, w)
182
183
# Find key frequencies
184
idx_wn = np.argmin(np.abs(w_bode - wn))
185
mag_at_wn = mag_plant[idx_wn]
186
phase_at_wn = phase_plant[idx_wn]
187
188
# Bandwidth (-3dB point)
189
mag_3db = 20*np.log10(K_plant) - 3
190
bandwidth_idx = np.where(mag_plant < mag_3db)[0]
191
if len(bandwidth_idx) > 0:
192
bandwidth = w_bode[bandwidth_idx[0]]
193
else:
194
bandwidth = w_bode[-1]
195
196
# Resonant peak
197
resonant_idx = np.argmax(mag_plant)
198
resonant_freq = w_bode[resonant_idx]
199
resonant_peak = mag_plant[resonant_idx]
200
201
fig, axes = plt.subplots(2, 1, figsize=(10, 6))
202
203
# Magnitude plot
204
axes[0].semilogx(w_bode, mag_plant, 'b-', linewidth=1.5)
205
axes[0].axhline(y=0, color='gray', linestyle='--', alpha=0.5)
206
axes[0].axhline(y=mag_3db, color='r', linestyle=':', alpha=0.7, label='-3dB')
207
axes[0].axvline(x=wn, color='g', linestyle=':', alpha=0.7, label=r'$\omega_n$')
208
axes[0].axvline(x=bandwidth, color='orange', linestyle='--', alpha=0.7, label='Bandwidth')
209
axes[0].plot(resonant_freq, resonant_peak, 'ro', markersize=5)
210
axes[0].set_ylabel('Magnitude (dB)')
211
axes[0].set_title('Bode Plot - Plant $G(s)$')
212
axes[0].legend(loc='lower left', fontsize=8)
213
axes[0].grid(True, which='both', alpha=0.3)
214
215
# Phase plot
216
axes[1].semilogx(w_bode, phase_plant, 'b-', linewidth=1.5)
217
axes[1].axhline(y=-90, color='gray', linestyle='--', alpha=0.5)
218
axes[1].axhline(y=-180, color='r', linestyle='--', alpha=0.5)
219
axes[1].axvline(x=wn, color='g', linestyle=':', alpha=0.7)
220
axes[1].set_xlabel('Frequency (rad/s)')
221
axes[1].set_ylabel('Phase (degrees)')
222
axes[1].grid(True, which='both', alpha=0.3)
223
224
plt.tight_layout()
225
plt.savefig('control_systems_plot2.pdf', bbox_inches='tight', dpi=150)
226
plt.close()
227
\end{pycode}
228
229
\begin{figure}[H]
230
\centering
231
\includegraphics[width=0.95\textwidth]{control_systems_plot2.pdf}
232
\caption{Bode plot of the plant transfer function showing magnitude and phase response.}
233
\end{figure}
234
235
\noindent\textbf{Frequency Domain Characteristics:}
236
\begin{itemize}
237
\item Bandwidth: $\omega_{BW} = \py{f"{bandwidth:.2f}"}$ rad/s
238
\item Resonant Frequency: $\omega_r = \py{f"{resonant_freq:.2f}"}$ rad/s
239
\item Resonant Peak: $M_r = \py{f"{resonant_peak:.1f}"}$ dB
240
\end{itemize}
241
242
\subsection{Nyquist Plot and Stability Analysis}
243
244
\begin{definition}[Nyquist Stability Criterion]
245
A closed-loop system is stable if and only if the Nyquist contour of $L(j\omega)$ encircles the point $-1$ exactly $P$ times counter-clockwise, where $P$ is the number of unstable open-loop poles.
246
\end{definition}
247
248
\begin{pycode}
249
# Nyquist plot
250
w_nyq = np.concatenate([np.linspace(0.01, 100, 5000)])
251
_, H = signal.freqs(num_plant, den_plant, w_nyq)
252
253
# Calculate gain and phase margins for the open-loop plant
254
# For phase margin: find frequency where |G| = 1 (0 dB)
255
mag_linear = 10**(mag_plant/20)
256
crossover_indices = np.where(np.diff(np.sign(mag_linear - 1)))[0]
257
if len(crossover_indices) > 0:
258
gc_freq = w_bode[crossover_indices[0]]
259
gc_phase = phase_plant[crossover_indices[0]]
260
phase_margin = 180 + gc_phase
261
else:
262
gc_freq = 0
263
phase_margin = np.inf
264
265
# For gain margin: find frequency where phase = -180
266
phase_crossover_indices = np.where(np.diff(np.sign(phase_plant + 180)))[0]
267
if len(phase_crossover_indices) > 0:
268
pc_freq = w_bode[phase_crossover_indices[0]]
269
pc_mag = mag_plant[phase_crossover_indices[0]]
270
gain_margin = -pc_mag
271
else:
272
pc_freq = np.inf
273
gain_margin = np.inf
274
275
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
276
277
# Nyquist plot
278
axes[0].plot(np.real(H), np.imag(H), 'b-', linewidth=1.5, label=r'$\omega > 0$')
279
axes[0].plot(np.real(H), -np.imag(H), 'b--', linewidth=1, alpha=0.7, label=r'$\omega < 0$')
280
axes[0].plot(-1, 0, 'rx', markersize=10, markeredgewidth=2, label='Critical Point')
281
axes[0].set_xlabel('Real')
282
axes[0].set_ylabel('Imaginary')
283
axes[0].set_title('Nyquist Plot')
284
axes[0].legend(loc='lower left', fontsize=8)
285
axes[0].grid(True, alpha=0.3)
286
axes[0].axis('equal')
287
axes[0].set_xlim([-15, 15])
288
axes[0].set_ylim([-15, 15])
289
290
# Nichols chart (Magnitude vs Phase)
291
axes[1].plot(phase_plant, mag_plant, 'b-', linewidth=1.5)
292
axes[1].axvline(x=-180, color='r', linestyle='--', alpha=0.7)
293
axes[1].axhline(y=0, color='r', linestyle='--', alpha=0.7)
294
axes[1].plot(-180, 0, 'rx', markersize=10, markeredgewidth=2)
295
axes[1].set_xlabel('Phase (degrees)')
296
axes[1].set_ylabel('Magnitude (dB)')
297
axes[1].set_title('Nichols Chart')
298
axes[1].grid(True, alpha=0.3)
299
300
plt.tight_layout()
301
plt.savefig('control_systems_plot3.pdf', bbox_inches='tight', dpi=150)
302
plt.close()
303
\end{pycode}
304
305
\begin{figure}[H]
306
\centering
307
\includegraphics[width=0.95\textwidth]{control_systems_plot3.pdf}
308
\caption{Nyquist plot and Nichols chart for stability analysis.}
309
\end{figure}
310
311
\section{Root Locus Analysis}
312
313
The root locus shows how closed-loop poles migrate as a gain parameter varies.
314
315
\begin{property}[Root Locus Rules]
316
\begin{enumerate}
317
\item The root locus has $n$ branches, where $n$ is the number of open-loop poles
318
\item Branches start at open-loop poles ($K=0$) and end at zeros ($K=\infty$)
319
\item The locus is symmetric about the real axis
320
\end{enumerate}
321
\end{property}
322
323
\begin{pycode}
324
# Root locus computation
325
K_range = np.linspace(0.01, 100, 500)
326
poles_rl = []
327
328
for K in K_range:
329
# Closed-loop characteristic equation: 1 + K*G(s) = 0
330
# den_plant + K*num_plant = 0
331
char_poly = np.polyadd(den_plant, K * np.array(num_plant))
332
roots = np.roots(char_poly)
333
poles_rl.append(roots)
334
335
poles_rl = np.array(poles_rl)
336
337
# Find critical gain for marginal stability
338
critical_K = None
339
for i, K in enumerate(K_range):
340
if np.any(np.real(poles_rl[i]) > 0):
341
critical_K = K_range[i-1] if i > 0 else K
342
break
343
344
# Find gain for specific damping ratio (zeta = 0.5)
345
target_zeta = 0.5
346
K_for_zeta = None
347
for i, K in enumerate(K_range):
348
poles = poles_rl[i]
349
complex_poles = poles[np.iscomplex(poles)]
350
if len(complex_poles) > 0:
351
actual_zeta = -np.real(complex_poles[0]) / np.abs(complex_poles[0])
352
if actual_zeta <= target_zeta:
353
K_for_zeta = K
354
break
355
356
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
357
358
# Root locus plot
359
for i in range(poles_rl.shape[1]):
360
axes[0].plot(np.real(poles_rl[:, i]), np.imag(poles_rl[:, i]),
361
'b-', linewidth=1)
362
axes[0].plot(np.real(poles_plant), np.imag(poles_plant), 'rx',
363
markersize=10, markeredgewidth=2, label='Open-loop Poles')
364
axes[0].axvline(x=0, color='gray', linestyle='-', alpha=0.3)
365
axes[0].axhline(y=0, color='gray', linestyle='-', alpha=0.3)
366
367
# Draw damping ratio lines
368
for z in [0.3, 0.5, 0.7]:
369
theta = np.arccos(z)
370
r = np.linspace(0, 20, 100)
371
axes[0].plot(-r*np.cos(theta), r*np.sin(theta), 'g:', alpha=0.5)
372
axes[0].plot(-r*np.cos(theta), -r*np.sin(theta), 'g:', alpha=0.5)
373
374
axes[0].set_xlabel('Real')
375
axes[0].set_ylabel('Imaginary')
376
axes[0].set_title('Root Locus')
377
axes[0].legend(loc='lower left', fontsize=8)
378
axes[0].grid(True, alpha=0.3)
379
axes[0].set_xlim([-15, 5])
380
axes[0].set_ylim([-10, 10])
381
382
# Damping ratio vs Gain
383
damping_ratios = []
384
for i, K in enumerate(K_range):
385
poles = poles_rl[i]
386
complex_poles = poles[np.abs(np.imag(poles)) > 0.01]
387
if len(complex_poles) > 0:
388
zeta = -np.real(complex_poles[0]) / np.abs(complex_poles[0])
389
damping_ratios.append(zeta)
390
else:
391
damping_ratios.append(1.0)
392
393
axes[1].plot(K_range, damping_ratios, 'b-', linewidth=1.5)
394
axes[1].axhline(y=0.5, color='r', linestyle='--', alpha=0.7, label=r'$\zeta = 0.5$')
395
axes[1].axhline(y=0.707, color='g', linestyle='--', alpha=0.7, label=r'$\zeta = 0.707$')
396
axes[1].set_xlabel('Gain $K$')
397
axes[1].set_ylabel('Damping Ratio $\\zeta$')
398
axes[1].set_title('Damping Ratio vs Gain')
399
axes[1].legend(loc='upper right', fontsize=8)
400
axes[1].grid(True, alpha=0.3)
401
402
plt.tight_layout()
403
plt.savefig('control_systems_plot4.pdf', bbox_inches='tight', dpi=150)
404
plt.close()
405
\end{pycode}
406
407
\begin{figure}[H]
408
\centering
409
\includegraphics[width=0.95\textwidth]{control_systems_plot4.pdf}
410
\caption{Root locus plot showing pole migration with gain and corresponding damping ratio variation.}
411
\end{figure}
412
413
\section{PID Controller Design}
414
415
\subsection{PID Transfer Function}
416
417
The PID controller transfer function is:
418
\begin{equation}
419
C(s) = K_p + \frac{K_i}{s} + K_d s = \frac{K_d s^2 + K_p s + K_i}{s}
420
\end{equation}
421
422
\begin{pycode}
423
# PID controller design using Ziegler-Nichols tuning
424
# First, find ultimate gain and period
425
426
# Ultimate gain: gain at which system becomes marginally stable
427
# For our system, we'll use the ultimate gain method
428
Ku = 2 * zeta * wn**2 / K_plant # Approximate ultimate gain
429
Tu = 2 * np.pi / wn # Ultimate period
430
431
# Ziegler-Nichols PID tuning rules
432
Kp_zn = 0.6 * Ku
433
Ki_zn = 2 * Kp_zn / Tu
434
Kd_zn = Kp_zn * Tu / 8
435
436
# Alternative tuning: Cohen-Coon
437
Kp_cc = 0.5 * Ku
438
Ki_cc = 1.5 * Kp_cc / Tu
439
Kd_cc = Kp_cc * Tu / 6
440
441
# Construct PID transfer functions
442
def create_closed_loop(Kp, Ki, Kd, num_plant, den_plant):
443
# PID: (Kd*s^2 + Kp*s + Ki) / s
444
num_pid = [Kd, Kp, Ki]
445
den_pid = [1, 0]
446
447
# Open loop: PID * Plant
448
num_ol = np.convolve(num_pid, num_plant)
449
den_ol = np.convolve(den_pid, den_plant)
450
451
# Closed loop: num_ol / (den_ol + num_ol)
452
num_cl = num_ol
453
den_cl = np.polyadd(den_ol, num_ol)
454
455
return signal.TransferFunction(num_cl, den_cl)
456
457
# Create closed-loop systems
458
cl_zn = create_closed_loop(Kp_zn, Ki_zn, Kd_zn, num_plant, den_plant)
459
cl_cc = create_closed_loop(Kp_cc, Ki_cc, Kd_cc, num_plant, den_plant)
460
461
# Step responses
462
t_cl = np.linspace(0, 3, 1000)
463
t_zn, y_zn = signal.step(cl_zn, T=t_cl)
464
t_cc, y_cc = signal.step(cl_cc, T=t_cl)
465
\end{pycode}
466
467
\begin{table}[H]
468
\centering
469
\caption{PID Tuning Parameters}
470
\begin{tabular}{lccc}
471
\toprule
472
\textbf{Method} & $K_p$ & $K_i$ & $K_d$ \\
473
\midrule
474
Ziegler-Nichols & \py{f"{Kp_zn:.3f}"} & \py{f"{Ki_zn:.3f}"} & \py{f"{Kd_zn:.4f}"} \\
475
Cohen-Coon & \py{f"{Kp_cc:.3f}"} & \py{f"{Ki_cc:.3f}"} & \py{f"{Kd_cc:.4f}"} \\
476
\bottomrule
477
\end{tabular}
478
\end{table}
479
480
\subsection{Closed-Loop Performance Comparison}
481
482
\begin{pycode}
483
# Performance metrics calculation
484
def calc_metrics(t, y):
485
steady_state = 1.0
486
# Overshoot
487
overshoot = (np.max(y) - steady_state) / steady_state * 100
488
# Settling time
489
tolerance = 0.02
490
settled = np.where(np.abs(y - steady_state) <= tolerance)[0]
491
if len(settled) > 0:
492
for i in range(len(settled)):
493
if np.all(np.abs(y[settled[i]:] - steady_state) <= tolerance):
494
settling = t[settled[i]]
495
break
496
else:
497
settling = t[-1]
498
else:
499
settling = t[-1]
500
# Rise time
501
idx_10 = np.where(y >= 0.1)[0]
502
idx_90 = np.where(y >= 0.9)[0]
503
if len(idx_10) > 0 and len(idx_90) > 0:
504
rise = t[idx_90[0]] - t[idx_10[0]]
505
else:
506
rise = 0
507
return overshoot, settling, rise
508
509
os_zn, st_zn, rt_zn = calc_metrics(t_zn, y_zn)
510
os_cc, st_cc, rt_cc = calc_metrics(t_cc, y_cc)
511
512
fig, axes = plt.subplots(2, 2, figsize=(10, 8))
513
514
# Step response comparison
515
axes[0, 0].plot(t_zn, y_zn, 'b-', linewidth=1.5, label='Ziegler-Nichols')
516
axes[0, 0].plot(t_cc, y_cc, 'r--', linewidth=1.5, label='Cohen-Coon')
517
axes[0, 0].axhline(y=1.0, color='gray', linestyle='--', alpha=0.5)
518
axes[0, 0].axhline(y=1.02, color='gray', linestyle=':', alpha=0.3)
519
axes[0, 0].axhline(y=0.98, color='gray', linestyle=':', alpha=0.3)
520
axes[0, 0].set_xlabel('Time (s)')
521
axes[0, 0].set_ylabel('Output')
522
axes[0, 0].set_title('Closed-Loop Step Response')
523
axes[0, 0].legend(loc='lower right', fontsize=8)
524
axes[0, 0].grid(True, alpha=0.3)
525
526
# Control effort
527
# Approximate control signal: u = Kp*e + Ki*int(e) + Kd*de/dt
528
e_zn = 1 - y_zn
529
u_zn = Kp_zn * e_zn + Kd_zn * np.gradient(e_zn, t_zn)
530
531
e_cc = 1 - y_cc
532
u_cc = Kp_cc * e_cc + Kd_cc * np.gradient(e_cc, t_cc)
533
534
axes[0, 1].plot(t_zn, u_zn, 'b-', linewidth=1.5, label='Ziegler-Nichols')
535
axes[0, 1].plot(t_cc, u_cc, 'r--', linewidth=1.5, label='Cohen-Coon')
536
axes[0, 1].set_xlabel('Time (s)')
537
axes[0, 1].set_ylabel('Control Signal')
538
axes[0, 1].set_title('Control Effort')
539
axes[0, 1].legend(loc='upper right', fontsize=8)
540
axes[0, 1].grid(True, alpha=0.3)
541
542
# Bode plot of closed-loop
543
w_cl = np.logspace(-1, 2, 500)
544
w_cl_zn, mag_cl_zn, phase_cl_zn = signal.bode(cl_zn, w_cl)
545
w_cl_cc, mag_cl_cc, phase_cl_cc = signal.bode(cl_cc, w_cl)
546
547
axes[1, 0].semilogx(w_cl_zn, mag_cl_zn, 'b-', linewidth=1.5, label='Ziegler-Nichols')
548
axes[1, 0].semilogx(w_cl_cc, mag_cl_cc, 'r--', linewidth=1.5, label='Cohen-Coon')
549
axes[1, 0].axhline(y=-3, color='gray', linestyle=':', alpha=0.5)
550
axes[1, 0].set_xlabel('Frequency (rad/s)')
551
axes[1, 0].set_ylabel('Magnitude (dB)')
552
axes[1, 0].set_title('Closed-Loop Frequency Response')
553
axes[1, 0].legend(loc='lower left', fontsize=8)
554
axes[1, 0].grid(True, which='both', alpha=0.3)
555
556
# Sensitivity function S = 1/(1+L)
557
num_ol_zn = np.convolve([Kd_zn, Kp_zn, Ki_zn], num_plant)
558
den_ol_zn = np.convolve([1, 0], den_plant)
559
sensitivity_den = np.polyadd(den_ol_zn, num_ol_zn)
560
sens_tf = signal.TransferFunction(den_ol_zn, sensitivity_den)
561
w_s, mag_s, _ = signal.bode(sens_tf, w_cl)
562
563
axes[1, 1].semilogx(w_s, mag_s, 'b-', linewidth=1.5)
564
axes[1, 1].axhline(y=0, color='gray', linestyle='--', alpha=0.5)
565
axes[1, 1].set_xlabel('Frequency (rad/s)')
566
axes[1, 1].set_ylabel('Magnitude (dB)')
567
axes[1, 1].set_title('Sensitivity Function $S(j\\omega)$')
568
axes[1, 1].grid(True, which='both', alpha=0.3)
569
570
plt.tight_layout()
571
plt.savefig('control_systems_plot5.pdf', bbox_inches='tight', dpi=150)
572
plt.close()
573
\end{pycode}
574
575
\begin{figure}[H]
576
\centering
577
\includegraphics[width=0.95\textwidth]{control_systems_plot5.pdf}
578
\caption{Closed-loop performance comparison between Ziegler-Nichols and Cohen-Coon tuning methods.}
579
\end{figure}
580
581
\begin{table}[H]
582
\centering
583
\caption{Closed-Loop Performance Metrics}
584
\begin{tabular}{lccc}
585
\toprule
586
\textbf{Method} & \textbf{Overshoot (\%)} & \textbf{Settling Time (s)} & \textbf{Rise Time (s)} \\
587
\midrule
588
Ziegler-Nichols & \py{f"{os_zn:.1f}"} & \py{f"{st_zn:.3f}"} & \py{f"{rt_zn:.3f}"} \\
589
Cohen-Coon & \py{f"{os_cc:.1f}"} & \py{f"{st_cc:.3f}"} & \py{f"{rt_cc:.3f}"} \\
590
\bottomrule
591
\end{tabular}
592
\end{table}
593
594
\section{Stability Margins and Robustness}
595
596
\begin{pycode}
597
# Calculate stability margins for the compensated system
598
# Open-loop with ZN controller
599
num_ol_comp = np.convolve([Kd_zn, Kp_zn, Ki_zn], num_plant)
600
den_ol_comp = np.convolve([1, 0], den_plant)
601
602
w_margin = np.logspace(-2, 3, 2000)
603
w_m, mag_m, phase_m = signal.bode((num_ol_comp, den_ol_comp), w_margin)
604
605
# Gain margin
606
mag_lin = 10**(mag_m/20)
607
phase_cross_idx = np.where(np.diff(np.sign(phase_m + 180)))[0]
608
if len(phase_cross_idx) > 0:
609
pc_freq_comp = w_m[phase_cross_idx[0]]
610
gm_db = -mag_m[phase_cross_idx[0]]
611
else:
612
pc_freq_comp = np.inf
613
gm_db = np.inf
614
615
# Phase margin
616
gain_cross_idx = np.where(np.diff(np.sign(mag_lin - 1)))[0]
617
if len(gain_cross_idx) > 0:
618
gc_freq_comp = w_m[gain_cross_idx[0]]
619
pm_deg = 180 + phase_m[gain_cross_idx[0]]
620
else:
621
gc_freq_comp = 0
622
pm_deg = np.inf
623
624
fig, axes = plt.subplots(2, 2, figsize=(10, 8))
625
626
# Open-loop Bode with margins marked
627
axes[0, 0].semilogx(w_m, mag_m, 'b-', linewidth=1.5)
628
axes[0, 0].axhline(y=0, color='r', linestyle='--', alpha=0.7)
629
if pc_freq_comp < np.inf:
630
axes[0, 0].axvline(x=pc_freq_comp, color='g', linestyle=':', alpha=0.7)
631
axes[0, 0].annotate(f'GM = {gm_db:.1f} dB',
632
xy=(pc_freq_comp, -gm_db), xytext=(pc_freq_comp*2, -gm_db+10),
633
fontsize=8, arrowprops=dict(arrowstyle='->', color='green', lw=0.5))
634
axes[0, 0].set_ylabel('Magnitude (dB)')
635
axes[0, 0].set_title('Open-Loop Bode (Compensated)')
636
axes[0, 0].grid(True, which='both', alpha=0.3)
637
638
axes[0, 1].semilogx(w_m, phase_m, 'b-', linewidth=1.5)
639
axes[0, 1].axhline(y=-180, color='r', linestyle='--', alpha=0.7)
640
if gc_freq_comp > 0:
641
axes[0, 1].axvline(x=gc_freq_comp, color='orange', linestyle=':', alpha=0.7)
642
axes[0, 1].annotate(f'PM = {pm_deg:.1f}°',
643
xy=(gc_freq_comp, -180+pm_deg), xytext=(gc_freq_comp*2, -180+pm_deg+20),
644
fontsize=8, arrowprops=dict(arrowstyle='->', color='orange', lw=0.5))
645
axes[0, 1].set_xlabel('Frequency (rad/s)')
646
axes[0, 1].set_ylabel('Phase (degrees)')
647
axes[0, 1].grid(True, which='both', alpha=0.3)
648
649
# Gain and phase margin vs Kp variation
650
Kp_var = np.linspace(0.1, 2.0, 50) * Kp_zn
651
gm_var = []
652
pm_var = []
653
654
for kp in Kp_var:
655
num_ol_var = np.convolve([Kd_zn, kp, Ki_zn], num_plant)
656
w_v, mag_v, phase_v = signal.bode((num_ol_var, den_ol_comp), w_margin)
657
658
mag_lin_v = 10**(mag_v/20)
659
gc_idx = np.where(np.diff(np.sign(mag_lin_v - 1)))[0]
660
if len(gc_idx) > 0:
661
pm_var.append(180 + phase_v[gc_idx[0]])
662
else:
663
pm_var.append(np.nan)
664
665
pc_idx = np.where(np.diff(np.sign(phase_v + 180)))[0]
666
if len(pc_idx) > 0:
667
gm_var.append(-mag_v[pc_idx[0]])
668
else:
669
gm_var.append(np.nan)
670
671
axes[1, 0].plot(Kp_var/Kp_zn, gm_var, 'b-', linewidth=1.5)
672
axes[1, 0].axhline(y=6, color='r', linestyle='--', alpha=0.7, label='Min GM = 6 dB')
673
axes[1, 0].set_xlabel('$K_p / K_{p,ZN}$')
674
axes[1, 0].set_ylabel('Gain Margin (dB)')
675
axes[1, 0].set_title('Gain Margin Sensitivity')
676
axes[1, 0].legend(loc='upper right', fontsize=8)
677
axes[1, 0].grid(True, alpha=0.3)
678
679
axes[1, 1].plot(Kp_var/Kp_zn, pm_var, 'r-', linewidth=1.5)
680
axes[1, 1].axhline(y=45, color='b', linestyle='--', alpha=0.7, label='Min PM = 45°')
681
axes[1, 1].set_xlabel('$K_p / K_{p,ZN}$')
682
axes[1, 1].set_ylabel('Phase Margin (degrees)')
683
axes[1, 1].set_title('Phase Margin Sensitivity')
684
axes[1, 1].legend(loc='upper right', fontsize=8)
685
axes[1, 1].grid(True, alpha=0.3)
686
687
plt.tight_layout()
688
plt.savefig('control_systems_plot6.pdf', bbox_inches='tight', dpi=150)
689
plt.close()
690
\end{pycode}
691
692
\begin{figure}[H]
693
\centering
694
\includegraphics[width=0.95\textwidth]{control_systems_plot6.pdf}
695
\caption{Stability margins for the PID-compensated system and sensitivity to gain variations.}
696
\end{figure}
697
698
\noindent\textbf{Stability Margins (Ziegler-Nichols Tuning):}
699
\begin{itemize}
700
\item Gain Margin: \py{f"{gm_db:.1f}"} dB at $\omega = \py{f"{pc_freq_comp:.2f}"}$ rad/s
701
\item Phase Margin: \py{f"{pm_deg:.1f}"}$^\circ$ at $\omega = \py{f"{gc_freq_comp:.2f}"}$ rad/s
702
\end{itemize}
703
704
\section{Disturbance Rejection Analysis}
705
706
\begin{pycode}
707
# Disturbance rejection: apply step disturbance at plant input
708
# Output due to disturbance: Y_d = G/(1+CG) * D
709
710
# Disturbance transfer function
711
dist_num = num_plant
712
dist_den = np.polyadd(den_ol_comp, num_ol_comp)
713
dist_tf = signal.TransferFunction(dist_num, dist_den)
714
715
t_dist = np.linspace(0, 5, 1000)
716
t_d, y_d = signal.step(dist_tf, T=t_dist)
717
718
# Apply disturbance at t=2s during step response
719
t_full = np.linspace(0, 5, 1000)
720
y_full = np.zeros_like(t_full)
721
722
# Get step response
723
_, y_step_full = signal.step(cl_zn, T=t_full)
724
725
# Add disturbance effect starting at t=2
726
dist_start_idx = np.where(t_full >= 2)[0][0]
727
dist_response = np.zeros_like(t_full)
728
t_shifted = t_full[dist_start_idx:] - 2
729
_, y_dist_shifted = signal.step(dist_tf, T=t_shifted)
730
dist_response[dist_start_idx:] = 0.5 * y_dist_shifted # 50% disturbance magnitude
731
732
y_with_dist = y_step_full + dist_response
733
734
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
735
736
# Disturbance response
737
axes[0].plot(t_d, y_d, 'b-', linewidth=1.5)
738
axes[0].axhline(y=0, color='gray', linestyle='--', alpha=0.5)
739
axes[0].set_xlabel('Time (s)')
740
axes[0].set_ylabel('Output')
741
axes[0].set_title('Unit Step Disturbance Response')
742
axes[0].grid(True, alpha=0.3)
743
744
# Combined response with disturbance
745
axes[1].plot(t_full, y_step_full, 'b--', linewidth=1, alpha=0.7, label='Without Disturbance')
746
axes[1].plot(t_full, y_with_dist, 'r-', linewidth=1.5, label='With Disturbance at $t=2$s')
747
axes[1].axhline(y=1, color='gray', linestyle='--', alpha=0.5)
748
axes[1].axvline(x=2, color='g', linestyle=':', alpha=0.7)
749
axes[1].set_xlabel('Time (s)')
750
axes[1].set_ylabel('Output')
751
axes[1].set_title('Step Response with Input Disturbance')
752
axes[1].legend(loc='lower right', fontsize=8)
753
axes[1].grid(True, alpha=0.3)
754
755
plt.tight_layout()
756
plt.savefig('control_systems_plot7.pdf', bbox_inches='tight', dpi=150)
757
plt.close()
758
759
# Calculate disturbance rejection metrics
760
max_deviation = np.max(np.abs(y_with_dist[dist_start_idx:] - 1))
761
recovery_idx = np.where(np.abs(y_with_dist[dist_start_idx:] - 1) < 0.02)[0]
762
if len(recovery_idx) > 0:
763
recovery_time = t_full[dist_start_idx + recovery_idx[0]] - 2
764
else:
765
recovery_time = t_full[-1] - 2
766
\end{pycode}
767
768
\begin{figure}[H]
769
\centering
770
\includegraphics[width=0.95\textwidth]{control_systems_plot7.pdf}
771
\caption{Disturbance rejection capability of the PID controller.}
772
\end{figure}
773
774
\noindent\textbf{Disturbance Rejection Metrics:}
775
\begin{itemize}
776
\item Maximum Deviation: \py{f"{max_deviation:.3f}"}
777
\item Recovery Time (2\% band): \py{f"{recovery_time:.2f}"} s
778
\end{itemize}
779
780
\section{Conclusions}
781
782
This tutorial demonstrated comprehensive control system analysis techniques:
783
784
\begin{enumerate}
785
\item \textbf{Transfer Function Modeling}: The second-order plant exhibits underdamped behavior with $\zeta = \py{zeta}$ and natural frequency $\omega_n = \py{wn}$ rad/s.
786
787
\item \textbf{Frequency Response}: Bode and Nyquist analysis revealed a bandwidth of \py{f"{bandwidth:.2f}"} rad/s and resonant peak at \py{f"{resonant_freq:.2f}"} rad/s.
788
789
\item \textbf{Root Locus}: Pole migration with gain showed stability boundaries and damping ratio constraints.
790
791
\item \textbf{PID Tuning}: Ziegler-Nichols tuning ($K_p = \py{f"{Kp_zn:.3f}"}$, $K_i = \py{f"{Ki_zn:.3f}"}$, $K_d = \py{f"{Kd_zn:.4f}"}$) provided acceptable performance with \py{f"{os_zn:.1f}"}\% overshoot.
792
793
\item \textbf{Robustness}: The compensated system achieves \py{f"{gm_db:.1f}"} dB gain margin and \py{f"{pm_deg:.1f}"}$^\circ$ phase margin, meeting typical design specifications.
794
\end{enumerate}
795
796
The computational approach ensures all results are reproducible and automatically update when system parameters change.
797
798
\end{document}
799
800