Fit SOPDT with Regression

A second-order linear system with time delay is an empirical description of potentially oscillating dynamic processes. These systems are common in chemical processes, mechanical systems, and other applications where the output response can exhibit overshoot and oscillations before reaching a steady state.

The general form of the SOPDT model is given by:

$$\tau_s^2 \frac{d^2y}{dt^2} + 2 \zeta \tau_s \frac{dy}{dt} + y = K_p \, u\left(t-\theta_p \right)$$

where:

  • `y(t)` is the output variable (e.g., temperature, concentration, position).
  • `u(t)` is the input variable (e.g., heater power, feed rate, force).
  • `K_p` is the process gain, representing the steady-state change in output per unit change in input.
  • `\tau_s` is the second-order time constant, representing the speed of response.
  • `\zeta` is the damping factor, determining the degree of oscillation in the response.
  • `\theta_p` is the time delay (dead time), representing a delay between the input change and the output response.

Understanding SOPDT Parameters

Each parameter in the SOPDT model significantly influences the system's dynamic behavior:

  • Process Gain (`K_p`): Indicates how much the output will eventually change for a unit change in input. A higher gain results in a larger steady-state output.
  • Second-Order Time Constant (`\tau_s`): Determines the speed of the system's response. A smaller time constant means a faster response to input changes.
  • Damping Factor (`\zeta`):
    • Overdamped (`\zeta > 1`): The system returns to steady state without oscillations but slower.
    • Critically Damped (`\zeta = 1`): Fastest return to steady state without oscillations.
    • Underdamped (`0 < \zeta < 1`): The system oscillates before settling at the steady state.
  • Time Delay (`\theta_p`): Represents the delay between an input change and the system's initial response. Common in processes with transport or communication delays.

Simulated Data from the SOPDT Model

To demonstrate how to fit an SOPDT model to data, generate simulated data using a higher-order process model. The simulated data mimics real process behavior, including the dynamic response to changes in the input variable.

# Generate process data as data_sopdt.txt
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import odeint

# Define a higher-order process model to generate data
def process(y, t, n, u, Kp, taup):
    """
    Higher-order process model (nth-order system)
    """

    dydt = np.zeros(n)
    # First derivative
    dydt[0] = (-y[0] + Kp * u) / (taup / n)
    # Higher-order derivatives
    for i in range(1, n):
        dydt[i] = (-y[i] + y[i - 1]) / (taup / n)
    return dydt

# Time points
ns = 100  # Number of time steps
t = np.linspace(0, 40, ns)
delta_t = t[1] - t[0]

# Input vector (piecewise constant input)
u = np.zeros(ns)
u[5:20] = 1.0
u[20:30] = 0.1
u[30:] = 0.5

def sim_process_data():
    """
    Simulate the process data using the higher-order process model.
    """

    n = 10       # Order of the system
    Kp = 2.5     # Process gain
    taup = 5.0   # Process time constant
    yp = np.zeros(ns)  # Initialize output array
    y0 = np.zeros(n)   # Initial conditions
    for i in range(ns - 1):
        ts = [t[i], t[i + 1]]
        args = (n, u[i], Kp, taup)
        y = odeint(process, y0, ts, args=args)
        y0 = y[-1]
        yp[i + 1] = y[-1][n - 1]
    return yp

yp = sim_process_data()

# Save data to a file
data = np.vstack((t, u, yp)).T
np.savetxt('data_sopdt.txt', data, delimiter=',', comments='', header='time,u,y')

# Plot the simulated data
plt.figure(figsize=(10, 6))
plt.subplot(2, 1, 1)
plt.plot(t, yp, 'k-', linewidth=2, label='Output y(t)')
plt.ylabel('Output')
plt.legend(loc='best')
plt.subplot(2, 1, 2)
plt.plot(t, u, 'b-', linewidth=2, label='Input u(t)')
plt.ylabel('Input')
plt.xlabel('Time')
plt.legend(loc='best')
plt.tight_layout()
plt.show()

SOPDT Fit to Data

Estimate the SOPDT model parameters (`K_p`, `\tau_s`, `\zeta`, `\theta_p`) that best fit the process data. We use optimization techniques to minimize the sum of squared errors (SSE) between the model predictions and the observed data.

