Path: blob/main/latex-templates/templates/electrical-engineering/control_systems.tex
51 views
unlisted
\documentclass[a4paper, 11pt]{article}1\usepackage[utf8]{inputenc}2\usepackage[T1]{fontenc}3\usepackage{amsmath, amssymb, amsthm}4\usepackage{graphicx}5\usepackage{siunitx}6\usepackage{booktabs}7\usepackage{float}8\usepackage{geometry}9\geometry{margin=1in}10\usepackage[makestderr]{pythontex}1112% Theorem environments13\newtheorem{theorem}{Theorem}[section]14\newtheorem{definition}[theorem]{Definition}15\newtheorem{property}[theorem]{Property}1617\title{Control Systems Analysis: A Comprehensive Tutorial on\\Classical Feedback Control Design}18\author{Control Systems Engineering Laboratory}19\date{\today}2021\begin{document}22\maketitle2324\begin{abstract}25This 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.26\end{abstract}2728\section{Introduction to Feedback Control}2930Feedback 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.3132\begin{definition}[Closed-Loop Transfer Function]33For a unity feedback system with plant $G(s)$ and controller $C(s)$, the closed-loop transfer function is:34\begin{equation}35T(s) = \frac{C(s)G(s)}{1 + C(s)G(s)} = \frac{L(s)}{1 + L(s)}36\end{equation}37where $L(s) = C(s)G(s)$ is the loop transfer function.38\end{definition}3940\section{System Modeling and Transfer Functions}4142\begin{pycode}43import numpy as np44import matplotlib.pyplot as plt45from scipy import signal46from scipy.optimize import brentq4748plt.rc('text', usetex=True)49plt.rc('font', family='serif', size=9)50np.random.seed(42)5152# Define the plant: Second-order system with delay approximation53# G(s) = K / (s^2 + 2*zeta*wn*s + wn^2)54K_plant = 10.055wn = 5.0 # Natural frequency (rad/s)56zeta = 0.3 # Damping ratio5758num_plant = [K_plant * wn**2]59den_plant = [1, 2*zeta*wn, wn**2]60plant = signal.TransferFunction(num_plant, den_plant)6162# Plant characteristics63poles_plant = np.roots(den_plant)64dc_gain_plant = K_plant6566# Time vector for simulations67t = np.linspace(0, 4, 1000)68\end{pycode}6970We consider a second-order plant with transfer function:71\begin{equation}72G(s) = \frac{K\omega_n^2}{s^2 + 2\zeta\omega_n s + \omega_n^2}73\end{equation}7475\noindent\textbf{Plant Parameters:}76\begin{itemize}77\item DC Gain: $K = \py{K_plant}$78\item Natural Frequency: $\omega_n = \py{wn}$ rad/s79\item Damping Ratio: $\zeta = \py{zeta}$80\item Plant Poles: $s = \py{f"{poles_plant[0]:.2f}"}$, $\py{f"{poles_plant[1]:.2f}"}$81\end{itemize}8283\section{Open-Loop Step Response Analysis}8485\begin{pycode}86# Open-loop step response87t_ol, y_ol = signal.step(plant, T=t)8889# Calculate open-loop characteristics90peak_idx = np.argmax(y_ol)91peak_time = t_ol[peak_idx]92peak_val = y_ol[peak_idx]93overshoot_ol = (peak_val / K_plant - 1) * 1009495# Settling time (2% criterion)96steady_state = K_plant97tolerance = 0.02 * steady_state98settled_indices = np.where(np.abs(y_ol - steady_state) <= tolerance)[0]99if len(settled_indices) > 0:100# Find first time it stays within tolerance101for i in range(len(settled_indices)):102if np.all(np.abs(y_ol[settled_indices[i]:] - steady_state) <= tolerance):103settling_time_ol = t_ol[settled_indices[i]]104break105else:106settling_time_ol = t_ol[-1]107else:108settling_time_ol = t_ol[-1]109110# Rise time (10% to 90%)111y_10 = 0.1 * steady_state112y_90 = 0.9 * steady_state113t_10 = t_ol[np.where(y_ol >= y_10)[0][0]]114t_90 = t_ol[np.where(y_ol >= y_90)[0][0]]115rise_time_ol = t_90 - t_10116117fig, axes = plt.subplots(1, 2, figsize=(10, 4))118119# Step response120axes[0].plot(t_ol, y_ol, 'b-', linewidth=1.5, label='Response')121axes[0].axhline(y=K_plant, color='r', linestyle='--', alpha=0.7, label='Steady State')122axes[0].axhline(y=K_plant*1.02, color='gray', linestyle=':', alpha=0.5)123axes[0].axhline(y=K_plant*0.98, color='gray', linestyle=':', alpha=0.5)124axes[0].plot(peak_time, peak_val, 'ro', markersize=6)125axes[0].annotate(f'Peak: {peak_val:.2f}', xy=(peak_time, peak_val),126xytext=(peak_time+0.3, peak_val+0.5), fontsize=8,127arrowprops=dict(arrowstyle='->', color='black', lw=0.5))128axes[0].set_xlabel('Time (s)')129axes[0].set_ylabel('Output')130axes[0].set_title('Open-Loop Step Response')131axes[0].legend(loc='right')132axes[0].grid(True, alpha=0.3)133134# Impulse response135t_imp, y_imp = signal.impulse(plant, T=t)136axes[1].plot(t_imp, y_imp, 'g-', linewidth=1.5)137axes[1].set_xlabel('Time (s)')138axes[1].set_ylabel('Output')139axes[1].set_title('Open-Loop Impulse Response')140axes[1].grid(True, alpha=0.3)141142plt.tight_layout()143plt.savefig('control_systems_plot1.pdf', bbox_inches='tight', dpi=150)144plt.close()145\end{pycode}146147\begin{figure}[H]148\centering149\includegraphics[width=0.95\textwidth]{control_systems_plot1.pdf}150\caption{Open-loop time domain responses of the plant.}151\end{figure}152153\begin{table}[H]154\centering155\caption{Open-Loop Performance Metrics}156\begin{tabular}{lcc}157\toprule158\textbf{Metric} & \textbf{Value} & \textbf{Unit} \\159\midrule160Peak Time & \py{f"{peak_time:.3f}"} & s \\161Peak Overshoot & \py{f"{overshoot_ol:.1f}"} & \% \\162Rise Time (10-90\%) & \py{f"{rise_time_ol:.3f}"} & s \\163Settling Time (2\%) & \py{f"{settling_time_ol:.3f}"} & s \\164DC Gain & \py{f"{dc_gain_plant:.1f}"} & -- \\165\bottomrule166\end{tabular}167\end{table}168169\section{Frequency Response Analysis}170171\subsection{Bode Plot Analysis}172173\begin{theorem}[Bode Gain-Phase Relationship]174For minimum-phase systems, the phase is uniquely determined by the magnitude characteristic through the Hilbert transform relationship.175\end{theorem}176177\begin{pycode}178# Frequency response analysis179w = np.logspace(-1, 2, 1000)180w_bode, mag_plant, phase_plant = signal.bode(plant, w)181182# Find key frequencies183idx_wn = np.argmin(np.abs(w_bode - wn))184mag_at_wn = mag_plant[idx_wn]185phase_at_wn = phase_plant[idx_wn]186187# Bandwidth (-3dB point)188mag_3db = 20*np.log10(K_plant) - 3189bandwidth_idx = np.where(mag_plant < mag_3db)[0]190if len(bandwidth_idx) > 0:191bandwidth = w_bode[bandwidth_idx[0]]192else:193bandwidth = w_bode[-1]194195# Resonant peak196resonant_idx = np.argmax(mag_plant)197resonant_freq = w_bode[resonant_idx]198resonant_peak = mag_plant[resonant_idx]199200fig, axes = plt.subplots(2, 1, figsize=(10, 6))201202# Magnitude plot203axes[0].semilogx(w_bode, mag_plant, 'b-', linewidth=1.5)204axes[0].axhline(y=0, color='gray', linestyle='--', alpha=0.5)205axes[0].axhline(y=mag_3db, color='r', linestyle=':', alpha=0.7, label='-3dB')206axes[0].axvline(x=wn, color='g', linestyle=':', alpha=0.7, label=r'$\omega_n$')207axes[0].axvline(x=bandwidth, color='orange', linestyle='--', alpha=0.7, label='Bandwidth')208axes[0].plot(resonant_freq, resonant_peak, 'ro', markersize=5)209axes[0].set_ylabel('Magnitude (dB)')210axes[0].set_title('Bode Plot - Plant $G(s)$')211axes[0].legend(loc='lower left', fontsize=8)212axes[0].grid(True, which='both', alpha=0.3)213214# Phase plot215axes[1].semilogx(w_bode, phase_plant, 'b-', linewidth=1.5)216axes[1].axhline(y=-90, color='gray', linestyle='--', alpha=0.5)217axes[1].axhline(y=-180, color='r', linestyle='--', alpha=0.5)218axes[1].axvline(x=wn, color='g', linestyle=':', alpha=0.7)219axes[1].set_xlabel('Frequency (rad/s)')220axes[1].set_ylabel('Phase (degrees)')221axes[1].grid(True, which='both', alpha=0.3)222223plt.tight_layout()224plt.savefig('control_systems_plot2.pdf', bbox_inches='tight', dpi=150)225plt.close()226\end{pycode}227228\begin{figure}[H]229\centering230\includegraphics[width=0.95\textwidth]{control_systems_plot2.pdf}231\caption{Bode plot of the plant transfer function showing magnitude and phase response.}232\end{figure}233234\noindent\textbf{Frequency Domain Characteristics:}235\begin{itemize}236\item Bandwidth: $\omega_{BW} = \py{f"{bandwidth:.2f}"}$ rad/s237\item Resonant Frequency: $\omega_r = \py{f"{resonant_freq:.2f}"}$ rad/s238\item Resonant Peak: $M_r = \py{f"{resonant_peak:.1f}"}$ dB239\end{itemize}240241\subsection{Nyquist Plot and Stability Analysis}242243\begin{definition}[Nyquist Stability Criterion]244A 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.245\end{definition}246247\begin{pycode}248# Nyquist plot249w_nyq = np.concatenate([np.linspace(0.01, 100, 5000)])250_, H = signal.freqs(num_plant, den_plant, w_nyq)251252# Calculate gain and phase margins for the open-loop plant253# For phase margin: find frequency where |G| = 1 (0 dB)254mag_linear = 10**(mag_plant/20)255crossover_indices = np.where(np.diff(np.sign(mag_linear - 1)))[0]256if len(crossover_indices) > 0:257gc_freq = w_bode[crossover_indices[0]]258gc_phase = phase_plant[crossover_indices[0]]259phase_margin = 180 + gc_phase260else:261gc_freq = 0262phase_margin = np.inf263264# For gain margin: find frequency where phase = -180265phase_crossover_indices = np.where(np.diff(np.sign(phase_plant + 180)))[0]266if len(phase_crossover_indices) > 0:267pc_freq = w_bode[phase_crossover_indices[0]]268pc_mag = mag_plant[phase_crossover_indices[0]]269gain_margin = -pc_mag270else:271pc_freq = np.inf272gain_margin = np.inf273274fig, axes = plt.subplots(1, 2, figsize=(10, 4))275276# Nyquist plot277axes[0].plot(np.real(H), np.imag(H), 'b-', linewidth=1.5, label=r'$\omega > 0$')278axes[0].plot(np.real(H), -np.imag(H), 'b--', linewidth=1, alpha=0.7, label=r'$\omega < 0$')279axes[0].plot(-1, 0, 'rx', markersize=10, markeredgewidth=2, label='Critical Point')280axes[0].set_xlabel('Real')281axes[0].set_ylabel('Imaginary')282axes[0].set_title('Nyquist Plot')283axes[0].legend(loc='lower left', fontsize=8)284axes[0].grid(True, alpha=0.3)285axes[0].axis('equal')286axes[0].set_xlim([-15, 15])287axes[0].set_ylim([-15, 15])288289# Nichols chart (Magnitude vs Phase)290axes[1].plot(phase_plant, mag_plant, 'b-', linewidth=1.5)291axes[1].axvline(x=-180, color='r', linestyle='--', alpha=0.7)292axes[1].axhline(y=0, color='r', linestyle='--', alpha=0.7)293axes[1].plot(-180, 0, 'rx', markersize=10, markeredgewidth=2)294axes[1].set_xlabel('Phase (degrees)')295axes[1].set_ylabel('Magnitude (dB)')296axes[1].set_title('Nichols Chart')297axes[1].grid(True, alpha=0.3)298299plt.tight_layout()300plt.savefig('control_systems_plot3.pdf', bbox_inches='tight', dpi=150)301plt.close()302\end{pycode}303304\begin{figure}[H]305\centering306\includegraphics[width=0.95\textwidth]{control_systems_plot3.pdf}307\caption{Nyquist plot and Nichols chart for stability analysis.}308\end{figure}309310\section{Root Locus Analysis}311312The root locus shows how closed-loop poles migrate as a gain parameter varies.313314\begin{property}[Root Locus Rules]315\begin{enumerate}316\item The root locus has $n$ branches, where $n$ is the number of open-loop poles317\item Branches start at open-loop poles ($K=0$) and end at zeros ($K=\infty$)318\item The locus is symmetric about the real axis319\end{enumerate}320\end{property}321322\begin{pycode}323# Root locus computation324K_range = np.linspace(0.01, 100, 500)325poles_rl = []326327for K in K_range:328# Closed-loop characteristic equation: 1 + K*G(s) = 0329# den_plant + K*num_plant = 0330char_poly = np.polyadd(den_plant, K * np.array(num_plant))331roots = np.roots(char_poly)332poles_rl.append(roots)333334poles_rl = np.array(poles_rl)335336# Find critical gain for marginal stability337critical_K = None338for i, K in enumerate(K_range):339if np.any(np.real(poles_rl[i]) > 0):340critical_K = K_range[i-1] if i > 0 else K341break342343# Find gain for specific damping ratio (zeta = 0.5)344target_zeta = 0.5345K_for_zeta = None346for i, K in enumerate(K_range):347poles = poles_rl[i]348complex_poles = poles[np.iscomplex(poles)]349if len(complex_poles) > 0:350actual_zeta = -np.real(complex_poles[0]) / np.abs(complex_poles[0])351if actual_zeta <= target_zeta:352K_for_zeta = K353break354355fig, axes = plt.subplots(1, 2, figsize=(10, 4))356357# Root locus plot358for i in range(poles_rl.shape[1]):359axes[0].plot(np.real(poles_rl[:, i]), np.imag(poles_rl[:, i]),360'b-', linewidth=1)361axes[0].plot(np.real(poles_plant), np.imag(poles_plant), 'rx',362markersize=10, markeredgewidth=2, label='Open-loop Poles')363axes[0].axvline(x=0, color='gray', linestyle='-', alpha=0.3)364axes[0].axhline(y=0, color='gray', linestyle='-', alpha=0.3)365366# Draw damping ratio lines367for z in [0.3, 0.5, 0.7]:368theta = np.arccos(z)369r = np.linspace(0, 20, 100)370axes[0].plot(-r*np.cos(theta), r*np.sin(theta), 'g:', alpha=0.5)371axes[0].plot(-r*np.cos(theta), -r*np.sin(theta), 'g:', alpha=0.5)372373axes[0].set_xlabel('Real')374axes[0].set_ylabel('Imaginary')375axes[0].set_title('Root Locus')376axes[0].legend(loc='lower left', fontsize=8)377axes[0].grid(True, alpha=0.3)378axes[0].set_xlim([-15, 5])379axes[0].set_ylim([-10, 10])380381# Damping ratio vs Gain382damping_ratios = []383for i, K in enumerate(K_range):384poles = poles_rl[i]385complex_poles = poles[np.abs(np.imag(poles)) > 0.01]386if len(complex_poles) > 0:387zeta = -np.real(complex_poles[0]) / np.abs(complex_poles[0])388damping_ratios.append(zeta)389else:390damping_ratios.append(1.0)391392axes[1].plot(K_range, damping_ratios, 'b-', linewidth=1.5)393axes[1].axhline(y=0.5, color='r', linestyle='--', alpha=0.7, label=r'$\zeta = 0.5$')394axes[1].axhline(y=0.707, color='g', linestyle='--', alpha=0.7, label=r'$\zeta = 0.707$')395axes[1].set_xlabel('Gain $K$')396axes[1].set_ylabel('Damping Ratio $\\zeta$')397axes[1].set_title('Damping Ratio vs Gain')398axes[1].legend(loc='upper right', fontsize=8)399axes[1].grid(True, alpha=0.3)400401plt.tight_layout()402plt.savefig('control_systems_plot4.pdf', bbox_inches='tight', dpi=150)403plt.close()404\end{pycode}405406\begin{figure}[H]407\centering408\includegraphics[width=0.95\textwidth]{control_systems_plot4.pdf}409\caption{Root locus plot showing pole migration with gain and corresponding damping ratio variation.}410\end{figure}411412\section{PID Controller Design}413414\subsection{PID Transfer Function}415416The PID controller transfer function is:417\begin{equation}418C(s) = K_p + \frac{K_i}{s} + K_d s = \frac{K_d s^2 + K_p s + K_i}{s}419\end{equation}420421\begin{pycode}422# PID controller design using Ziegler-Nichols tuning423# First, find ultimate gain and period424425# Ultimate gain: gain at which system becomes marginally stable426# For our system, we'll use the ultimate gain method427Ku = 2 * zeta * wn**2 / K_plant # Approximate ultimate gain428Tu = 2 * np.pi / wn # Ultimate period429430# Ziegler-Nichols PID tuning rules431Kp_zn = 0.6 * Ku432Ki_zn = 2 * Kp_zn / Tu433Kd_zn = Kp_zn * Tu / 8434435# Alternative tuning: Cohen-Coon436Kp_cc = 0.5 * Ku437Ki_cc = 1.5 * Kp_cc / Tu438Kd_cc = Kp_cc * Tu / 6439440# Construct PID transfer functions441def create_closed_loop(Kp, Ki, Kd, num_plant, den_plant):442# PID: (Kd*s^2 + Kp*s + Ki) / s443num_pid = [Kd, Kp, Ki]444den_pid = [1, 0]445446# Open loop: PID * Plant447num_ol = np.convolve(num_pid, num_plant)448den_ol = np.convolve(den_pid, den_plant)449450# Closed loop: num_ol / (den_ol + num_ol)451num_cl = num_ol452den_cl = np.polyadd(den_ol, num_ol)453454return signal.TransferFunction(num_cl, den_cl)455456# Create closed-loop systems457cl_zn = create_closed_loop(Kp_zn, Ki_zn, Kd_zn, num_plant, den_plant)458cl_cc = create_closed_loop(Kp_cc, Ki_cc, Kd_cc, num_plant, den_plant)459460# Step responses461t_cl = np.linspace(0, 3, 1000)462t_zn, y_zn = signal.step(cl_zn, T=t_cl)463t_cc, y_cc = signal.step(cl_cc, T=t_cl)464\end{pycode}465466\begin{table}[H]467\centering468\caption{PID Tuning Parameters}469\begin{tabular}{lccc}470\toprule471\textbf{Method} & $K_p$ & $K_i$ & $K_d$ \\472\midrule473Ziegler-Nichols & \py{f"{Kp_zn:.3f}"} & \py{f"{Ki_zn:.3f}"} & \py{f"{Kd_zn:.4f}"} \\474Cohen-Coon & \py{f"{Kp_cc:.3f}"} & \py{f"{Ki_cc:.3f}"} & \py{f"{Kd_cc:.4f}"} \\475\bottomrule476\end{tabular}477\end{table}478479\subsection{Closed-Loop Performance Comparison}480481\begin{pycode}482# Performance metrics calculation483def calc_metrics(t, y):484steady_state = 1.0485# Overshoot486overshoot = (np.max(y) - steady_state) / steady_state * 100487# Settling time488tolerance = 0.02489settled = np.where(np.abs(y - steady_state) <= tolerance)[0]490if len(settled) > 0:491for i in range(len(settled)):492if np.all(np.abs(y[settled[i]:] - steady_state) <= tolerance):493settling = t[settled[i]]494break495else:496settling = t[-1]497else:498settling = t[-1]499# Rise time500idx_10 = np.where(y >= 0.1)[0]501idx_90 = np.where(y >= 0.9)[0]502if len(idx_10) > 0 and len(idx_90) > 0:503rise = t[idx_90[0]] - t[idx_10[0]]504else:505rise = 0506return overshoot, settling, rise507508os_zn, st_zn, rt_zn = calc_metrics(t_zn, y_zn)509os_cc, st_cc, rt_cc = calc_metrics(t_cc, y_cc)510511fig, axes = plt.subplots(2, 2, figsize=(10, 8))512513# Step response comparison514axes[0, 0].plot(t_zn, y_zn, 'b-', linewidth=1.5, label='Ziegler-Nichols')515axes[0, 0].plot(t_cc, y_cc, 'r--', linewidth=1.5, label='Cohen-Coon')516axes[0, 0].axhline(y=1.0, color='gray', linestyle='--', alpha=0.5)517axes[0, 0].axhline(y=1.02, color='gray', linestyle=':', alpha=0.3)518axes[0, 0].axhline(y=0.98, color='gray', linestyle=':', alpha=0.3)519axes[0, 0].set_xlabel('Time (s)')520axes[0, 0].set_ylabel('Output')521axes[0, 0].set_title('Closed-Loop Step Response')522axes[0, 0].legend(loc='lower right', fontsize=8)523axes[0, 0].grid(True, alpha=0.3)524525# Control effort526# Approximate control signal: u = Kp*e + Ki*int(e) + Kd*de/dt527e_zn = 1 - y_zn528u_zn = Kp_zn * e_zn + Kd_zn * np.gradient(e_zn, t_zn)529530e_cc = 1 - y_cc531u_cc = Kp_cc * e_cc + Kd_cc * np.gradient(e_cc, t_cc)532533axes[0, 1].plot(t_zn, u_zn, 'b-', linewidth=1.5, label='Ziegler-Nichols')534axes[0, 1].plot(t_cc, u_cc, 'r--', linewidth=1.5, label='Cohen-Coon')535axes[0, 1].set_xlabel('Time (s)')536axes[0, 1].set_ylabel('Control Signal')537axes[0, 1].set_title('Control Effort')538axes[0, 1].legend(loc='upper right', fontsize=8)539axes[0, 1].grid(True, alpha=0.3)540541# Bode plot of closed-loop542w_cl = np.logspace(-1, 2, 500)543w_cl_zn, mag_cl_zn, phase_cl_zn = signal.bode(cl_zn, w_cl)544w_cl_cc, mag_cl_cc, phase_cl_cc = signal.bode(cl_cc, w_cl)545546axes[1, 0].semilogx(w_cl_zn, mag_cl_zn, 'b-', linewidth=1.5, label='Ziegler-Nichols')547axes[1, 0].semilogx(w_cl_cc, mag_cl_cc, 'r--', linewidth=1.5, label='Cohen-Coon')548axes[1, 0].axhline(y=-3, color='gray', linestyle=':', alpha=0.5)549axes[1, 0].set_xlabel('Frequency (rad/s)')550axes[1, 0].set_ylabel('Magnitude (dB)')551axes[1, 0].set_title('Closed-Loop Frequency Response')552axes[1, 0].legend(loc='lower left', fontsize=8)553axes[1, 0].grid(True, which='both', alpha=0.3)554555# Sensitivity function S = 1/(1+L)556num_ol_zn = np.convolve([Kd_zn, Kp_zn, Ki_zn], num_plant)557den_ol_zn = np.convolve([1, 0], den_plant)558sensitivity_den = np.polyadd(den_ol_zn, num_ol_zn)559sens_tf = signal.TransferFunction(den_ol_zn, sensitivity_den)560w_s, mag_s, _ = signal.bode(sens_tf, w_cl)561562axes[1, 1].semilogx(w_s, mag_s, 'b-', linewidth=1.5)563axes[1, 1].axhline(y=0, color='gray', linestyle='--', alpha=0.5)564axes[1, 1].set_xlabel('Frequency (rad/s)')565axes[1, 1].set_ylabel('Magnitude (dB)')566axes[1, 1].set_title('Sensitivity Function $S(j\\omega)$')567axes[1, 1].grid(True, which='both', alpha=0.3)568569plt.tight_layout()570plt.savefig('control_systems_plot5.pdf', bbox_inches='tight', dpi=150)571plt.close()572\end{pycode}573574\begin{figure}[H]575\centering576\includegraphics[width=0.95\textwidth]{control_systems_plot5.pdf}577\caption{Closed-loop performance comparison between Ziegler-Nichols and Cohen-Coon tuning methods.}578\end{figure}579580\begin{table}[H]581\centering582\caption{Closed-Loop Performance Metrics}583\begin{tabular}{lccc}584\toprule585\textbf{Method} & \textbf{Overshoot (\%)} & \textbf{Settling Time (s)} & \textbf{Rise Time (s)} \\586\midrule587Ziegler-Nichols & \py{f"{os_zn:.1f}"} & \py{f"{st_zn:.3f}"} & \py{f"{rt_zn:.3f}"} \\588Cohen-Coon & \py{f"{os_cc:.1f}"} & \py{f"{st_cc:.3f}"} & \py{f"{rt_cc:.3f}"} \\589\bottomrule590\end{tabular}591\end{table}592593\section{Stability Margins and Robustness}594595\begin{pycode}596# Calculate stability margins for the compensated system597# Open-loop with ZN controller598num_ol_comp = np.convolve([Kd_zn, Kp_zn, Ki_zn], num_plant)599den_ol_comp = np.convolve([1, 0], den_plant)600601w_margin = np.logspace(-2, 3, 2000)602w_m, mag_m, phase_m = signal.bode((num_ol_comp, den_ol_comp), w_margin)603604# Gain margin605mag_lin = 10**(mag_m/20)606phase_cross_idx = np.where(np.diff(np.sign(phase_m + 180)))[0]607if len(phase_cross_idx) > 0:608pc_freq_comp = w_m[phase_cross_idx[0]]609gm_db = -mag_m[phase_cross_idx[0]]610else:611pc_freq_comp = np.inf612gm_db = np.inf613614# Phase margin615gain_cross_idx = np.where(np.diff(np.sign(mag_lin - 1)))[0]616if len(gain_cross_idx) > 0:617gc_freq_comp = w_m[gain_cross_idx[0]]618pm_deg = 180 + phase_m[gain_cross_idx[0]]619else:620gc_freq_comp = 0621pm_deg = np.inf622623fig, axes = plt.subplots(2, 2, figsize=(10, 8))624625# Open-loop Bode with margins marked626axes[0, 0].semilogx(w_m, mag_m, 'b-', linewidth=1.5)627axes[0, 0].axhline(y=0, color='r', linestyle='--', alpha=0.7)628if pc_freq_comp < np.inf:629axes[0, 0].axvline(x=pc_freq_comp, color='g', linestyle=':', alpha=0.7)630axes[0, 0].annotate(f'GM = {gm_db:.1f} dB',631xy=(pc_freq_comp, -gm_db), xytext=(pc_freq_comp*2, -gm_db+10),632fontsize=8, arrowprops=dict(arrowstyle='->', color='green', lw=0.5))633axes[0, 0].set_ylabel('Magnitude (dB)')634axes[0, 0].set_title('Open-Loop Bode (Compensated)')635axes[0, 0].grid(True, which='both', alpha=0.3)636637axes[0, 1].semilogx(w_m, phase_m, 'b-', linewidth=1.5)638axes[0, 1].axhline(y=-180, color='r', linestyle='--', alpha=0.7)639if gc_freq_comp > 0:640axes[0, 1].axvline(x=gc_freq_comp, color='orange', linestyle=':', alpha=0.7)641axes[0, 1].annotate(f'PM = {pm_deg:.1f}°',642xy=(gc_freq_comp, -180+pm_deg), xytext=(gc_freq_comp*2, -180+pm_deg+20),643fontsize=8, arrowprops=dict(arrowstyle='->', color='orange', lw=0.5))644axes[0, 1].set_xlabel('Frequency (rad/s)')645axes[0, 1].set_ylabel('Phase (degrees)')646axes[0, 1].grid(True, which='both', alpha=0.3)647648# Gain and phase margin vs Kp variation649Kp_var = np.linspace(0.1, 2.0, 50) * Kp_zn650gm_var = []651pm_var = []652653for kp in Kp_var:654num_ol_var = np.convolve([Kd_zn, kp, Ki_zn], num_plant)655w_v, mag_v, phase_v = signal.bode((num_ol_var, den_ol_comp), w_margin)656657mag_lin_v = 10**(mag_v/20)658gc_idx = np.where(np.diff(np.sign(mag_lin_v - 1)))[0]659if len(gc_idx) > 0:660pm_var.append(180 + phase_v[gc_idx[0]])661else:662pm_var.append(np.nan)663664pc_idx = np.where(np.diff(np.sign(phase_v + 180)))[0]665if len(pc_idx) > 0:666gm_var.append(-mag_v[pc_idx[0]])667else:668gm_var.append(np.nan)669670axes[1, 0].plot(Kp_var/Kp_zn, gm_var, 'b-', linewidth=1.5)671axes[1, 0].axhline(y=6, color='r', linestyle='--', alpha=0.7, label='Min GM = 6 dB')672axes[1, 0].set_xlabel('$K_p / K_{p,ZN}$')673axes[1, 0].set_ylabel('Gain Margin (dB)')674axes[1, 0].set_title('Gain Margin Sensitivity')675axes[1, 0].legend(loc='upper right', fontsize=8)676axes[1, 0].grid(True, alpha=0.3)677678axes[1, 1].plot(Kp_var/Kp_zn, pm_var, 'r-', linewidth=1.5)679axes[1, 1].axhline(y=45, color='b', linestyle='--', alpha=0.7, label='Min PM = 45°')680axes[1, 1].set_xlabel('$K_p / K_{p,ZN}$')681axes[1, 1].set_ylabel('Phase Margin (degrees)')682axes[1, 1].set_title('Phase Margin Sensitivity')683axes[1, 1].legend(loc='upper right', fontsize=8)684axes[1, 1].grid(True, alpha=0.3)685686plt.tight_layout()687plt.savefig('control_systems_plot6.pdf', bbox_inches='tight', dpi=150)688plt.close()689\end{pycode}690691\begin{figure}[H]692\centering693\includegraphics[width=0.95\textwidth]{control_systems_plot6.pdf}694\caption{Stability margins for the PID-compensated system and sensitivity to gain variations.}695\end{figure}696697\noindent\textbf{Stability Margins (Ziegler-Nichols Tuning):}698\begin{itemize}699\item Gain Margin: \py{f"{gm_db:.1f}"} dB at $\omega = \py{f"{pc_freq_comp:.2f}"}$ rad/s700\item Phase Margin: \py{f"{pm_deg:.1f}"}$^\circ$ at $\omega = \py{f"{gc_freq_comp:.2f}"}$ rad/s701\end{itemize}702703\section{Disturbance Rejection Analysis}704705\begin{pycode}706# Disturbance rejection: apply step disturbance at plant input707# Output due to disturbance: Y_d = G/(1+CG) * D708709# Disturbance transfer function710dist_num = num_plant711dist_den = np.polyadd(den_ol_comp, num_ol_comp)712dist_tf = signal.TransferFunction(dist_num, dist_den)713714t_dist = np.linspace(0, 5, 1000)715t_d, y_d = signal.step(dist_tf, T=t_dist)716717# Apply disturbance at t=2s during step response718t_full = np.linspace(0, 5, 1000)719y_full = np.zeros_like(t_full)720721# Get step response722_, y_step_full = signal.step(cl_zn, T=t_full)723724# Add disturbance effect starting at t=2725dist_start_idx = np.where(t_full >= 2)[0][0]726dist_response = np.zeros_like(t_full)727t_shifted = t_full[dist_start_idx:] - 2728_, y_dist_shifted = signal.step(dist_tf, T=t_shifted)729dist_response[dist_start_idx:] = 0.5 * y_dist_shifted # 50% disturbance magnitude730731y_with_dist = y_step_full + dist_response732733fig, axes = plt.subplots(1, 2, figsize=(10, 4))734735# Disturbance response736axes[0].plot(t_d, y_d, 'b-', linewidth=1.5)737axes[0].axhline(y=0, color='gray', linestyle='--', alpha=0.5)738axes[0].set_xlabel('Time (s)')739axes[0].set_ylabel('Output')740axes[0].set_title('Unit Step Disturbance Response')741axes[0].grid(True, alpha=0.3)742743# Combined response with disturbance744axes[1].plot(t_full, y_step_full, 'b--', linewidth=1, alpha=0.7, label='Without Disturbance')745axes[1].plot(t_full, y_with_dist, 'r-', linewidth=1.5, label='With Disturbance at $t=2$s')746axes[1].axhline(y=1, color='gray', linestyle='--', alpha=0.5)747axes[1].axvline(x=2, color='g', linestyle=':', alpha=0.7)748axes[1].set_xlabel('Time (s)')749axes[1].set_ylabel('Output')750axes[1].set_title('Step Response with Input Disturbance')751axes[1].legend(loc='lower right', fontsize=8)752axes[1].grid(True, alpha=0.3)753754plt.tight_layout()755plt.savefig('control_systems_plot7.pdf', bbox_inches='tight', dpi=150)756plt.close()757758# Calculate disturbance rejection metrics759max_deviation = np.max(np.abs(y_with_dist[dist_start_idx:] - 1))760recovery_idx = np.where(np.abs(y_with_dist[dist_start_idx:] - 1) < 0.02)[0]761if len(recovery_idx) > 0:762recovery_time = t_full[dist_start_idx + recovery_idx[0]] - 2763else:764recovery_time = t_full[-1] - 2765\end{pycode}766767\begin{figure}[H]768\centering769\includegraphics[width=0.95\textwidth]{control_systems_plot7.pdf}770\caption{Disturbance rejection capability of the PID controller.}771\end{figure}772773\noindent\textbf{Disturbance Rejection Metrics:}774\begin{itemize}775\item Maximum Deviation: \py{f"{max_deviation:.3f}"}776\item Recovery Time (2\% band): \py{f"{recovery_time:.2f}"} s777\end{itemize}778779\section{Conclusions}780781This tutorial demonstrated comprehensive control system analysis techniques:782783\begin{enumerate}784\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.785786\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.787788\item \textbf{Root Locus}: Pole migration with gain showed stability boundaries and damping ratio constraints.789790\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.791792\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.793\end{enumerate}794795The computational approach ensures all results are reproducible and automatically update when system parameters change.796797\end{document}798799800