3.10. Analysis of Velocity-Form Bumpless PI Controller#
# Set default parameters for publication quality plots
import matplotlib.pyplot as plt
SMALL_SIZE = 14
MEDIUM_SIZE = 16
BIGGER_SIZE = 18
plt.rc('font', size=SMALL_SIZE)  # controls default text sizes
plt.rc('axes', titlesize=SMALL_SIZE)  # fontsize of the axes title
plt.rc('axes', labelsize=MEDIUM_SIZE)  # fontsize of the x and y labels
plt.rc('xtick', labelsize=SMALL_SIZE)  # fontsize of the tick labels
plt.rc('ytick', labelsize=SMALL_SIZE)  # fontsize of the tick labels
plt.rc('legend', fontsize=SMALL_SIZE)  # legend fontsize
plt.rc('figure', titlesize=BIGGER_SIZE)  # fontsize of the figure title
plt.rc('lines', linewidth=3)
3.10.1. Closed-Loop Dynamics#
We start with the velocity form of the controller:
(3.40)#\[\begin{equation}
u_k = u_{k-1} + K_p (-y_k + y_{k-1}) + K_I \underbrace{(y_{SP} - y_f)\Delta t}_{e_k}
\end{equation}\]
Next, divide by \(\Delta t\):
(3.41)#\[\begin{equation}
\frac{u_k - u_{k-1}}{\Delta t} = -K_p \frac{(y_k - y_{k-1})}{\Delta t} + K_I e_k
\end{equation}\]
Consider limit as \(\Delta t \to 0\):
(3.42)#\[\begin{equation}
\dot{u} = -K_p \dot{y} + K_I e_k 
\end{equation}\]
Next, consider the two-state model for a single TCLab channel:
(3.43)#\[\begin{align}
C_p^H \frac{dT^*_{H,1}}{dt} &= -U_a T^*_{H,1} + U_b (T^*_{S,1} - T^*_{H,1}) + \alpha P_u \\
C_p^S \frac{dT^*_{S,1}}{dt} &= U_b (T^*_{H,1} - T^*_{S,1})
\end{align}\]
Next, substitute the control law:
(3.44)#\[\begin{equation}
\frac{du}{dt} = -K_p \dot{T}^*_{S,1} + K_I (T^*_{set} - T^*_{S,1})
\end{equation}\]
Finally, subsitute the second differential equation:
(3.45)#\[\begin{equation}
\frac{du}{dt} = -K_p \frac{U_b (T^*_{H,1} - T^*_{S,1})}{C_p^S} + K_I (T^*_{set} - T^*_{S,1})
\end{equation}\]
Now, we can write the model as a linear system:
(3.46)#\[\begin{equation}
\underbrace{\begin{bmatrix}
\dot{T}_{H,1} \\
\dot{T}_{S,1} \\
\dot{u}
\end{bmatrix}}_{\mathbf{\dot{x}}} =
\underbrace{\begin{bmatrix}
-\frac{U_a + U_b}{C_p^H} & \frac{U_b}{C_p^H} & \frac{\alpha P_i}{C_p^H} \\
\frac{U_b}{C_p^S} & -\frac{U_b}{C_p^S} & 0 \\
-\frac{K_p U_b}{C_p^S} & \frac{K_p U_b}{C_p^S} - K_I & 0
\end{bmatrix}}_{\mathbf{A}} 
\underbrace{\begin{bmatrix}
T_{H,1}^* \\
T_{S,1}^* \\
u
\end{bmatrix}}_\mathbf{x} +
\underbrace{\begin{bmatrix}
0 \\
0 \\
K_I
\end{bmatrix}}_\mathbf{B}
\underbrace{\begin{bmatrix} T^*_{set} \end{bmatrix}}_\mathbf{u}
\end{equation}\]
(3.47)#\[\begin{equation}
\underbrace{\begin{bmatrix} T^*_{S,1} \end{bmatrix}}_\mathbf{y} = 
\underbrace{\begin{bmatrix}
0 & 1 & 0
\end{bmatrix}}_\mathbf{C}
\underbrace{\begin{bmatrix}
T_{H,1} \\
T_{S,1} \\
u
\end{bmatrix}}_\mathbf{x} + 
\underbrace{\begin{bmatrix} 0 \end{bmatrix}}_{\mathbf{D}} 
\underbrace{\begin{bmatrix} T^*_{set} \end{bmatrix}}_\mathbf{u}
\end{equation}\]
3.10.2. Simulation#
import numpy as np
# parameters
T_amb = 21 # deg C
alpha = 0.00016 # watts / (units P1 * percent U1)
P1 = 100 # P1 units
U1 = 50 # steady state value of u1 (percent)
# fitted parameters (see previous lab) for hardware
'''
Ua = 0.0261 # watts/deg C
Ub = 0.0222 # watts/deg C
CpH = 1.335 # joules/deg C
CpS = 1.328 # joules/deg C
'''
# fitted parameters (repeat Lab 3) for TCLab digital twin
Ua = 0.05 # watts/deg C
Ub = 0.05 # watts/deg C
CpH = 5.0 # joules/deg C
CpS = 1.0 # joules/deg C
t = np.arange(0, 600, 1)
T_set = np.ones(t.shape)*5
T_set[100:] = 30
plt.step(t, T_set + T_amb, linestyle='-.', color='black', alpha=0.5)
plt.xlabel('Time [second]')
plt.ylabel('Setpoint [° C]')
plt.show()
3.10.2.1. Continuous Simulation#
3.10.2.2. Discrete Simulation#
import pandas as pd
from scipy.signal import cont2discrete
def tclab_simulate_bumpless_PI(Kp = 1.0, Ki = 0.05, verbose=False, plot=True):
    ''' Simulate the TCLab system with PI control
    Arguments:
        Kp: the proportional control gain
        Ki: the integral control gain
        verbose: print matrices, default is False
        plot: create plots, default is True
    Returns:
        data: DataFrame with columns for Time, T1, T2, Q1, Q2
    '''
    n = len(t)
    assert len(T_set) == n, 'Setpoint array must have the same length as time array'
    # Original open loop state space model
    A = np.array([[-(Ua + Ub)/CpH, Ub/CpH], [Ub/CpS, -Ub/CpS]])
    B = np.array([[alpha*P1/CpH], [0]])
    C = np.array([[0, 1]])
    D = np.array([[0]])
    Ad, Bd, Cd, Dd, dt = cont2discrete((A, B, C, D), dt=1, method='zoh')
    # Initialize state matrix
    X = np.zeros((n, 2))
    # Initialize input matrix
    U = np.zeros((n, 1))
    prev_error = 0
    # Loop over time steps
    for i in range(n):
    
        # Current state
        x = X[i, :]
        # Unpack into individual states
        T1H, T1S = x
        error = T_set[i] - T1S
        if i > 0:
            dt = t[i] - t[i-1]
            U[i,0] = U[i-1,0] + Kp*(error-prev_error) +  dt*Ki*(error)
        # Limit the power levels
        U[i, 0] = max(0, min(100, U[i, 0]))
        # Update state
        if i < n-1:
            # Do not update the state for the last time step
            # We want to update U and SP for plotting
            X[i + 1, :] = Ad @ x + Bd @ U[i, :]
        prev_error = error
    # Shift states from deviation variables to absolute values
    X += T_amb
    # Create DataFrame
    data = pd.DataFrame(X, columns=['T1H', 'T1S'])
    data['Time'] = t
    data['Q1'] = U[:, 0]
    data['SP1'] = T_set + T_amb
    if plot:
        plt.title('Channel 1, Simulated P Control with Kp={}, Ki={}'.format(Kp,Ki))
        plt.step(data['Time'], data['T1H'], label='T1H', linestyle='--')
        plt.step(data['Time'], data['T1S'], label='T1S', linestyle='-')
        plt.step(data['Time'], data['SP1'], label='SP1', linestyle='-.', color='black', alpha=0.5)
        plt.ylabel('Temperature (C)')
        plt.xlabel('Time (s)')
        plt.legend()
        plt.grid()
        plt.show()
        
        plt.title('Heaters, Simulated P Control with Kp={}, Ki={}'.format(Kp,Ki))
        plt.step(data['Time'], data['Q1'], label='Q1')
        plt.xlabel('Time (s)')
        plt.ylabel('Power Level (%)')
        plt.legend()
        plt.grid()
        plt.show()
    return data