Below are two methods to perform the parameter estimation:

  1. Scipy Optimization Tools
  2. GEKKO Optimization Suite

Method 1: SOPDT Fit Using Scipy

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.integrate import odeint
from scipy.optimize import minimize
from scipy.interpolate import interp1d
import warnings

# Import CSV data file
# Column 1 = time (t)
# Column 2 = input (u)
# Column 3 = output (yp)
url = 'http://apmonitor.com/pdc/uploads/Main/data_fopdt.txt'
data = pd.read_csv(url)
t = data['time'].values - data['time'].values[0]
u = data['u'].values
yp = data['y'].values
u0 = u[0]
y0 = yp[0]
xp0 = yp[0]

# specify number of steps
ns = len(t)
delta_t = t[1]-t[0]
# create linear interpolation of the u data versus time
uf = interp1d(t,u)

def sopdt(x,t,uf,Kp,taus,zeta,thetap):
    # Kp = process gain
    # taus = second order time constant
    # zeta = damping factor
    # thetap = model time delay
    # ts^2 dy2/dt2 + 2 zeta taus dydt + y = Kp u(t-thetap)
    # time-shift u
    try:
        if (t-thetap) <= 0:
            um = uf(0.0)
        else:
            um = uf(t-thetap)
    except:
        # catch any error
        um = u0
    # two states (y and y')
    y = x[0] - y0
    dydt = x[1]
    dy2dt2 = (-2.0*zeta*taus*dydt - y + Kp*(um-u0))/taus**2
    return [dydt,dy2dt2]

# simulate model with x=[Km,taum,thetam]
def sim_model(x):
    # input arguments
    Kp = x[0]
    taus = x[1]
    zeta = x[2]
    thetap = x[3]
    # storage for model values
    xm = np.zeros((ns,2))  # model
    # initial condition
    xm[0] = xp0
    # loop through time steps    
    for i in range(0,ns-1):
        ts = [t[i],t[i+1]]
        inputs = (uf,Kp,taus,zeta,thetap)
        # turn off warnings
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            # integrate SOPDT model
            x = odeint(sopdt,xm[i],ts,args=inputs)
        xm[i+1] = x[-1]
    y = xm[:,0]
    return y

# define objective
def objective(x):
    # simulate model
    ym = sim_model(x)
    # calculate objective
    obj = 0.0
    for i in range(len(ym)):
        obj = obj + (ym[i]-yp[i])**2    
    # return result
    return obj

# initial guesses
p0 = np.zeros(4)
p0[0] = 3 # Kp
p0[1] = 5.0 # taup
p0[2] = 1.0 # zeta
p0[3] = 2.0 # thetap

# show initial objective
print('Initial SSE Objective: ' + str(objective(p0)))

# optimize Kp, taus, zeta, thetap
solution = minimize(objective,p0)

# with bounds on variables
#no_bnd = (-1.0e10, 1.0e10)
#low_bnd = (0.01, 1.0e10)
#bnds = (no_bnd, low_bnd, low_bnd, low_bnd)
#solution = minimize(objective,p0,method='SLSQP',bounds=bnds)

p = solution.x

# show final objective
print('Final SSE Objective: ' + str(objective(p)))

print('Kp: ' + str(p[0]))
print('taup: ' + str(p[1]))
print('zeta: ' + str(p[2]))
print('thetap: ' + str(p[3]))

# calculate model with updated parameters
ym1 = sim_model(p0)
ym2 = sim_model(p)
# plot results
plt.figure()
plt.subplot(2,1,1)
plt.plot(t,ym1,'b-',linewidth=2,label='Initial Guess')
plt.plot(t,ym2,'r--',linewidth=3,label='Optimized SOPDT')
plt.plot(t,yp,'k--',linewidth=2,label='Process Data')
plt.ylabel('Output')
plt.legend(loc='best')
plt.subplot(2,1,2)
plt.plot(t,u,'bx-',linewidth=2)
plt.plot(t,uf(t),'r--',linewidth=3)
plt.legend(['Measured','Interpolated'],loc='best')
plt.ylabel('Input Data')
plt.savefig('results.png')
plt.show()

Method 2: SOPDT Fit Using GEKKO

