├── LICENSE ├── PythonPID_Simulator.pyw ├── README.md └── requirements.txt /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Destination2Unknown 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PythonPID_Simulator.pyw: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Updated and maintained by destination0b10unknown@gmail.com 4 | Copyright 2023 destination2unknown 5 | 6 | Licensed under the MIT License; 7 | you may not use this file except in compliance with the License. 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | """ 16 | 17 | import io 18 | import matplotlib.pyplot as plt 19 | import numpy as np 20 | import ttkbootstrap as ttk 21 | from PIL import Image, ImageTk 22 | from scipy.integrate import odeint 23 | from tkinter import messagebox 24 | 25 | 26 | class PID_Controller(object): 27 | """ 28 | Proportional-Integral-Derivative (PID) controller. 29 | 30 | Attributes: 31 | Kp (float): Proportional gain. 32 | Ki (float): Integral gain. 33 | Kd (float): Derivative gain. 34 | setpoint (float): Target setpoint for the controller. 35 | _min_output (float): Minimum allowed controller output value. 36 | _max_output (float): Maximum allowed controller output value. 37 | _proportional (float): Proportional term value. 38 | _integral (float): Integral term value. 39 | _derivative (float): Derivative term value. 40 | output_limits (tuple): Tuple containing the minimum and maximum allowed controller output values. 41 | _last_eD (float): The previous error value used for derivative calculation. 42 | _lastCV (float): The previous controller output value. 43 | _d_init (int): A flag to indicate whether the derivative term has been initialized. 44 | """ 45 | 46 | def __init__(self): 47 | """ 48 | Initialize the PID controller with default values. 49 | """ 50 | self.Kp, self.Ki, self.Kd = 1, 0.1, 0.01 51 | self.setpoint = 50 52 | self._min_output, self._max_output = 0, 100 53 | self._proportional = 0 54 | self._integral = 0 55 | self._derivative = 0 56 | self.output_limits = (0, 100) 57 | self._last_eD = 0 58 | self._lastCV = 0 59 | self._d_init = 0 60 | self.reset() 61 | 62 | def __call__(self, PV=0, SP=0, direction="Direct"): 63 | """ 64 | Calculate the control value (CV) based on the process variable (PV) and the setpoint (SP). 65 | """ 66 | 67 | # P term 68 | if direction == "Direct": 69 | e = SP - PV 70 | else: 71 | e = PV - SP 72 | self._proportional = self.Kp * e 73 | 74 | # I Term 75 | if self._lastCV < 100 and self._lastCV > 0: 76 | self._integral += self.Ki * e 77 | # Allow I Term to change when Kp is set to Zero 78 | if self.Kp == 0 and self._lastCV == 100 and self.Ki * e < 0: 79 | self._integral += self.Ki * e 80 | if self.Kp == 0 and self._lastCV == 0 and self.Ki * e > 0: 81 | self._integral += self.Ki * e 82 | 83 | # D term 84 | eD = -PV 85 | self._derivative = self.Kd * (eD - self._last_eD) 86 | 87 | # init D term 88 | if self._d_init == 0: 89 | self._derivative = 0 90 | self._d_init = 1 91 | 92 | # Controller Output 93 | CV = self._proportional + self._integral + self._derivative 94 | CV = self._clamp(CV, self.output_limits) 95 | 96 | # update stored data for next iteration 97 | self._last_eD = eD 98 | self._lastCV = CV 99 | return CV 100 | 101 | @property 102 | def components(self): 103 | """ 104 | Get the individual components of the controller output. 105 | """ 106 | return self._proportional, self._integral, self._derivative 107 | 108 | @property 109 | def tunings(self): 110 | """ 111 | Get the current PID tuning values (Kp, Ki, and Kd). 112 | """ 113 | return self.Kp, self.Ki, self.Kd 114 | 115 | @tunings.setter 116 | def tunings(self, tunings): 117 | """ 118 | Set new PID tuning values (Kp, Ki, and Kd). 119 | """ 120 | self.Kp, self.Ki, self.Kd = tunings 121 | 122 | @property 123 | def output_limits(self): 124 | """ 125 | Get the current output limits (minimum and maximum allowed controller output values). 126 | """ 127 | return self._min_output, self._max_output 128 | 129 | @output_limits.setter 130 | def output_limits(self, limits): 131 | """ 132 | Set new output limits (minimum and maximum allowed controller output values). 133 | """ 134 | if limits is None: 135 | self._min_output, self._max_output = 0, 100 136 | return 137 | min_output, max_output = limits 138 | self._min_output = min_output 139 | self._max_output = max_output 140 | self._integral = self._clamp(self._integral, self.output_limits) 141 | 142 | def reset(self): 143 | """ 144 | Reset the controller values to their initial state. 145 | """ 146 | self._proportional = 0 147 | self._integral = 0 148 | self._derivative = 0 149 | self._integral = self._clamp(self._integral, self.output_limits) 150 | self._last_eD = 0 151 | self._d_init = 0 152 | self._lastCV = 0 153 | 154 | def _clamp(self, value, limits): 155 | """ 156 | Clamp the given value between the specified limits. 157 | """ 158 | lower, upper = limits 159 | if value is None: 160 | return None 161 | elif (upper is not None) and (value > upper): 162 | return upper 163 | elif (lower is not None) and (value < lower): 164 | return lower 165 | return value 166 | 167 | 168 | class FOPDT_Model(object): 169 | """ 170 | First Order Plus Dead Time (FOPDT) Model. 171 | """ 172 | 173 | def __init__(self): 174 | """ 175 | Initialize the FOPDTModel with an empty list to store control values (CV). 176 | """ 177 | self.work_CV = [] 178 | 179 | def change_params(self, data): 180 | """ 181 | Update the model parameters with new values. 182 | """ 183 | self.Gain, self.Time_Constant, self.Dead_Time, self.Bias = data 184 | 185 | def _calc(self, work_PV, ts): 186 | """ 187 | Calculate the change in the process variable (PV) over time. 188 | """ 189 | if (ts - self.Dead_Time) <= 0: 190 | um = 0 191 | elif int(ts - self.Dead_Time) >= len(self.work_CV): 192 | um = self.work_CV[-1] 193 | else: 194 | um = self.work_CV[int(ts - self.Dead_Time)] 195 | dydt = (-(work_PV - self.Bias) + self.Gain * um) / self.Time_Constant 196 | return dydt 197 | 198 | def update(self, work_PV, ts): 199 | """ 200 | Update the process variable (PV) using the FOPDT model. 201 | """ 202 | y = odeint(self._calc, work_PV, ts) 203 | return y[-1] 204 | 205 | 206 | class PID_Simulator(object): 207 | """ 208 | Python PID Simulator Application. 209 | 210 | This application provides a Graphical User Interface (GUI) to simulate and visualize the response 211 | of a Proportional-Integral-Derivative (PID) controller using the First Order Plus Dead Time (FOPDT) model. 212 | """ 213 | 214 | def __init__(self): 215 | """ 216 | Initialize the PID Simulator application. 217 | """ 218 | plt.style.use("bmh") 219 | self.root = ttk.Window() 220 | self.root.title("Python PID Simulator") 221 | self.root.state("zoomed") 222 | # Adjust theme to suit 223 | style = ttk.Style(theme="yeti") 224 | style.theme.colors.bg = "#c0c0c0" 225 | style.configure(".", font=("Helvetica", 12)) 226 | # Add frames 227 | self.master_frame = ttk.Frame(self.root) 228 | self.master_frame.pack(fill="both", expand=True) 229 | self.bottom_frame = ttk.Frame(self.master_frame) 230 | self.bottom_frame.pack(side="bottom", fill="both", expand=True) 231 | self.left_frame = ttk.LabelFrame(self.master_frame, text=" Controller ", bootstyle="Success") 232 | self.left_frame.pack(side="left", fill="both", expand=True, padx=10, pady=10) 233 | self.middle_frame = ttk.LabelFrame(self.master_frame, text=" Result ", bootstyle="Light") 234 | self.middle_frame.pack(side="left", fill="both", expand=True, padx=10, pady=10) 235 | self.right_frame = ttk.LabelFrame(self.master_frame, text=" Process ", bootstyle="Warning") 236 | self.right_frame.pack(side="right", fill="both", expand=True, padx=10, pady=10) 237 | 238 | # GUI Variables 239 | self.model_gain = ttk.DoubleVar(value=2.5) 240 | self.model_tc = ttk.DoubleVar(value=75.5) 241 | self.model_dt = ttk.DoubleVar(value=10.5) 242 | self.model_bias = ttk.DoubleVar(value=13.5) 243 | self.kp = ttk.DoubleVar(value=1.9) 244 | self.ki = ttk.DoubleVar(value=0.1) 245 | self.kd = ttk.DoubleVar(value=1.1) 246 | 247 | # Left Frame Static Text 248 | ttk.Label(self.left_frame, text="PID Gains").grid(row=0, column=0, padx=10, pady=10, columnspan=2) 249 | ttk.Label(self.left_frame, text="Kp:").grid(row=1, column=0, padx=10, pady=10) 250 | ttk.Label(self.left_frame, text="Ki (1/sec):").grid(row=2, column=0, padx=10, pady=10) 251 | ttk.Label(self.left_frame, text="Kd (sec):").grid(row=3, column=0, padx=10, pady=10) 252 | # Left Frame Entry Boxes 253 | ttk.Spinbox(self.left_frame, from_=-1000.00, to=1000.00, increment=0.1, textvariable=self.kp, width=15).grid(row=1, column=1, padx=10, pady=10) 254 | ttk.Spinbox(self.left_frame, from_=-1000.00, to=1000.00, increment=0.01, textvariable=self.ki, width=15).grid(row=2, column=1, padx=10, pady=10) 255 | ttk.Spinbox(self.left_frame, from_=-1000.00, to=1000.00, increment=0.01, textvariable=self.kd, width=15).grid(row=3, column=1, padx=10, pady=10) 256 | 257 | # Button 258 | button_refresh = ttk.Button(self.left_frame, text="Refresh", command=self.generate_response, bootstyle="Success") 259 | button_refresh.grid(row=5, column=0, columnspan=2, sticky="NESW", padx=10, pady=10) 260 | 261 | # Middle Frame 262 | self.plot_label = ttk.Label(self.middle_frame) 263 | self.plot_label.pack(padx=10, pady=10) 264 | 265 | # Right Frame Static Text 266 | ttk.Label(self.right_frame, text="First Order Plus Dead Time Model").grid(row=0, column=0, columnspan=2, padx=10, pady=10) 267 | ttk.Label(self.right_frame, text="Model Gain: ").grid(row=1, column=0, padx=10, pady=10) 268 | ttk.Label(self.right_frame, text="Time Constant (seconds):").grid(row=2, column=0, padx=10, pady=10) 269 | ttk.Label(self.right_frame, text="Dead Time (seconds):").grid(row=3, column=0, padx=10, pady=10) 270 | ttk.Label(self.right_frame, text="Bias:").grid(row=4, column=0, padx=10, pady=10) 271 | # Right Frame Entry Boxes 272 | ttk.Spinbox(self.right_frame, from_=-1000.00, to=1000.00, increment=0.1, textvariable=self.model_gain, width=15).grid(row=1, column=1, padx=10, pady=10) 273 | ttk.Spinbox(self.right_frame, from_=1.00, to=1000.00, increment=0.1, textvariable=self.model_tc, width=15).grid(row=2, column=1, padx=10, pady=10) 274 | ttk.Spinbox(self.right_frame, from_=1.00, to=1000.00, increment=0.1, textvariable=self.model_dt, width=15).grid(row=3, column=1, padx=10, pady=10) 275 | ttk.Spinbox(self.right_frame, from_=-1000.00, to=1000.00, increment=0.1, textvariable=self.model_bias, width=15).grid(row=4, column=1, padx=10, pady=10) 276 | 277 | # Random noise between -0.2 and 0.2, same set used for each run as it's created once at runtime. 278 | self.minsize = 300 279 | self.maxsize = 18000 280 | self.noise = np.random.uniform(-0.2, 0.2, self.maxsize) 281 | 282 | # PID and Process Instantiation 283 | self.pid = PID_Controller() 284 | self.process_model = FOPDT_Model() 285 | self.sim_length = self.maxsize 286 | self.itae = 0 287 | # Create arrays to store the simulation results 288 | self.SP = np.zeros(self.maxsize) 289 | self.PV = np.zeros(self.maxsize) 290 | self.CV = np.zeros(self.maxsize) 291 | self.pterm = np.zeros(self.maxsize) 292 | self.iterm = np.zeros(self.maxsize) 293 | self.dterm = np.zeros(self.maxsize) 294 | # Create plot buffer and generate blank plot 295 | self.image_buffer = io.BytesIO() 296 | self.generate_plot() 297 | 298 | def generate_response(self): 299 | """ 300 | Generate the response of the PID controller and the FOPDT model. 301 | 302 | This method calculates the control values, process variable, and individual controller 303 | components based on the given model parameters and PID gains. It also calculates the 304 | Integral Time Weighted Average of the Error (ITAE) as a performance measure. 305 | """ 306 | try: 307 | # Find the size of the range needed 308 | calc_duration = int(self.model_dt.get() * 2 + self.model_tc.get() * 5) 309 | self.sim_length = min(max(calc_duration, self.minsize), self.maxsize - 1) 310 | 311 | # Defaults 312 | start_of_step = 10 313 | direction = "Direct" if self.model_gain.get() > 0 else "Reverse" 314 | 315 | # Update Process model 316 | self.process_model.change_params((self.model_gain.get(), self.model_tc.get(), self.model_dt.get(), self.model_bias.get())) 317 | self.process_model.work_CV = self.CV 318 | # Get PID ready 319 | self.pid.tunings = (self.kp.get(), self.ki.get(), self.kd.get()) 320 | self.pid.reset() 321 | # Set initial value 322 | self.PV[0] = self.model_bias.get() + self.noise[0] 323 | 324 | # Loop through timestamps 325 | for i in range(self.sim_length): 326 | # Adjust the Setpoint 327 | if i < start_of_step: 328 | self.SP[i] = self.model_bias.get() 329 | elif direction == "Direct": 330 | self.SP[i] = 60 + self.model_bias.get() if i < self.sim_length * 0.6 else 40 + self.model_bias.get() 331 | else: 332 | self.SP[i] = -60 + self.model_bias.get() if i < self.sim_length * 0.6 else -40 + self.model_bias.get() 333 | # Find current controller output 334 | self.CV[i] = self.pid(self.PV[i], self.SP[i], direction) 335 | # Find calculated PV 336 | self.PV[i + 1] = self.process_model.update(self.PV[i], [i, i + 1]) 337 | self.PV[i + 1] += self.noise[i] 338 | # Store individual terms 339 | self.pterm[i], self.iterm[i], self.dterm[i] = self.pid.components 340 | # Calculate Integral Time weighted Average of the Error (ITAE) 341 | self.itae = 0 if i < start_of_step else self.itae + (i - start_of_step) * abs(self.SP[i] - self.PV[i]) 342 | # Update the plot 343 | self.generate_plot() 344 | 345 | except Exception as e: 346 | messagebox.showerror("An Error Occurred", "Check Configuration: " + str(e)) 347 | 348 | def generate_plot(self): 349 | """ 350 | Generate the plot for the response of the PID controller and the FOPDT model. 351 | """ 352 | plt.figure() 353 | plt.subplot(2, 1, 1) 354 | plt.plot(self.SP[: self.sim_length], color="blue", linewidth=2, label="SP") 355 | plt.plot(self.CV[: self.sim_length], color="darkgreen", linewidth=2, label="CV") 356 | plt.plot(self.PV[: self.sim_length], color="red", linewidth=2, label="PV") 357 | plt.ylabel("Value") 358 | plt.suptitle("ITAE: %s" % round(self.itae / self.sim_length, 2)) 359 | plt.title("Kp:%s Ki:%s Kd:%s" % (self.kp.get(), self.ki.get(), self.kd.get()), fontsize=10) 360 | plt.legend(loc="best") 361 | 362 | # Individual components 363 | plt.subplot(2, 1, 2) 364 | plt.plot(self.pterm[: self.sim_length], color="lime", linewidth=2, label="P Term") 365 | plt.plot(self.iterm[: self.sim_length], color="orange", linewidth=2, label="I Term") 366 | plt.plot(self.dterm[: self.sim_length], color="purple", linewidth=2, label="D Term") 367 | plt.xlabel("Time [seconds]") 368 | plt.ylabel("Value") 369 | plt.legend(loc="best") 370 | plt.grid(True) 371 | plt.savefig(self.image_buffer, format="png") 372 | plt.close() 373 | 374 | # Convert plot to tkinter image 375 | img = Image.open(self.image_buffer) 376 | photo_img = ImageTk.PhotoImage(img) 377 | # Delete the existing plot 378 | self.plot_label.configure(image="") 379 | self.plot_label.image = "" 380 | # Add the new plot 381 | self.plot_label.configure(image=photo_img) 382 | self.plot_label.image = photo_img 383 | # Rewind the tape 384 | self.image_buffer.seek(0) 385 | 386 | 387 | if __name__ == "__main__": 388 | gui = PID_Simulator() 389 | gui.root.mainloop() 390 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PythonPID_Simulator 2 | 3 | **Direct action:** 4 | 5 | 6 | ![PythonPID_Simulator_Direct](https://github.com/user-attachments/assets/0bfa1119-7082-4d10-b947-74a15c6a8eee) 7 | 8 | 9 | --- 10 | 11 | **Reverse action:** 12 | 13 | 14 | ![PythonPID_Simulator_Reverse](https://github.com/user-attachments/assets/4c22120e-e29e-478e-b8e8-761d9914a316) 15 | 16 | 17 | --- 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib 2 | numpy 3 | ttkbootstrap 4 | scipy --------------------------------------------------------------------------------