PID Controller with SEELab3

PID Controller with SEELab3

The setup used a BMP280 connected to a SEELab3 for temperature reading, and a heating cartridge whose current is adjusted with a power supply. The algorithm is written in Python with a PyQt5 graphical interface. All measurements and tuning work was carried out by Faheema at Dr. Shahin’s Lab.

Wrote code for a PID controller for a temperature controller dielectric spectroscopy setup. Most commercially available PID controllers use PWM (switching and varying the duty cycle) to adjust heating current, and the transient current spikes completely destroy any readings made near the heater. Therefore, I went with a Tektronix power supply with a USBTMC interface.

The python-usbtmc is quite easy to use on Linux. On windows it is a pain to install drivers, but finally got it running with Zadig. The eyes17lib python library is used to interface with SEELab3.

Since the BMP280 has a leat count of 0.01 , the algorithm managed a ridiculous stability of 0.02C

The Code

'''
PID controller. Connect BMP280

PWS4305 linear power supply.
https://download.tek.com/manual/077048102web.pdf

Remote operation

:SYST:REM
:SYST:LOC

Measurement commands
CURR?
:MEAS:CURR?
:MEAS:VOLT?

Setting commands
CURR x
'''

import sys
import threading
import time, usbtmc
from PyQt5 import QtWidgets, QtCore
import pyqtgraph as pg
import eyes17.eyes
from eyes17 import SENSORS
import numpy as np
from simple_pid import PID