GEKKO is an optimization suite that facilitates model building and solving differential equations.

import numpy as np
import pandas as pd
from gekko import GEKKO
import matplotlib.pyplot as plt

# Import CSV data file
# Column 1 = time (t)
# Column 2 = input (u)
# Column 3 = output (y)
url = 'http://apmonitor.com/pdc/uploads/Main/data_sopdt.txt'
data = pd.read_csv(url)
t = data['time'].values - data['time'].values[0]
u = data['u'].values
y = data['y'].values

m = GEKKO(remote=False)
m.time = t; time = m.Var(0); m.Equation(time.dt()==1)

K = m.FV(2,lb=0,ub=10);      K.STATUS=1
tau = m.FV(3,lb=1,ub=200);   tau.STATUS=1
theta_ub = 30 # upper bound to dead-time
theta = m.FV(0,lb=0,ub=theta_ub); theta.STATUS=1
zeta = m.FV(1,lb=0.1,ub=3);  zeta.STATUS=1

# add extrapolation points
td = np.concatenate((np.linspace(-theta_ub,min(t)-1e-5,5),t))
ud = np.concatenate((u[0]*np.ones(5),u))
# create cubic spline with t versus u
uc = m.Var(u); tc = m.Var(t); m.Equation(tc==time-theta)
m.cspline(tc,uc,td,ud,bound_x=False)

ym = m.Param(y); yp = m.Var(y); xp = m.Var(y)
m.Equation(xp==yp.dt())
m.Equation((tau**2)*xp.dt()+2*zeta*tau*yp.dt()+yp==K*(uc-u[0]))

m.Minimize((yp-ym)**2)

m.options.IMODE=5
m.solve(disp=False)

print('Kp: ', K.value[0])
print('taup: ',  tau.value[0])
print('thetap: ', theta.value[0])
print('zetap: ', zeta.value[0])

# plot results
plt.figure()
plt.subplot(2,1,1)
plt.plot(t,y,'k.-',lw=2,label='Process Data')
plt.plot(t,yp.value,'r--',lw=2,label='Optimized SOPDT')
plt.ylabel('Output')
plt.legend()
plt.subplot(2,1,2)
plt.plot(t,u,'b.-',lw=2,label='u')
plt.legend()
plt.ylabel('Input')
plt.show()

Fit SOPDT Model to Experimental Data

In this example, we fit an SOPDT model to real experimental data from a temperature control lab (TCLab). The TCLab is a device with a heater and a temperature sensor, exhibiting second-order dynamics due to thermal mass and heat transfer effects.

Generating Experimental Data

Run the TCLab by applying step changes in the temperature set point and record the heater, temperature, and set point over time.

import tclab
import numpy as np
import time
import matplotlib.pyplot as plt
from scipy.integrate import odeint

######################################################
# Use this script for evaluating model predictions   #
# and PID controller performance for the TCLab       #
# Adjust only PID and model sections                 #
######################################################

######################################################
# PID Controller                                     #
######################################################
# inputs -----------------------------------
# sp = setpoint
# pv = current temperature
# pv_last = prior temperature
# ierr = integral error
# dt = time increment between measurements
# outputs ----------------------------------
# op = output of the PID controller
# P = proportional contribution
# I = integral contribution
# D = derivative contribution
def pid(sp,pv,pv_last,ierr,dt):
    Kc   = 15.0 # K/%Heater
    tauI = 40.0 # sec
    tauD = 1.0  # sec
    # Parameters in terms of PID coefficients
    KP = Kc
    KI = Kc/tauI
    KD = Kc*tauD
    # ubias for controller (initial heater)
    op0 = 0
    # upper and lower bounds on heater level
    ophi = 100
    oplo = 0
    # calculate the error
    error = sp-pv
    # calculate the integral error
    ierr = ierr + KI * error * dt
    # calculate the measurement derivative
    dpv = (pv - pv_last) / dt
    # calculate the PID output
    P = KP * error
    I = ierr
    D = -KD * dpv
    op = op0 + P + I + D
    # implement anti-reset windup
    if op < oplo or op > ophi:
        I = I - KI * error * dt
        # clip output
        op = max(oplo,min(ophi,op))
    # return the controller output and PID terms
    return [op,P,I,D]

