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()
../../_images/81390d085c07a28ba195af12b185068c02ef358fac07c3a04c535dc5f4fb8788.png

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)
../../_images/b7b969bcf8c2d09bcefcb7731415b0bfabdf5619e0dd04cef4d7abbce43d2886.png ../../_images/5459962049978b686ee383698c8b5e7951f7016393bdcfc6aee79e448e80f034.png
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>
../../_images/06cea3ec0e5cbfd08b3c75bb7de46b7b0e1de8bb865a16fe4c792add31f14e8d.png