class Expt(QtWidgets.QMainWindow):
    update_plots_signal = QtCore.pyqtSignal(float, float)  # Signal to update plots
    setpoint_changed_signal = QtCore.pyqtSignal(float)  # Signal for setpoint changes

    def __init__(self):
        super().__init__()
        self.setpoint = 50.0  # Default setpoint
        self.pid = None
        self.p = eyes17.eyes.open()
        self.bmp = SENSORS.BMP280.connect(self.p.I2C)
        self.scpi = usbtmc.Instrument(0x0699, 0x0392)
        
        # Get the instrument identification string
        idn_string = self.scpi.ask('*IDN?')
        print(idn_string)
        self.scpi.write('SYST:REM')

        self.running = True
        self.temperature_data = []
        self.current_data = []
        self.initUI()

        # Set the window title with the instrument ID
        self.setWindowTitle(f'PID Temperature Controller - {idn_string}')

        # Connect signals to slots
        self.update_plots_signal.connect(self.update_plots)
        self.setpoint_changed_signal.connect(self.update_setpoint)

    def initUI(self):
        self.setWindowTitle('PID Temperature Controller')

        # Create text fields for initial vaue parameters
        self.initial_input = QtWidgets.QLineEdit(self)
        self.initial_input.setText('1.04')
        # Create text fields for PID parameters
        self.p_input = QtWidgets.QLineEdit(self)
        self.p_input.setText('0.002')
        self.i_input = QtWidgets.QLineEdit(self)
        self.i_input.setText('0.005')
        self.d_input = QtWidgets.QLineEdit(self)
        self.d_input.setText('0.005')

        # Create text field for setpoint
        self.setpoint_input = QtWidgets.QLineEdit(self)
        self.setpoint_input.setText(str(self.setpoint))
        self.setpoint_input.editingFinished.connect(self.on_setpoint_changed)

        # Create buttons to start and stop the PID loop
        self.start_button = QtWidgets.QPushButton('Start', self)
        self.start_button.clicked.connect(self.start_pid)
        self.stop_button = QtWidgets.QPushButton('Stop', self)
        self.stop_button.clicked.connect(self.stop_pid)

        # Set up the layout
        layout = QtWidgets.QVBoxLayout()
        layout.addWidget(QtWidgets.QLabel('initial:'))
        layout.addWidget(self.initial_input)
        layout.addWidget(QtWidgets.QLabel('P:'))
        layout.addWidget(self.p_input)
        layout.addWidget(QtWidgets.QLabel('I:'))
        layout.addWidget(self.i_input)
        layout.addWidget(QtWidgets.QLabel('D:'))
        layout.addWidget(self.d_input)
        layout.addWidget(QtWidgets.QLabel('Setpoint (°C):'))
        layout.addWidget(self.setpoint_input)
        layout.addWidget(self.start_button)
        layout.addWidget(self.stop_button)

        # Create a widget for the plots
        self.plot_widget = pg.GraphicsLayoutWidget()
        layout.addWidget(self.plot_widget)

        # Set up the plots
        self.temp_plot = self.plot_widget.addPlot(title="Temperature")
        self.current_plot = self.plot_widget.addPlot(title="Current Output")

        # Set the central widget
        central_widget = QtWidgets.QWidget()
        central_widget.setLayout(layout)
        self.setCentralWidget(central_widget)

        self.pid_thread = threading.Thread(target=self.run_pid)
        self.pid_thread.start()

    def on_setpoint_changed(self):
        new_setpoint = float(self.setpoint_input.text())
        self.setpoint_changed_signal.emit(new_setpoint)

    @QtCore.pyqtSlot(float)
    def update_setpoint(self, new_setpoint):
        self.setpoint = new_setpoint
        if self.pid is not None:
            self.pid.setpoint = new_setpoint

    def start_pid(self):
        initval = float(self.initial_input.text())
        Kp = float(self.p_input.text())
        Ki = float(self.i_input.text())
        Kd = float(self.d_input.text())
        self.setpoint = float(self.setpoint_input.text())
        self.pid.tunings = (Kp, Ki, Kd)
        self.pid.setpoint = self.setpoint
        self.scpi.write('OUTP ON')
        #self.pid.auto_mode = True
        self.pid.set_auto_mode(True, last_output=initval)

    def stop_pid(self):
        self.pid.auto_mode = False
        self.scpi.write('OUTP OFF')

    def kill_pid(self):
        self.running = False
        if self.pid_thread.is_alive():
            self.pid_thread.join()

    def run_pid(self):
        # PID parameters
        Kp = float(self.p_input.text())
        Ki = float(self.i_input.text())
        Kd = float(self.d_input.text())
        self.setpoint = float(self.setpoint_input.text())


        self.pid = PID(Kp, Ki, Kd, setpoint = self.setpoint)
        self.pid.output_limits = (0, 5) #0 to 5Amps
        self.pid.auto_mode = False
        self.pid.sample_time = 0.1  # Update every n seconds

        while self.running:
            temperature = np.average([self.bmp.getVals()[1] for a in range(10)])
            
            # Set the current output
            current_output = self.pid(temperature)
            
            # Print debug information
            print(f"SET:{self.setpoint}, T: {temperature}, I: {current_output} | {self.pid.auto_mode}")
            
            # Emit signal to update plots
            if not self.pid.auto_mode:
                current_output = 0

            self.update_plots_signal.emit(temperature, current_output)

            time.sleep(0.005)  # Adjust sleep time as needed

    def calculate_temperature(self, resistance):
        # Callendar-Van Dusen Constants for standard PT100 (IEC 60751)
        A = 3.9083e-3
        B = -5.775e-7
        C = -4.183e-12  # Used only for T < 0°C
        r0 = 98.4
        
        # Approximate temperature using the linear formula
        approx_temp = (resistance - r0) / (A * r0)
        
        # Solve Callendar-Van Dusen equation for better accuracy
        import math
        if resistance >= r0:
            # Quadratic formula (valid for T ≥ 0°C)
            temp = (-A + math.sqrt(A**2 - 4 * B * (1 - resistance / r0))) / (2 * B)
        else:
            # For T < 0°C, numerical solving is required due to the cubic term
            from scipy.optimize import fsolve
            def cvd_equation(T):
                return r0 * (1 + A*T + B*T**2 + C*(T - 100)*T**3) - resistance
            temp = fsolve(cvd_equation, approx_temp)[0]  # Use approx_temp as initial guess
        
        print(f"R:{resistance} , T:{temp}")
        return temp
        #return temperature

    @QtCore.pyqtSlot(float, float)
    def update_plots(self, temperature, current_output):
        # Limit data arrays to 10,000 points
        MAX_POINTS = 10000
        
        self.temperature_data.append(temperature)
        self.current_data.append(current_output)
        
        # Keep only the last MAX_POINTS
        if len(self.temperature_data) > MAX_POINTS:
            self.temperature_data = self.temperature_data[-MAX_POINTS:]
        if len(self.current_data) > MAX_POINTS:
            self.current_data = self.current_data[-MAX_POINTS:]

        # Update temperature plot
        self.temp_plot.clear()
        self.temp_plot.plot(self.temperature_data, pen='r')

        # Update current output plot
        self.current_plot.clear()
        self.current_plot.plot(self.current_data, pen='b')

        self.scpi.write(f'CURR {current_output}')


    def closeEvent(self, event):
        # Stop the PID thread when the window is closed
        self.stop_pid()
        self.kill_pid()
        self.scpi.write('OUTP OFF')
        self.scpi.write('SYST:LOC')
        event.accept()

if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    ex = Expt()
    ex.show()
    sys.exit(app.exec_())