# save txt file with data and set point
# t = time
# u1,u2 = heaters
# y1,y2 = tempeatures
# sp1,sp2 = setpoints
def save_txt(t, u1, u2, y1, y2, sp1, sp2):
    data = np.vstack((t, u1, u2, y1, y2, sp1, sp2))  # vertical stack
    data = data.T  # transpose data
    top = ('Time,Q1,Q2,T1,T2,SP1,SP2')
    np.savetxt('data.txt', data, delimiter=',', header=top, comments='')

######################################################
# FOPDT model                                        #
######################################################
Kp = 0.5      # degC/%
tauP = 120.0  # seconds
thetaP = 10   # seconds (integer)
Tss = 23      # degC (ambient temperature)
Qss = 0       # % heater

######################################################
# Energy balance model                               #
######################################################
def heat(x,t,Q):
    # Parameters
    Ta = 23 + 273.15   # K
    U = 10.0           # W/m^2-K
    m = 4.0/1000.0     # kg
    Cp = 0.5 * 1000.0  # J/kg-K    
    A = 12.0 / 100.0**2 # Area in m^2
    alpha = 0.01       # W / % heater
    eps = 0.9          # Emissivity
    sigma = 5.67e-8    # Stefan-Boltzman

    # Temperature State
    T = x[0]

    # Nonlinear Energy Balance
    dTdt = (1.0/(m*Cp))*(U*A*(Ta-T) \
            + eps * sigma * A * (Ta**4 - T**4) \
            + alpha*Q)
    return dTdt

######################################################
# Do not adjust anything below this point            #
######################################################

# Connect to Arduino
a = tclab.TCLab()

# Turn LED on
print('LED On')
a.LED(100)

# Run time in minutes
run_time = 15.0

# Number of cycles
loops = int(60.0*run_time)
tm = np.zeros(loops)

# Temperature
# set point (degC)
Tsp1 = np.ones(loops) * 25.0
Tsp1[60:] = 50.0
Tsp1[360:] = 30.0
Tsp1[660:] = 40.0
T1 = np.ones(loops) * a.T1 # measured T (degC)
error_sp = np.zeros(loops)

Tsp2 = np.ones(loops) * 23.0 # set point (degC)
T2 = np.ones(loops) * a.T2 # measured T (degC)

# Predictions
Tp = np.ones(loops) * a.T1
error_eb = np.zeros(loops)
Tpl = np.ones(loops) * a.T1
error_fopdt = np.zeros(loops)

# impulse tests (0 - 100%)
Q1 = np.ones(loops) * 0.0
Q2 = np.ones(loops) * 0.0

print('Running Main Loop. Ctrl-C to end.')
print('  Time     SP     PV     Q1   =  P   +  I  +   D')
print(('{:6.1f} {:6.2f} {:6.2f} ' + \
       '{:6.2f} {:6.2f} {:6.2f} {:6.2f}').format( \
           tm[0],Tsp1[0],T1[0], \
           Q1[0],0.0,0.0,0.0))

# Create plot
plt.figure() #figsize=(10,7)
plt.ion()
plt.show()