tclab_simulate_bumpless_PI(Kp=10.0, Ki=0.2)
| T1H | T1S | Time | Q1 | SP1 | |
|---|---|---|---|---|---|
| 0 | 21.000000 | 21.000000 | 0 | 0.000000 | 26.0 | 
| 1 | 21.000000 | 21.000000 | 1 | 1.000000 | 26.0 | 
| 2 | 21.003168 | 21.000078 | 2 | 1.999203 | 26.0 | 
| 3 | 21.009442 | 21.000384 | 3 | 2.996071 | 26.0 | 
| 4 | 21.018754 | 21.001055 | 4 | 3.989146 | 26.0 | 
| ... | ... | ... | ... | ... | ... | 
| 595 | 50.984636 | 50.990960 | 595 | 93.702318 | 51.0 | 
| 596 | 50.984699 | 50.990653 | 596 | 93.707256 | 51.0 | 
| 597 | 50.984772 | 50.990365 | 597 | 93.712069 | 51.0 | 
| 598 | 50.984857 | 50.990094 | 598 | 93.716757 | 51.0 | 
| 599 | 50.984952 | 50.989841 | 599 | 93.721319 | 51.0 | 
600 rows × 5 columns
3.10.3. Stability Analysis#
# Eigendecomposition analysis
from scipy.linalg import eig
import numpy as np
def calc_eig(Kp, Ki, verbose=True):
    """Calculates the eigenvalues and eigenvectors of the A_PI matrix.
    Args:
        Kp: Proportional gain.
        Ki: Integral gain.
        verbose: If True, prints the eigenvalues and eigenvectors.
    Returns:
        A numpy array containing the eigenvalues.
    """
    A_PI = np.array([[-(Ua + Ub)/CpH, Ub/CpH, alpha*P1/CpH], 
                [Ub/CpS, -Ub/CpS, 0],
                [-Kp*Ub/CpS, Kp*Ub/CpS - Ki, 0]])
    
    w, vl = eig(A_PI)
    if verbose:
        for i in range(len(w)):
            print("Eigenvalue",i,"=",w[i])
            print("Eigenvector",i,"=",vl[:,i],"\n")
    return w