# Main Loop
start_time = time.time()
prev_time = start_time
dt_error = 0.0
# Integral error
ierr = 0.0
try:
    for i in range(1,loops):
        # Sleep time
        sleep_max = 1.0
        sleep = sleep_max - (time.time() - prev_time) - dt_error
        if sleep>=1e-4:
            time.sleep(sleep-1e-4)
        else:
            print('exceeded max cycle time by ' + str(abs(sleep)) + ' sec')
            time.sleep(1e-4)

        # Record time and change in time
        t = time.time()
        dt = t - prev_time
        if (sleep>=1e-4):
            dt_error = dt-1.0+0.009
        else:
            dt_error = 0.0
        prev_time = t
        tm[i] = t - start_time

        # Read temperatures in Kelvin
        T1[i] = a.T1
        T2[i] = a.T2

        # Simulate one time step with Energy Balance
        Tnext = odeint(heat,Tp[i-1]+273.15,[0,dt],args=(Q1[i-1],))
        Tp[i] = Tnext[1]-273.15

        # Simulate one time step with linear FOPDT model
        z = np.exp(-dt/tauP)
        Tpl[i] = (Tpl[i-1]-Tss) * z \
                 + (Q1[max(0,i-int(thetaP)-1)]-Qss)*(1-z)*Kp \
                 + Tss

        # Calculate PID output
        [Q1[i],P,ierr,D] = pid(Tsp1[i],T1[i],T1[i-1],ierr,dt)

        # Start setpoint error accumulation after 1 minute (60 seconds)
        if i>=60:
            error_eb[i] = error_eb[i-1] + abs(Tp[i]-T1[i])
            error_fopdt[i] = error_fopdt[i-1] + abs(Tpl[i]-T1[i])
            error_sp[i] = error_sp[i-1] + abs(Tsp1[i]-T1[i])

        # Write output (0-100)
        a.Q1(Q1[i])
        a.Q2(0.0)

        # Print line of data
        print(('{:6.1f} {:6.2f} {:6.2f} ' + \
              '{:6.2f} {:6.2f} {:6.2f} {:6.2f}').format( \
                  tm[i],Tsp1[i],T1[i], \
                  Q1[i],P,ierr,D))

        # Plot
        plt.clf()
        ax=plt.subplot(4,1,1)
        ax.grid()
        plt.plot(tm[0:i],T1[0:i],'r.',label=r'$T_1$ measured')
        plt.plot(tm[0:i],Tsp1[0:i],'k--',label=r'$T_1$ set point')
        plt.ylabel('Temperature (degC)')
        plt.legend(loc=2)
        ax=plt.subplot(4,1,2)
        ax.grid()
        plt.plot(tm[0:i],Q1[0:i],'b-',label=r'$Q_1$')
        plt.ylabel('Heater')
        plt.legend(loc='best')
        ax=plt.subplot(4,1,3)
        ax.grid()
        plt.plot(tm[0:i],T1[0:i],'r.',label=r'$T_1$ measured')
        plt.plot(tm[0:i],Tp[0:i],'k-',label=r'$T_1$ energy balance')
        plt.plot(tm[0:i],Tpl[0:i],'g-',label=r'$T_1$ linear model')
        plt.ylabel('Temperature (degC)')
        plt.legend(loc=2)
        ax=plt.subplot(4,1,4)
        ax.grid()
        plt.plot(tm[0:i],error_sp[0:i],'r-',label='Set Point Error')
        plt.plot(tm[0:i],error_eb[0:i],'k-',label='Energy Balance Error')
        plt.plot(tm[0:i],error_fopdt[0:i],'g-',label='Linear Model Error')
        plt.ylabel('Cumulative Error')
        plt.legend(loc='best')
        plt.xlabel('Time (sec)')
        plt.draw()
        plt.pause(0.05)

    # Turn off heaters
    a.Q1(0)
    a.Q2(0)
    # Save text file
    save_txt(tm[0:i],Q1[0:i],Q2[0:i],T1[0:i],T2[0:i],Tsp1[0:i],Tsp2[0:i])
    # Save figure
    plt.savefig('test_PID.png')

# Allow user to end loop with Ctrl-C          
except KeyboardInterrupt:
    # Disconnect from Arduino
    a.Q1(0)
    a.Q2(0)
    print('Shutting down')
    a.close()
    save_txt(tm[0:i],Q1[0:i],Q2[0:i],T1[0:i],T2[0:i],Tsp1[0:i],Tsp2[0:i])
    plt.savefig('test_PID.png')

# Make sure serial connection still closes when there's an error
except:          
    # Disconnect from Arduino
    a.Q1(0)
    a.Q2(0)
    print('Error: Shutting down')
    a.close()
    save_txt(tm[0:i],Q1[0:i],Q2[0:i],T1[0:i],T2[0:i],Tsp1[0:i],Tsp2[0:i])
    plt.savefig('test_PID.png')
    raise

Note: Optionally replace 'tclab_pid.csv' URL with your experimental data file generate from the script above.

import pandas as pd
import matplotlib.pyplot as plt

# Load experimental data
#file = 'tclab_pid.csv'
file = 'http://apmonitor.com/pdc/uploads/Main/tclab_pid.csv'
data = pd.read_csv(file)
t_exp = data['Time'].values
u_exp = data['Q1'].values
y_exp = data['T1'].values
sp = data['SP1'].values