calc_eig(1.5, 0.01)
Eigenvalue 0 = (-0.057644127570114174+0j)
Eigenvector 0 = [ 0.09164288 -0.59943323  0.79516124] 
Eigenvalue 1 = (-0.009404448645463076+0j)
Eigenvector 1 = [-0.5969477  -0.73523783  0.32105883] 
Eigenvalue 2 = (-0.0029514237844228087+0j)
Eigenvector 2 = [0.40304171 0.42832509 0.80876139] 
array([-0.05764413+0.j, -0.00940445+0.j, -0.00295142+0.j])
Kp_range = np.arange(0.1,10,0.1)
Ki_range = np.arange(-3,0.1,0.1)
xv, yv = np.meshgrid(Kp_range, Ki_range)
# store eigenvalues in 3D array
s3 = (len(Ki_range),len(Kp_range),3)
s2 = (len(Ki_range),len(Kp_range))
ev = np.zeros(s3, dtype=complex)
positive_real_eig = np.zeros(s2)
nonzero_imag_eig = np.zeros(s2)
small_number = 1E-9
for i in range(len(Ki_range)):
    for j in range(len(Kp_range)):
        ev[i,j,:] = calc_eig(xv[i,j], np.power(10,yv[i,j]), verbose=False)[:]
        positive_real_eig[i,j] = sum(np.real(ev[i,j,:]) >= -small_number)
        nonzero_imag_eig[i,j] = sum(np.abs(np.imag(ev[i,j,:])) >= small_number)
plt.figure()
fig, axs = plt.subplots(1,2, sharey=True)
axs[0].set_box_aspect(1)
cs = axs[0].contourf(xv,yv,positive_real_eig, levels=100)
axs[0].set_xlabel('$K_P$')
axs[0].set_ylabel('log$_{10}$($K_I$)')
axs[0].set_title('Count Re($\lambda_i$)$>0$')
axs[1].set_box_aspect(1)
cs = axs[1].contourf(xv,yv,nonzero_imag_eig, levels=100)
axs[1].set_xlabel('$K_P$')
#axs[1].set_ylabel('log$_{10}$($K_i$)')
axs[1].set_title('Count Im($\lambda_i$)$≠0$')
cbar = fig.colorbar(cs, ticks=[0, 2], orientation='horizontal', ax=axs[:])
#plt.tight_layout()
plt.show()
<Figure size 640x480 with 0 Axes>