# Plot the experimental data
plt.figure(figsize=(6, 3.5))
plt.subplot(2, 1, 1)
plt.plot(t_exp, y_exp, 'k-', label='Temperature')
plt.plot(t_exp, sp, 'r--', label='Set Point')
plt.ylabel('Temperature (°C)')
plt.legend()
plt.subplot(2, 1, 2)
plt.plot(t_exp, u_exp, 'b-', label='Heater Power')
plt.ylabel('Heater Power (%)')
plt.xlabel('Time (s)')
plt.legend()
plt.tight_layout()
plt.savefig('tclab_pid.png',dpi=300)
plt.show()

Fit the SOPDT Model to Experimental Data

We use the same fitting approach as before but apply it to the experimental data.

import numpy as np
import pandas as pd
from gekko import GEKKO
import matplotlib.pyplot as plt

# Load experimental data
#file = 'tclab_pid.csv'
file = 'http://apmonitor.com/pdc/uploads/Main/tclab_pid.csv'
data = pd.read_csv(file)
t = data['Time'].values - data['Time'].values[0]
u0 = data['Q1'].values[0]
y0 = data['T1'].values[0]
u = data['Q1'].values - u0
y = data['T1'].values - y0

m = GEKKO(remote=False)
m.time = t; time = m.Var(0); m.Equation(time.dt()==1)

K = m.FV(2,lb=0,ub=1);      K.STATUS=1
tau = m.FV(3,lb=1,ub=200);   tau.STATUS=1
theta_ub = 30 # upper bound to dead-time
theta = m.FV(0,lb=0,ub=theta_ub); theta.STATUS=1
zeta = m.FV(1,lb=0.5,ub=3);  zeta.STATUS=1

# add extrapolation points
td = np.concatenate((np.linspace(-theta_ub,min(t)-1e-5,5),t))
ud = np.concatenate((u[0]*np.ones(5),u))
# create cubic spline with t versus u
uc = m.Var(u); tc = m.Var(t); m.Equation(tc==time-theta)
m.cspline(tc,uc,td,ud,bound_x=False)

ym = m.Param(y); yp = m.Var(y); xp = m.Var(y)
m.Equation(xp==yp.dt())
m.Equation((tau**2)*xp.dt()+2*zeta*tau*yp.dt()+yp==K*(uc-u[0]))

m.Minimize((yp-ym)**2)

m.options.IMODE=5
m.solve(disp=False)

print('Kp: ', K.value[0])
print('taup: ',  tau.value[0])
print('thetap: ', theta.value[0])
print('zetap: ', zeta.value[0])

# plot results
plt.figure(figsize=(6,3.5))
plt.subplot(2,1,1)
plt.plot(t,y+y0,'k.-',lw=2,label='Process Data')
plt.plot(t,yp.value+y0,'r--',lw=2,label='Optimized SOPDT')
plt.ylabel('Output')
plt.legend()
plt.subplot(2,1,2)
plt.plot(t,u+u0,'b.-',lw=2,label='u')
plt.legend()
plt.ylabel('Input')
plt.tight_layout()
plt.savefig('pid_data_SOPDT.png',dpi=300)
plt.show()

Discussion of Results

The SOPDT model parameters provide insights into the system dynamics.

  • Process Gain (`K_p`=0.64): Indicates how much the temperature changes per unit change in heater power.
  • Time Constant (`\tau_s`=45.7 sec): Reflects the speed of the temperature response.
  • Damping Factor (`\zeta`=1.83): Determines whether the temperature overshoots the setpoint.
  • Time Delay (`\theta_p`=4.5 sec): Accounts for the delay between heater activation and temperature change.

Is the damping factor for the heater / temperature response overdamped, critically damped, or underdamped?

  • Overdamped `(\zeta>1)`
  • Critically damped `(\zeta=1)`
  • Underdamped `(0 \le \zeta < 1)`

Comparing the model output with the experimental data helps validate the model accuracy and identify any discrepancies.

Conclusion

Fitting an SOPDT model to process data captures the essential dynamics of a system with potential oscillatory behavior and time delays. The estimated model can be used for controller design, simulation studies, and understanding system behavior.

See Second Order Estimation: Optimization

💬