├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── publish.yml ├── screenshots ├── logger.png ├── tuner.png ├── emulator.png └── simulator.png ├── requirements.txt ├── pypidtune ├── __init__.py ├── sim.py ├── common.py ├── process_emulator.py ├── tuner.py └── logger.py ├── LICENSE ├── setup.py ├── README.md └── docs └── prc.csv /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [PIDTuningIreland] 2 | -------------------------------------------------------------------------------- /screenshots/logger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/HEAD/screenshots/logger.png -------------------------------------------------------------------------------- /screenshots/tuner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/HEAD/screenshots/tuner.png -------------------------------------------------------------------------------- /screenshots/emulator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/HEAD/screenshots/emulator.png -------------------------------------------------------------------------------- /screenshots/simulator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/HEAD/screenshots/simulator.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | scipy>=1.8.0 2 | numpy>=1.22.1 3 | matplotlib>=3.5.1 4 | pylogix>=0.8.0 5 | asyncua>=0.9.92 6 | -------------------------------------------------------------------------------- /pypidtune/__init__.py: -------------------------------------------------------------------------------- 1 | from .process_emulator import ProcessEmulator 2 | from .logger import PIDLogger 3 | from .sim import PIDSimulator 4 | from .tuner import PIDTuner 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 PIDTuningIreland 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 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package to PyPI when a Release is Created 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | pypi-publish: 9 | name: Publish release to PyPI 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/p/pypidtune 14 | permissions: 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.x" 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel 26 | - name: Extract tag name 27 | run: | 28 | TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) 29 | echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV 30 | - name: Update version in setup.py 31 | run: | 32 | sed -i "s/{{VERSION_PLACEHOLDER}}/${{ env.TAG_NAME }}/g" setup.py 33 | - name: Build package 34 | run: | 35 | python setup.py sdist bdist_wheel 36 | - name: Publish package distributions to PyPI 37 | uses: pypa/gh-action-pypi-publish@release/v1 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import codecs 3 | 4 | 5 | def parse_requirements(filename): 6 | with open(filename, "r") as f: 7 | return [line.strip() for line in f if line.strip() and not line.startswith("#")] 8 | 9 | 10 | with codecs.open("README.md", encoding="utf-8") as fh: 11 | long_description = fh.read() 12 | 13 | setup( 14 | name="pypidtune", 15 | version="{{VERSION_PLACEHOLDER}}", 16 | license="MIT", 17 | license_files=("LICENSE",), 18 | author="PIDTuningIreland", 19 | author_email="pidtuningireland@gmail.com", 20 | description="PID tuner, logger, simulator and process emulator", 21 | long_description=long_description, 22 | long_description_content_type="text/markdown", 23 | packages=find_packages(), 24 | url="https://github.com/PIDTuningIreland/pypidtune", 25 | keywords="PID Tuner", 26 | classifiers=[ 27 | "Development Status :: 5 - Production/Stable", 28 | "Intended Audience :: Information Technology", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Operating System :: MacOS :: MacOS X", 35 | "Operating System :: Microsoft :: Windows", 36 | "Operating System :: POSIX :: Linux", 37 | ], 38 | install_requires=parse_requirements("requirements.txt"), 39 | ) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyPIDTune - Python PID Tuner 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/pypidtune?label=pypi%20package) 4 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/pypidtune) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pypidtune) 6 | ![GitHub repo size](https://img.shields.io/github/repo-size/PIDTuningIreland/pyPIDTune) 7 | ![PyPI - License](https://img.shields.io/pypi/l/pypidtune) 8 | 9 | 10 | To install: 11 | 12 | ``` 13 | pip install pypidtune 14 | ``` 15 | 16 | 17 | PID tuning in 4 Steps: 18 | ``` 19 | A-> Record a PRC using the PID Logger 20 | B-> Tune using the PID Tuner 21 | C-> Refine the tune using PID Simulator 22 | D-> Test the tune with FOPDT Process Emulator 23 | ``` 24 | 25 | 26 | # **PID Logger** 27 | 28 | To launch, use: 29 | ``` 30 | import pypidtune 31 | 32 | pid_logger = pypidtune.PIDLogger() 33 | pid_logger.root.mainloop() 34 | 35 | ``` 36 | 37 | 38 | ![Logger](https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/main/screenshots/logger.png) 39 | 40 | 41 | 42 | # **PID Simulator** 43 | 44 | To launch, use: 45 | ``` 46 | import pypidtune 47 | 48 | pid_simulator = pypidtune.PIDSimulator() 49 | pid_simulator.root.mainloop() 50 | 51 | ``` 52 | 53 | 54 | ![Simulator](https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/main/screenshots/simulator.png) 55 | 56 | 57 | 58 | # **Process Emulator** 59 | 60 | To launch, use: 61 | ``` 62 | import pypidtune 63 | 64 | pid_emulator = pypidtune.ProcessEmulator() 65 | pid_emulator.root.mainloop() 66 | 67 | ``` 68 | 69 | 70 | ![Emulator](https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/main/screenshots/emulator.png) 71 | 72 | 73 | --- 74 | 75 | 76 | # **PID Tuner** 77 | 78 | To launch, use: 79 | ``` 80 | import pypidtune 81 | 82 | pid_tuner = pypidtune.PIDTuner() 83 | pid_tuner.root.mainloop() 84 | 85 | ``` 86 | 87 | 88 | ![Tuner](https://raw.githubusercontent.com/PIDTuningIreland/pyPIDTune/main/screenshots/tuner.png) 89 | 90 | 91 | --- 92 | 93 | # **beoTune© Adapative Auto-Tuner** 94 | 95 | 96 | 97 | https://github.com/user-attachments/assets/46a9ba28-b4ca-4fb7-ad83-43ef1c42f0e6 98 | 99 | 100 | 101 | > Get in touch for more information. 102 | 103 | -------------------------------------------------------------------------------- /pypidtune/sim.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Updated and maintained by pidtuningireland@gmail.com 4 | Copyright 2024 pidtuningireland 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 | from tkinter import messagebox 19 | from PIL import Image, ImageTk 20 | import ttkbootstrap as ttk 21 | import numpy as np 22 | import matplotlib.pyplot as plt 23 | import common 24 | 25 | 26 | class PIDSimulator: 27 | """ 28 | Python PID Simulator Application. 29 | 30 | This application provides a Graphical User Interface (GUI) to simulate and visualize the response 31 | of a Proportional-Integral-Derivative (PID) controller using the First Order Plus Dead Time (FOPDT) model. 32 | """ 33 | 34 | def __init__(self, **kwargs) -> None: 35 | """ 36 | Parameters: 37 | ----------- 38 | `**kwargs`: dict 39 | A dictionary of optional parameters for configuration. 40 | 41 | Keyword Arguments: 42 | ------------------ 43 | - `pid_form` : str, optional 44 | 45 | The form of PID controller. Must be 'independent' or 'dependent'. Defaults to 'independent'. 46 | 47 | - `pid_time_units` : str, optional 48 | 49 | The time units for the PID controller. Must be 'seconds' or 'minutes'. Defaults to 'seconds'. 50 | 51 | - `init_model_gain` : float, optional 52 | 53 | Initial gain of the process model. Defaults to 2.5. 54 | 55 | - `init_model_tc` : float, optional 56 | 57 | Initial time constant of the process model. Defaults to 75.5 seconds. 58 | 59 | - `init_model_dt` : float, optional 60 | 61 | Initial dead time of the process model. Defaults to 10.5 seconds. 62 | 63 | - `init_model_bias` : float, optional 64 | 65 | Initial bias of the process model. Defaults to 13.5. 66 | """ 67 | self.pid_form = kwargs.get("pid_form", "independent") 68 | self.pid_time_units = kwargs.get("pid_time_units", "seconds") 69 | if self.pid_form not in ["independent", "dependent"]: 70 | raise ValueError("pid_form must be 'independent' or 'dependent'") 71 | if self.pid_time_units not in ["minutes", "seconds"]: 72 | raise ValueError("pid_time_units must be 'minutes' or 'seconds'") 73 | 74 | try: 75 | init_model_gain = float(kwargs.get("init_model_gain", common.INITIAL_MODEL_GAIN)) 76 | init_model_tc = float(kwargs.get("init_model_tc", common.INITIAL_MODEL_TC)) 77 | init_model_dt = float(kwargs.get("init_model_dt", common.INITIAL_MODEL_DT)) 78 | init_model_bias = float(kwargs.get("init_model_bias", common.INITIAL_MODEL_BIAS)) 79 | init_kp = float(kwargs.get("init_kp", common.INITIAL_KP)) 80 | init_ki = float(kwargs.get("init_ki", common.INITIAL_KI)) 81 | init_kd = float(kwargs.get("init_kd", common.INITIAL_KD)) 82 | except ValueError: 83 | raise ValueError("init_model_gain, init_model_tc, init_model_dt, init_model_bias, init_kp, init_ki, and init_kd must all be floats") 84 | 85 | plt.style.use("bmh") 86 | self.root = ttk.Window() 87 | self.root.title("PID Simulator - PID Tuning Ireland©") 88 | self.root.state("zoomed") 89 | # Adjust theme to suit 90 | style = ttk.Style(theme="yeti") 91 | style.theme.colors.bg = "#c0c0c0" 92 | style.configure(".", font=("Helvetica", 12)) 93 | # Add frames 94 | self.master_frame = ttk.Frame(self.root) 95 | self.master_frame.pack(fill="both", expand=True) 96 | self.bottom_frame = ttk.Frame(self.master_frame) 97 | self.bottom_frame.pack(side="bottom", fill="both", expand=True) 98 | self.left_frame = ttk.LabelFrame(self.master_frame, text=" Controller ", bootstyle="Success") 99 | self.left_frame.pack(side="left", fill="both", expand=True, padx=10, pady=10) 100 | self.middle_frame = ttk.LabelFrame(self.master_frame, text=" Result ", bootstyle="Light") 101 | self.middle_frame.pack(side="left", fill="both", expand=True, padx=10, pady=10) 102 | self.right_frame = ttk.LabelFrame(self.master_frame, text=" Process ", bootstyle="Warning") 103 | self.right_frame.pack(side="right", fill="both", expand=True, padx=10, pady=10) 104 | 105 | # GUI Variables 106 | self.model_gain = ttk.DoubleVar(value=init_model_gain) 107 | self.model_tc = ttk.DoubleVar(value=init_model_tc) 108 | self.model_dt = ttk.DoubleVar(value=init_model_dt) 109 | self.model_bias = ttk.DoubleVar(value=init_model_bias) 110 | self.kp = ttk.DoubleVar(value=init_kp) 111 | self.ki = ttk.DoubleVar(value=init_ki) 112 | self.kd = ttk.DoubleVar(value=init_kd) 113 | 114 | # Left Frame Static Text 115 | ttk.Label(self.left_frame, text="PID Gains").grid(row=0, column=0, padx=10, pady=10, columnspan=2) 116 | if self.pid_form == "independent" and self.pid_time_units == "seconds": 117 | kp_unit_text = "Kp:" 118 | ki_unit_text = "Ki (1/sec):" 119 | kd_unit_text = "Kd (sec):" 120 | elif self.pid_form == "independent" and self.pid_time_units == "minutes": 121 | self.kp.set(init_kp) 122 | self.ki.set(init_ki * 60) 123 | self.kd.set(round(init_kd / 60, 4)) 124 | kp_unit_text = "Kp:" 125 | ki_unit_text = "Ki (1/min):" 126 | kd_unit_text = "Kd (min):" 127 | elif self.pid_form == "dependent" and self.pid_time_units == "seconds": 128 | self.kp.set(init_kp) 129 | self.ki.set(round(init_kp / init_ki, 4)) 130 | self.kd.set(round(init_kd / init_kp, 4)) 131 | kp_unit_text = "Kc:" 132 | ki_unit_text = "Ti (sec/repeat):" 133 | kd_unit_text = "Td (sec):" 134 | elif self.pid_form == "dependent" and self.pid_time_units == "minutes": 135 | self.kp.set(init_kp) 136 | self.ki.set(round((init_kp / init_ki) / 60, 4)) 137 | self.kd.set(round((init_kd / init_kp) / 60, 4)) 138 | kp_unit_text = "Kc:" 139 | ki_unit_text = "Ti (min/repeat):" 140 | kd_unit_text = "Td (min):" 141 | 142 | ttk.Label(self.left_frame, text=kp_unit_text).grid(row=1, column=0, padx=10, pady=10) 143 | ttk.Label(self.left_frame, text=ki_unit_text).grid(row=2, column=0, padx=10, pady=10) 144 | ttk.Label(self.left_frame, text=kd_unit_text).grid(row=3, column=0, padx=10, pady=10) 145 | # Left Frame Entry Boxes 146 | 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) 147 | ttk.Spinbox(self.left_frame, from_=0, to=10000.00, increment=0.01, textvariable=self.ki, width=15).grid(row=2, column=1, padx=10, pady=10) 148 | ttk.Spinbox(self.left_frame, from_=-10000.00, to=10000.00, increment=0.01, textvariable=self.kd, width=15).grid(row=3, column=1, padx=10, pady=10) 149 | 150 | # Button 151 | button_refresh = ttk.Button(self.left_frame, text="Refresh", command=self.generate_response, bootstyle="Success") 152 | button_refresh.grid(row=5, column=0, columnspan=2, sticky="NESW", padx=10, pady=10) 153 | 154 | # Middle Frame 155 | self.plot_label = ttk.Label(self.middle_frame) 156 | self.plot_label.pack(padx=10, pady=10) 157 | 158 | # Right Frame Static Text 159 | ttk.Label(self.right_frame, text="First Order Plus Dead Time Model").grid(row=0, column=0, columnspan=2, padx=10, pady=10) 160 | ttk.Label(self.right_frame, text="Model Gain: ").grid(row=1, column=0, padx=10, pady=10) 161 | ttk.Label(self.right_frame, text="Time Constant (seconds):").grid(row=2, column=0, padx=10, pady=10) 162 | ttk.Label(self.right_frame, text="Dead Time (seconds):").grid(row=3, column=0, padx=10, pady=10) 163 | ttk.Label(self.right_frame, text="Bias:").grid(row=4, column=0, padx=10, pady=10) 164 | # Right Frame Entry Boxes 165 | 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) 166 | 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) 167 | 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) 168 | 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) 169 | 170 | # PID and Process Instantiation 171 | self.pid = common.PIDController() 172 | self.process_model = common.FOPDTModel() 173 | self.SP = np.zeros(0) 174 | self.CV = np.zeros(0) 175 | self.PV = np.zeros(0) 176 | self.pterm = np.zeros(0) 177 | self.iterm = np.zeros(0) 178 | self.dterm = np.zeros(0) 179 | self.itae = 0 180 | # Create plot buffer and generate blank plot 181 | self.image_buffer = io.BytesIO() 182 | self.generate_plot() 183 | 184 | def generate_response(self) -> None: 185 | """ 186 | Generate the response of the PID controller and the FOPDT model. 187 | 188 | This method calculates the control values, process variable, and individual controller 189 | components based on the given model parameters and PID gains. 190 | It also calculates the Integral Time Weighted Average of the Error (ITAE) as a performance measure. 191 | """ 192 | try: 193 | model_params = (self.model_gain.get(), self.model_tc.get(), self.model_dt.get(), self.model_bias.get()) 194 | sim_gains = common.convert_gains_for_sim(gains=(self.kp.get(), self.ki.get(), self.kd.get()), pid_form=self.pid_form, pid_time_units=self.pid_time_units) 195 | plot_data = common.calculate_pid_response(model_params=model_params, tune_values=sim_gains) 196 | # Create arrays to store the simulation results 197 | self.SP = plot_data[0] 198 | self.CV = plot_data[1] 199 | self.PV = plot_data[2] 200 | self.pterm = plot_data[3] 201 | self.iterm = plot_data[4] 202 | self.dterm = plot_data[5] 203 | self.itae = plot_data[6] 204 | # Update the plot 205 | self.generate_plot() 206 | 207 | except Exception as e: 208 | messagebox.showerror("An Error Occurred", "Check Configuration: " + str(e)) 209 | 210 | def generate_plot(self) -> None: 211 | """ 212 | Generate the plot for the response of the PID controller and the FOPDT model. 213 | """ 214 | plt.figure() 215 | plt.subplot(2, 1, 1) 216 | plt.plot(self.SP, color="goldenrod", linewidth=2, label="SP") 217 | plt.plot(self.CV, color="darkgreen", linewidth=2, label="CV") 218 | plt.plot(self.PV, color="blue", linewidth=2, label="PV") 219 | plt.ylabel("Value") 220 | plt.suptitle(f"ITAE: {round(self.itae, 2)}") 221 | if self.pid_form == "independent": 222 | title = f"Kp: {self.kp.get()} Ki: {self.ki.get()} Kd: {self.kd.get()}" 223 | else: 224 | title = f"Kc: {self.kp.get()} Ti: {self.ki.get()} Td: {self.kd.get()}" 225 | plt.title(title, fontsize=10) 226 | plt.legend(loc="best") 227 | 228 | # Individual components 229 | plt.subplot(2, 1, 2) 230 | plt.plot(self.pterm, color="lime", linewidth=2, label="P Term") 231 | plt.plot(self.iterm, color="orange", linewidth=2, label="I Term") 232 | plt.plot(self.dterm, color="purple", linewidth=2, label="D Term") 233 | plt.xlabel("Time (seconds)") 234 | plt.ylabel("Value") 235 | plt.legend(loc="best") 236 | plt.grid(True) 237 | plt.savefig(self.image_buffer, format="png") 238 | plt.close() 239 | 240 | # Convert plot to tkinter image 241 | img = Image.open(self.image_buffer) 242 | photo_img = ImageTk.PhotoImage(img) 243 | # Delete the existing plot 244 | self.plot_label.configure(image="") 245 | self.plot_label.image = "" 246 | # Add the new plot 247 | self.plot_label.configure(image=photo_img) 248 | self.plot_label.image = photo_img 249 | # Rewind the tape 250 | self.image_buffer.seek(0) 251 | 252 | 253 | if __name__ == "__main__": 254 | sim_app = PIDSimulator() 255 | sim_app.root.mainloop() 256 | -------------------------------------------------------------------------------- /pypidtune/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Updated and maintained by pidtuningireland@gmail.com 4 | Copyright 2024 pidtuningireland 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 time 18 | import threading 19 | from typing import Callable, Tuple, List, Optional 20 | from scipy.integrate import odeint 21 | import numpy as np 22 | 23 | INITIAL_MODEL_GAIN = 2.5 24 | INITIAL_MODEL_TC = 75.5 25 | INITIAL_MODEL_DT = 10.5 26 | INITIAL_MODEL_BIAS = 13.5 27 | INITIAL_KP = 1.9 28 | INITIAL_KI = 0.1 29 | INITIAL_KD = 1.1 30 | INITIAL_LOGIX_IP_ADDRESS = "192.168.1.100" 31 | INITIAL_LOGIX_SLOT = 2 32 | INITIAL_OPC_ADDRESS = "opc.tcp://192.168.1.100:49320" 33 | INITIAL_SP_TAG = "PID_SP" 34 | INITIAL_PV_TAG = "PID_PV" 35 | INITIAL_CV_TAG = "PID_CV" 36 | MIN_PID_SIM_DURATION = 300 37 | MAX_PID_SIM_DURATION = 18000 38 | 39 | 40 | class PeriodicInterval(threading.Thread): 41 | """ 42 | A class for running a task function periodically at a specified interval. 43 | 44 | This class inherits from threading.Thread and runs the specified task function 45 | periodically with a given period. 46 | 47 | Attributes: 48 | - task_function (Callable[[], None]): The task function to be executed periodically. 49 | - period (float): The period in seconds between consecutive executions of the task function. 50 | - i (int): A counter to keep track of the number of periods elapsed. 51 | - t0 (float): The starting time of the first period. 52 | - stop_event (threading.Event): An event to control stopping the thread. 53 | - locker (threading.Lock): A lock to ensure thread-safe execution of the task function. 54 | """ 55 | 56 | def __init__(self, task_function: Callable[[], None], period: float) -> None: 57 | """ 58 | Initialize the PeriodicInterval thread. 59 | """ 60 | super().__init__() 61 | self.daemon = True 62 | self.task_function: Callable[[], None] = task_function 63 | self.period: float = period 64 | self.i: int = 0 65 | self.t0: float = time.time() 66 | self.stop_event: threading.Event = threading.Event() 67 | self.locker: threading.Lock = threading.Lock() 68 | self.start() 69 | 70 | def sleep(self) -> None: 71 | """ 72 | Sleep for the remaining time to meet the specified period. 73 | """ 74 | self.i += 1 75 | delta: float = self.t0 + self.period * self.i - time.time() 76 | if delta > 0: 77 | time.sleep(delta) 78 | 79 | def run(self) -> None: 80 | """ 81 | Start the thread and execute the task_function periodically. 82 | """ 83 | while not self.stop_event.is_set(): 84 | with self.locker: 85 | self.task_function() 86 | self.sleep() 87 | 88 | def stop(self) -> None: 89 | """ 90 | Set the stop event to terminate the periodic task execution. 91 | """ 92 | self.stop_event.set() 93 | 94 | 95 | class PIDController: 96 | """ 97 | Proportional-Integral-Derivative (PID) controller. 98 | 99 | Attributes: 100 | - Kp (float): Proportional gain. 101 | - Ki (float): Integral gain. 102 | - Kd (float): Derivative gain. 103 | - setpoint (float): Target setpoint for the controller. 104 | - _min_output (float): Minimum allowed controller output value. 105 | - _max_output (float): Maximum allowed controller output value. 106 | - _proportional (float): Proportional term value. 107 | - _integral (float): Integral term value. 108 | - _derivative (float): Derivative term value. 109 | - output_limits (Tuple[float, float]): Tuple containing the minimum and maximum allowed controller output values. 110 | - _last_eD (float): The previous error value used for derivative calculation. 111 | - _lastCV (float): The previous controller output value. 112 | - _d_init (int): A flag to indicate whether the derivative term has been initialized. 113 | """ 114 | 115 | def __init__(self) -> None: 116 | """ 117 | Initialize the PID controller with default values. 118 | """ 119 | self.Kp: float = 1 120 | self.Ki: float = 0.1 121 | self.Kd: float = 0.01 122 | self.setpoint: float = 50 123 | self._min_output: float = 0 124 | self._max_output: float = 100 125 | self._proportional: float = 0 126 | self._integral: float = 0 127 | self._derivative: float = 0 128 | self.output_limits: Tuple[float, float] = (0, 100) 129 | self._last_eD: float = 0 130 | self._lastCV: float = 0 131 | self._d_init: int = 0 132 | self.reset() 133 | 134 | def __call__(self, PV: float = 0, SP: float = 0, direction: str = "Direct") -> float: 135 | """ 136 | Calculate the control value (CV) based on the process variable (PV) and the setpoint (SP). 137 | """ 138 | # P term 139 | if direction == "Direct": 140 | e: float = SP - PV 141 | else: 142 | e = PV - SP 143 | self._proportional = self.Kp * e 144 | 145 | # I Term 146 | if 0 < self._lastCV < 100: 147 | self._integral += self.Ki * e 148 | # Allow I Term to change when Kp is set to Zero 149 | if self.Kp == 0 and self._lastCV == 100 and self.Ki * e < 0: 150 | self._integral += self.Ki * e 151 | if self.Kp == 0 and self._lastCV == 0 and self.Ki * e > 0: 152 | self._integral += self.Ki * e 153 | 154 | # D term 155 | eD: float = -PV 156 | self._derivative = self.Kd * (eD - self._last_eD) 157 | 158 | # init D term 159 | if self._d_init == 0: 160 | self._derivative = 0 161 | self._d_init = 1 162 | 163 | # Controller Output 164 | CV: float = self._proportional + self._integral + self._derivative 165 | CV = self._clamp(CV, self.output_limits) 166 | 167 | # update stored data for next iteration 168 | self._last_eD = eD 169 | self._lastCV = CV 170 | return CV 171 | 172 | @property 173 | def components(self) -> Tuple[float, float, float]: 174 | """ 175 | Get the individual components of the controller output. 176 | """ 177 | return self._proportional, self._integral, self._derivative 178 | 179 | @property 180 | def tunings(self) -> Tuple[float, float, float]: 181 | """ 182 | Get the current PID tuning values (Kp, Ki, and Kd). 183 | """ 184 | return self.Kp, self.Ki, self.Kd 185 | 186 | @tunings.setter 187 | def tunings(self, tunings: Tuple[float, float, float]) -> None: 188 | """ 189 | Set new PID tuning values (Kp, Ki, and Kd). 190 | """ 191 | self.Kp, self.Ki, self.Kd = tunings 192 | 193 | @property 194 | def output_limits(self) -> Tuple[float, float]: 195 | """ 196 | Get the current output limits (minimum and maximum allowed controller output values). 197 | """ 198 | return self._min_output, self._max_output 199 | 200 | @output_limits.setter 201 | def output_limits(self, limits: Optional[Tuple[float, float]]) -> None: 202 | """ 203 | Set new output limits (minimum and maximum allowed controller output values). 204 | """ 205 | if limits is None: 206 | self._min_output, self._max_output = 0, 100 207 | return 208 | min_output, max_output = limits 209 | self._min_output = min_output 210 | self._max_output = max_output 211 | self._integral = self._clamp(self._integral, self.output_limits) 212 | 213 | def reset(self) -> None: 214 | """ 215 | Reset the controller values to their initial state. 216 | """ 217 | self._proportional = 0 218 | self._integral = 0 219 | self._derivative = 0 220 | self._integral = self._clamp(self._integral, self.output_limits) 221 | self._last_eD = 0 222 | self._d_init = 0 223 | self._lastCV = 0 224 | 225 | def _clamp(self, value: float, limits: Tuple[float, float]) -> float: 226 | """ 227 | Clamp the given value between the specified limits. 228 | """ 229 | lower, upper = limits 230 | if value is None: 231 | return lower 232 | value = min(value, upper) 233 | value = max(value, lower) 234 | return value 235 | 236 | 237 | class FOPDTModel: 238 | """ 239 | First Order Plus Dead Time (FOPDT) Model. 240 | 241 | This class models a process with first-order dynamics and a time delay (dead time). 242 | 243 | Parameters: 244 | - Gain (float): The gain of the process. 245 | - Time_Constant (float): The time constant of the process. 246 | - Dead_Time (float): The dead time (time delay) of the process. 247 | - Bias (float): The bias value of the process. 248 | - stored_cv (List[float]): List to store control values over time. 249 | """ 250 | 251 | def __init__(self) -> None: 252 | """ 253 | Initialize the FOPDTModel with default parameters and an empty list for control values (CV). 254 | 255 | Attributes: 256 | - stored_cv (List[float]): List to store control values over time. 257 | - Gain (float): Gain of the process. 258 | - Time_Constant (float): Time constant of the process. 259 | - Dead_Time (float): Dead time (time delay) of the process. 260 | - Bias (float): Bias value of the process. 261 | """ 262 | self.stored_cv: List[float] = [] 263 | self.Gain: float = 1.0 264 | self.Time_Constant: float = 1.0 265 | self.Dead_Time: float = 1.0 266 | self.Bias: float = 0.0 267 | 268 | def change_params(self, data: Tuple[float, float, float, float]) -> None: 269 | """ 270 | Update the model parameters with new values. 271 | """ 272 | self.Gain, self.Time_Constant, self.Dead_Time, self.Bias = data 273 | 274 | def _calc(self, init_pv: float, ts: float) -> float: 275 | """ 276 | Calculate the change in the process variable (PV) over time. 277 | """ 278 | if (ts - self.Dead_Time) <= 0: 279 | um: float = 0 280 | elif int(ts - self.Dead_Time) >= len(self.stored_cv): 281 | um = self.stored_cv[-1] 282 | else: 283 | um = self.stored_cv[int(ts - self.Dead_Time)] 284 | dydt: float = (-(init_pv - self.Bias) + self.Gain * um) / self.Time_Constant 285 | return dydt 286 | 287 | def update(self, init_pv: float, ts: List[float]) -> float: 288 | """ 289 | Update the process variable (PV) using the FOPDT model. 290 | """ 291 | y: np.ndarray = odeint(self._calc, init_pv, ts) 292 | return float(y[-1]) 293 | 294 | 295 | def convert_gains_for_sim(gains, pid_form="independent", pid_time_units="seconds") -> tuple: 296 | """ 297 | Convert the PID Gains to be used in simulation. 298 | 299 | Parameters: 300 | - gains (tuple): A tuple containing the original PID gains (Kp, Ki, Kd). 301 | - pid_form (str): A string indicating the form of PID controller ('independent' or 'dependent'). 302 | - time_units (str): A string indicating the time units ('seconds' or 'minutes'). 303 | 304 | Returns: 305 | - tuple: A tuple containing the converted PID gains (Kp, Ki, Kd) appropriate for the simulation. 306 | """ 307 | calc_p, calc_i, calc_d = gains 308 | if pid_form == "independent" and pid_time_units == "seconds": 309 | return gains 310 | 311 | elif pid_form == "independent" and pid_time_units == "minutes": 312 | kp_sec = calc_p 313 | ki_sec = calc_i / 60.0 314 | kd_sec = calc_d * 60.0 315 | return (kp_sec, ki_sec, kd_sec) 316 | 317 | elif pid_form == "dependent" and pid_time_units == "seconds": 318 | kp_sec = calc_p 319 | ki_sec = calc_p / calc_i if calc_i != 0 else 0.0 320 | kd_sec = calc_d * calc_p 321 | return (kp_sec, ki_sec, kd_sec) 322 | 323 | elif pid_form == "dependent" and pid_time_units == "minutes": 324 | kp_sec = calc_p 325 | ki_sec = (calc_p / calc_i if calc_i != 0 else 0.0) / 60 326 | kd_sec = (calc_d * calc_p) * 60 327 | return (kp_sec, ki_sec, kd_sec) 328 | 329 | 330 | def calculate_pid_response(model_params, tune_values) -> tuple: 331 | """ 332 | Simulate the PID response. 333 | 334 | Parameters: 335 | - model_params (tuple): A tuple containing the process model parameters (model_gain, model_tc, model_dt, model_bias). 336 | - tune_values (tuple): A tuple containing the PID tuning values (Kp, Ki, Kd). 337 | 338 | Returns: 339 | - tuple: A tuple containing arrays of the simulation results and ITAE value. 340 | - sim_sp_array (np.array): The setpoint values array. 341 | - sim_cv_array (np.array): The control variable values array. 342 | - sim_pv_array (np.array): The process variable values array. 343 | - p_term_array (np.array): The proportional term values array. 344 | - i_term_array (np.array): The integral term values array. 345 | - d_term_array (np.array): The derivative term values array. 346 | - itae (float): The Integral of Time-weighted Absolute Error (ITAE) value. 347 | """ 348 | # Unpack 349 | model_gain, model_tc, model_dt, model_bias = model_params 350 | kp, ki, kd = tune_values 351 | 352 | # Find the size of the range needed 353 | calc_duration = int(model_dt * 2 + model_tc * 5) 354 | sim_length = min(max(calc_duration, MIN_PID_SIM_DURATION), MAX_PID_SIM_DURATION - 1) 355 | 356 | # Setup arrays 357 | sim_sp_array = np.zeros(sim_length) 358 | sim_pv_array = np.zeros(sim_length) 359 | sim_cv_array = np.zeros(sim_length) 360 | p_term_array = np.zeros(sim_length) 361 | i_term_array = np.zeros(sim_length) 362 | d_term_array = np.zeros(sim_length) 363 | np.random.seed(0) 364 | noise = np.random.uniform(-0.1, 0.1, sim_length) 365 | 366 | process_model = FOPDTModel() 367 | pid = PIDController() 368 | process_model.change_params((model_gain, model_tc, model_dt, model_bias)) 369 | process_model.stored_cv = sim_cv_array 370 | 371 | # Defaults 372 | start_of_step = 10 373 | direction = "Direct" if model_gain > 0 else "Reverse" 374 | 375 | # Update Process model 376 | sim_gains = convert_gains_for_sim(gains=(kp, ki, kd)) 377 | # Get PID ready 378 | pid.tunings = sim_gains 379 | pid.reset() 380 | # Set initial value 381 | sim_pv_array[0] = model_bias + noise[0] 382 | 383 | # Loop through timestamps 384 | for i in range(sim_length - 1): 385 | # Adjust the Setpoint 386 | if i < start_of_step: 387 | sim_sp_array[i] = model_bias 388 | elif direction == "Direct": 389 | sim_sp_array[i] = 60 + model_bias if i < sim_length * 0.6 else 40 + model_bias 390 | else: 391 | sim_sp_array[i] = -60 + model_bias if i < sim_length * 0.6 else -40 + model_bias 392 | # Find current controller output 393 | sim_cv_array[i] = pid(sim_pv_array[i], sim_sp_array[i], direction) 394 | # Find calculated sim_pv_array 395 | sim_pv_array[i + 1] = process_model.update(sim_pv_array[i], [i, i + 1]) 396 | sim_pv_array[i + 1] += noise[i] 397 | # Store individual terms 398 | p_term_array[i], i_term_array[i], d_term_array[i] = pid.components 399 | # Calculate Integral Time weighted Average of the Error (ITAE) 400 | itae = 0 if i < start_of_step else itae + (i - start_of_step) * abs(sim_sp_array[i] - sim_pv_array[i]) 401 | 402 | return sim_sp_array[:-1], sim_cv_array[:-1], sim_pv_array[:-1], p_term_array[:-1], i_term_array[:-1], d_term_array[:-1], itae / sim_length 403 | 404 | 405 | def find_pid_gains(model_params) -> dict[str, dict[str, float]]: 406 | """ 407 | Calculate the PID gains using different tuning methods. 408 | 409 | Parameters: 410 | - model_params (tuple): A tuple containing the process model parameters (model_gain, model_tc, model_dt, model_bias). 411 | 412 | Returns: 413 | - dict: A dictionary containing PID gains for different tuning methods. 414 | - 'chr': Gains using the CHR tuning method: 415 | - 'chr_kp': Proportional gain (float). 416 | - 'chr_ki': Integral gain (float). 417 | - 'chr_kd': Derivative gain (float). 418 | - 'imc': Gains using the IMC tuning method: 419 | - 'imc_kp': Proportional gain (float). 420 | - 'imc_ki': Integral gain (float). 421 | - 'imc_kd': Derivative gain (float). 422 | - 'aimc': Gains using the AIMC tuning method: 423 | - 'aimc_kp': Proportional gain (float). 424 | - 'aimc_ki': Integral gain (float). 425 | - 'aimc_kd': Derivative gain (float). 426 | """ 427 | model_gain, model_tc, model_dt, model_bias = model_params 428 | if model_tc <= 0: 429 | model_tc = 0.1 430 | if model_dt <= 0: 431 | model_dt = 0.1 432 | # CHR Kp 433 | num = 0.6 * model_tc 434 | den = abs(model_gain) * model_dt 435 | chr_kp = num / den 436 | # CHR Ki 437 | ti = 1 * model_tc 438 | chr_ki = chr_kp / ti 439 | # CHR Kd 440 | td = 0.5 * model_dt 441 | chr_kd = chr_kp * td 442 | 443 | # IMC Kp 444 | lmda = 2.1 * model_dt 445 | num = model_tc + 0.5 * model_dt 446 | den = abs(model_gain) * (lmda) 447 | imc_kp = 1.1 * (num / den) 448 | # IMC Ki 449 | ti = model_tc + 0.5 * model_dt 450 | imc_ki = imc_kp / ti 451 | # IMC Kd 452 | num = model_tc * model_dt 453 | den = 2 * model_tc + model_dt 454 | td = num / den 455 | imc_kd = 1.1 * (td * imc_kp) 456 | 457 | # AIMC Kp 458 | L = max(0.1 * model_tc, 0.8 * model_dt) 459 | num = model_tc + 0.5 * model_dt 460 | den = abs(model_gain) * (L + 0.5 * model_dt) 461 | aimc_kp = num / den 462 | # AIMC Ki 463 | ti = model_tc + 0.5 * model_dt 464 | aimc_ki = aimc_kp / ti 465 | # AIMC Kd 466 | num = model_tc * model_dt 467 | den = 2 * model_tc + model_dt 468 | td = num / den 469 | aimc_kd = td * aimc_kp 470 | 471 | return { 472 | "chr": {"kp": round(chr_kp, 4), "ki": round(chr_ki, 4), "kd": round(chr_kd, 4)}, 473 | "imc": {"kp": round(imc_kp, 4), "ki": round(imc_ki, 4), "kd": round(imc_kd, 4)}, 474 | "aimc": {"kp": round(aimc_kp, 4), "ki": round(aimc_ki, 4), "kd": round(aimc_kd, 4)}, 475 | } 476 | 477 | 478 | def find_pi_gains(model_params) -> dict[str, dict[str, float]]: 479 | """ 480 | Calculate the PID gains using different tuning methods. 481 | 482 | Parameters: 483 | - model_params (tuple): A tuple containing the process model parameters (model_gain, model_tc, model_dt, model_bias). 484 | 485 | Returns: 486 | - dict: A dictionary containing PID gains for different tuning methods. 487 | - 'chr': Gains using the CHR tuning method: 488 | - 'chr_kp': Proportional gain (float). 489 | - 'chr_ki': Integral gain (float). 490 | - 'chr_kd': Derivative gain (float). 491 | - 'imc': Gains using the IMC tuning method: 492 | - 'imc_kp': Proportional gain (float). 493 | - 'imc_ki': Integral gain (float). 494 | - 'imc_kd': Derivative gain (float). 495 | - 'aimc': Gains using the AIMC tuning method: 496 | - 'aimc_kp': Proportional gain (float). 497 | - 'aimc_ki': Integral gain (float). 498 | - 'aimc_kd': Derivative gain (float). 499 | """ 500 | model_gain, model_tc, model_dt, model_bias = model_params 501 | if model_tc <= 0: 502 | model_tc = 0.1 503 | if model_dt <= 0: 504 | model_dt = 0.1 505 | # CHR Kp 506 | num = 0.35 * model_tc 507 | den = abs(model_gain) * model_dt 508 | chr_kp = num / den 509 | # CHR Ki 510 | ti = 1.2 * model_tc 511 | chr_ki = chr_kp / ti 512 | # CHR Kd 513 | chr_kd = 0 514 | 515 | # IMC Kp 516 | lmda = 2.1 * model_dt 517 | num = model_tc + 0.5 * model_dt 518 | den = abs(model_gain) * (lmda) 519 | imc_kp = num / den 520 | # IMC Ki 521 | ti = model_tc + 0.5 * model_dt 522 | imc_ki = imc_kp / ti 523 | # IMC Kd 524 | imc_kd = 0 525 | 526 | # AIMC Kp 527 | L = max(0.1 * model_tc, 0.8 * model_dt) 528 | aimc_kp = model_tc / (abs(model_gain) * (model_dt + L)) 529 | # AIMC Ki 530 | ti = model_tc / (1.03 - 0.165 * (model_dt / model_tc)) 531 | aimc_ki = aimc_kp / ti 532 | # AIMC Kd 533 | aimc_kd = 0 534 | 535 | return { 536 | "chr": {"kp": round(chr_kp, 4), "ki": round(chr_ki, 4), "kd": round(chr_kd, 4)}, 537 | "imc": {"kp": round(imc_kp, 4), "ki": round(imc_ki, 4), "kd": round(imc_kd, 4)}, 538 | "aimc": {"kp": round(aimc_kp, 4), "ki": round(aimc_ki, 4), "kd": round(aimc_kd, 4)}, 539 | } 540 | -------------------------------------------------------------------------------- /pypidtune/process_emulator.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Updated and maintained by pidtuningireland@gmail.com 4 | Copyright 2024 pidtuningireland 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 time 18 | import random 19 | import threading 20 | from datetime import datetime 21 | import numpy as np 22 | import ttkbootstrap as ttk 23 | import matplotlib.pyplot as plt 24 | from matplotlib import animation 25 | from pylogix import PLC 26 | from asyncua.sync import Client, ua 27 | import common 28 | 29 | SAMPLE_RATE = 0.1 30 | 31 | 32 | class ProcessEmulator: 33 | """ 34 | ProcessEmulator is responsible for setting up PLC communication and initializing the GUI 35 | for emulating a FOPDT process. 36 | """ 37 | 38 | def __init__(self, **kwargs): 39 | """ 40 | Parameters: 41 | ----------- 42 | `**kwargs`: dict 43 | A dictionary of optional parameters for configuration. 44 | 45 | Keyword Arguments: 46 | ------------------ 47 | - `comm_type` : str, optional 48 | 49 | The type of communication to use, either 'logix' or 'opc'. Defaults to 'logix'. 50 | 51 | - `init_logix_ip_address` : str, optional 52 | 53 | The initial IP address for Logix communication. Defaults to a predefined constant. 54 | 55 | - `init_logix_slot` : int, optional 56 | 57 | The initial slot for Logix communication. Defaults to a predefined constant. 58 | 59 | - `init_opc_address` : str, optional 60 | 61 | The initial address for OPC communication. Defaults to a predefined constant. 62 | 63 | - `init_sp_tag` : str, optional 64 | 65 | The initial setpoint tag for the PID controller. Defaults to a predefined constant. 66 | 67 | - `init_pv_tag` : str, optional 68 | 69 | The initial process variable tag for the PID controller. Defaults to a predefined constant. 70 | 71 | - `init_cv_tag` : str, optional 72 | 73 | The initial control variable tag for the PID controller. Defaults to a predefined constant. 74 | 75 | - `init_model_gain` : float, optional 76 | 77 | Initial gain of the process model. Default is 2.5. 78 | 79 | - `init_model_tc` : float, optional 80 | 81 | Initial time constant of the process model. Default is 75.5 seconds. 82 | 83 | - `init_model_dt` : float, optional 84 | 85 | Initial dead time of the process model. Default is 10.5 seconds. 86 | 87 | - `init_model_bias` : float, optional 88 | 89 | Initial bias of the process model. Default is 13.5. 90 | """ 91 | # Initialize instance attributes based on provided or default values 92 | self.comm_type = kwargs.get("comm_type", "logix") 93 | self.init_logix_ip_address = kwargs.get("init_logix_ip_address", common.INITIAL_LOGIX_IP_ADDRESS) 94 | self.init_logix_slot = kwargs.get("init_logix_slot", common.INITIAL_LOGIX_SLOT) 95 | self.init_opc_address = kwargs.get("init_opc_address", common.INITIAL_OPC_ADDRESS) 96 | self.init_sp_tag = kwargs.get("init_sp_tag", common.INITIAL_SP_TAG) 97 | self.init_pv_tag = kwargs.get("init_pv_tag", common.INITIAL_PV_TAG) 98 | self.init_cv_tag = kwargs.get("init_cv_tag", common.INITIAL_CV_TAG) 99 | try: 100 | self.init_model_gain = float(kwargs.get("init_model_gain", common.INITIAL_MODEL_GAIN)) 101 | self.init_model_tc = float(kwargs.get("init_model_tc", common.INITIAL_MODEL_TC)) 102 | self.init_model_dt = float(kwargs.get("init_model_dt", common.INITIAL_MODEL_DT)) 103 | self.init_model_bias = float(kwargs.get("init_model_bias", common.INITIAL_MODEL_BIAS)) 104 | except ValueError: 105 | raise ValueError("init_model_gain, init_model_tc, init_model_dt and init_model_bias must all be floats") 106 | 107 | self._build_gui() 108 | self._initial_setup() 109 | self._empty_plot_setup() 110 | 111 | def _build_gui(self): 112 | """ 113 | Set up the graphical user interface (GUI) elements using ttkbootstrap. 114 | """ 115 | plt.style.use("bmh") 116 | self.root = ttk.Window() 117 | self.root.title("Process Emulator - PID Tuning Ireland©") 118 | # Adjust theme to suit 119 | style = ttk.Style(theme="yeti") 120 | style.theme.colors.bg = "#c0c0c0" 121 | 122 | self.screen_width = self.root.winfo_screenwidth() 123 | self.screen_height = self.root.winfo_screenheight() 124 | self.offset = 7 125 | self.toolbar = 73 126 | 127 | # Configure GUI window size and appearance 128 | self.root.resizable(True, True) 129 | self.root.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{-self.offset}+0") 130 | 131 | self.main_frame = ttk.Frame(self.root) 132 | self.main_frame.pack(expand=True, fill=ttk.BOTH) 133 | 134 | # Define various variables to be used in the GUI 135 | self.sp_value = ttk.DoubleVar() 136 | self.pv_value = ttk.DoubleVar() 137 | self.cv_value = ttk.DoubleVar() 138 | 139 | self.read_count = ttk.IntVar() 140 | self.error_count = ttk.IntVar() 141 | self.gui_status = ttk.StringVar() 142 | 143 | self.sp_plc_tag = ttk.StringVar(value=self.init_sp_tag) 144 | self.pv_plc_tag = ttk.StringVar(value=self.init_pv_tag) 145 | self.cv_plc_tag = ttk.StringVar(value=self.init_cv_tag) 146 | 147 | self.model_gain = ttk.DoubleVar(value=self.init_model_gain) 148 | self.model_tc = ttk.DoubleVar(value=self.init_model_tc) 149 | self.model_dt = ttk.DoubleVar(value=self.init_model_dt) 150 | self.model_bias = ttk.DoubleVar(value=self.init_model_bias) 151 | 152 | if self.comm_type == "logix": 153 | self.plc_address = ttk.StringVar(value=self.init_logix_ip_address) 154 | elif self.comm_type == "opc": 155 | self.plc_address = ttk.StringVar(value=self.init_opc_address) 156 | 157 | self.slot = ttk.IntVar(value=self.init_logix_slot) 158 | 159 | self.tags_frame = ttk.LabelFrame(self.main_frame, padding=5, text="Control") 160 | self.tags_frame.pack(expand=True, fill=ttk.BOTH, padx=5, pady=5) 161 | self.settings_frame = ttk.LabelFrame(self.main_frame, padding=5, text="Settings") 162 | self.settings_frame.pack(expand=True, fill=ttk.BOTH, padx=5, pady=5) 163 | 164 | # Orgainse 165 | self.top_tags_frame = ttk.Frame(self.tags_frame) 166 | self.top_tags_frame.pack(expand=True, fill=ttk.BOTH) 167 | self.bottom_tags_frame = ttk.Frame(self.tags_frame) 168 | self.bottom_tags_frame.pack(expand=True, fill=ttk.BOTH) 169 | 170 | # Column 0: Labels 171 | ttk.Label(self.top_tags_frame, text="SP:").grid(row=1, column=0, padx=10, pady=10, sticky=ttk.W) 172 | ttk.Label(self.top_tags_frame, text="PV:").grid(row=2, column=0, padx=10, pady=10, sticky=ttk.W) 173 | ttk.Label(self.top_tags_frame, text="CV:").grid(row=3, column=0, padx=10, pady=10, sticky=ttk.W) 174 | 175 | # Column 1: PLC Tags 176 | ttk.Label(self.top_tags_frame, text="PLC Tag").grid(row=0, column=1, padx=10, pady=10, sticky=ttk.W) 177 | self.entry_sp_tag = ttk.Entry(self.top_tags_frame, textvariable=self.sp_plc_tag, width=50) 178 | self.entry_sp_tag.grid(row=1, column=1, padx=10, pady=10, sticky=ttk.NSEW) 179 | self.entry_pv_tag = ttk.Entry(self.top_tags_frame, textvariable=self.pv_plc_tag, width=50) 180 | self.entry_pv_tag.grid(row=2, column=1, padx=10, pady=10, sticky=ttk.NSEW) 181 | self.entry_cv_tag = ttk.Entry(self.top_tags_frame, textvariable=self.cv_plc_tag, width=50) 182 | self.entry_cv_tag.grid(row=3, column=1, padx=10, pady=10, sticky=ttk.NSEW) 183 | 184 | # Column 2: Actual Values 185 | ttk.Label(self.top_tags_frame, text=" Value ").grid(row=0, column=2, padx=10, pady=10) 186 | ttk.Label(self.top_tags_frame, textvariable=self.sp_value).grid(row=1, column=2, padx=10, pady=10) 187 | ttk.Label(self.top_tags_frame, textvariable=self.pv_value).grid(row=2, column=2, padx=10, pady=10) 188 | ttk.Label(self.top_tags_frame, textvariable=self.cv_value).grid(row=3, column=2, padx=10, pady=10) 189 | 190 | # Buttons 191 | self.button_start = ttk.Button(self.bottom_tags_frame, width=25, text="Start Simulator", command=lambda: [self.start()]) 192 | self.button_start.grid(row=0, column=0, columnspan=1, padx=10, pady=10, sticky=ttk.NSEW) 193 | 194 | self.button_stop = ttk.Button(self.bottom_tags_frame, width=25, text="Stop Simulator", command=lambda: [self.stop()]) 195 | self.button_stop.grid(row=0, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.NSEW) 196 | self.button_stop.configure(state=ttk.DISABLED) 197 | 198 | self.button_show_trend = ttk.Button(self.bottom_tags_frame, text="Show Trend", command=lambda: [self.show_live_trend()]) 199 | self.button_show_trend.grid(row=1, column=0, columnspan=2, padx=10, pady=10, sticky=ttk.NSEW) 200 | self.button_show_trend.configure(state=ttk.DISABLED) 201 | 202 | # Settings Frame 203 | ttk.Label(self.settings_frame, text="PLC Address:").grid(row=0, column=0, padx=10, pady=10, sticky=ttk.W) 204 | self.entry_plc_address = ttk.Entry(self.settings_frame, width=25, textvariable=self.plc_address) 205 | self.entry_plc_address.grid(row=0, column=1, columnspan=2, padx=10, pady=10, sticky=ttk.NSEW) 206 | 207 | ttk.Label(self.settings_frame, text="PLC Slot:").grid(row=1, column=0, padx=10, pady=10, sticky=ttk.W) 208 | self.entry_slot = ttk.Entry(self.settings_frame, textvariable=self.slot, width=6) 209 | self.entry_slot.grid(row=1, column=1, padx=10, pady=10, sticky=ttk.W) 210 | 211 | ttk.Label(self.settings_frame, text="Interval (mS):").grid(row=2, column=0, padx=10, pady=10, sticky=ttk.W) 212 | ttk.Label(self.settings_frame, text="100").grid(row=2, column=1, padx=10, pady=10, sticky=ttk.W) 213 | 214 | ttk.Label(self.settings_frame, text="Read Count:").grid(row=3, column=0, padx=10, pady=10, sticky=ttk.W) 215 | ttk.Label(self.settings_frame, textvariable=self.read_count).grid(row=3, column=1, padx=10, pady=10, sticky=ttk.W) 216 | 217 | ttk.Label(self.settings_frame, text="Error Count:").grid(row=4, column=0, padx=10, pady=10, sticky=ttk.W) 218 | ttk.Label(self.settings_frame, textvariable=self.error_count).grid(row=4, column=1, padx=10, pady=10, sticky=ttk.W) 219 | 220 | ttk.Label(self.settings_frame, text="Status:").grid(row=5, column=0, padx=10, pady=10, sticky=ttk.W) 221 | ttk.Label(self.settings_frame, textvariable=self.gui_status).grid(row=5, column=1, columnspan=5, padx=10, pady=10, sticky=ttk.W) 222 | 223 | ttk.Label(self.settings_frame, text="First Order Plus Dead Time Process Model").grid(row=0, column=3, columnspan=2, padx=10, pady=10, sticky=ttk.W) 224 | ttk.Label(self.settings_frame, text="Model Gain: ").grid(row=1, column=3, padx=10, pady=10, sticky=ttk.W) 225 | ttk.Label(self.settings_frame, text="Time Constant (seconds):").grid(row=2, column=3, padx=10, pady=10, sticky=ttk.W) 226 | ttk.Label(self.settings_frame, text="Dead Time (seconds):").grid(row=3, column=3, padx=10, pady=10, sticky=ttk.W) 227 | ttk.Label(self.settings_frame, text="Bias:").grid(row=4, column=3, padx=10, pady=10, sticky=ttk.W) 228 | 229 | self.model_gain_spinbox = ttk.Spinbox(self.settings_frame, from_=-1000.00, to=1000.00, increment=0.1, textvariable=self.model_gain, width=6) 230 | self.model_gain_spinbox.grid(row=1, column=4, padx=10, pady=10) 231 | self.model_tc_spinbox = ttk.Spinbox(self.settings_frame, from_=1.00, to=1000.00, increment=0.1, textvariable=self.model_tc, width=6) 232 | self.model_tc_spinbox.grid(row=2, column=4, padx=10, pady=10) 233 | self.model_dt_spinbox = ttk.Spinbox(self.settings_frame, from_=1.00, to=1000.00, increment=0.1, textvariable=self.model_dt, width=6) 234 | self.model_dt_spinbox.grid(row=3, column=4, padx=10, pady=10) 235 | self.model_bias_spinbox = ttk.Spinbox(self.settings_frame, from_=-1000.00, to=1000.00, increment=0.1, textvariable=self.model_bias, width=6) 236 | self.model_bias_spinbox.grid(row=4, column=4, padx=10, pady=10) 237 | 238 | def _initial_setup(self): 239 | """ 240 | Initialize necessary resources like threads, PLC/OPC communication objects, 241 | CSV file handling, and client connections. 242 | """ 243 | self.thread_stop_event = threading.Event() 244 | self.thread_lock = threading.Lock() 245 | self.PV = np.zeros(0) 246 | self.CV = np.zeros(0) 247 | self.SP = np.zeros(0) 248 | self.scan_count = 0 249 | self.comm = None 250 | self.anim = None 251 | self.looped_thread_for_data_retrieval = None 252 | 253 | if self.comm_type == "logix": 254 | self.comm = PLC(ip_address=self.plc_address.get(), slot=self.slot.get()) 255 | elif self.comm_type == "opc": 256 | self.comm = Client(self.plc_address.get()) 257 | self.process = common.FOPDTModel() 258 | 259 | def _empty_plot_setup(self): 260 | """ 261 | Configure an empty plot window using matplotlib. 262 | """ 263 | self.fig = plt.figure() 264 | self.ax = plt.axes() 265 | (self.plot_SP,) = self.ax.plot([], [], color="goldenrod", linewidth=2, label="SP") 266 | (self.plot_CV,) = self.ax.plot([], [], color="darkgreen", linewidth=2, label="CV") 267 | (self.plot_PV,) = self.ax.plot([], [], color="blue", linewidth=2, label="PV") 268 | plt.ylabel("EU") 269 | plt.xlabel("Time (minutes)") 270 | plt.suptitle("Live Data") 271 | plt.legend(loc="upper right") 272 | mngr = plt.get_current_fig_manager() 273 | mngr.window.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{int(self.screen_width/2)-self.offset+1}+0") 274 | plt.gcf().canvas.mpl_connect("close_event", self._on_plot_close) 275 | plt.show(block=False) 276 | 277 | def start(self): 278 | self.thread_stop_event.clear() 279 | self.PV = np.zeros(0) 280 | self.CV = np.zeros(0) 281 | self.SP = np.zeros(0) 282 | self.scan_count = 0 283 | self.error_count.set(0) 284 | self.read_count.set(0) 285 | self.gui_status.set("") 286 | self.button_stop.configure(state=ttk.NORMAL) 287 | self.button_start.configure(state=ttk.DISABLED) 288 | self.button_show_trend.configure(state=ttk.DISABLED) 289 | self._change_gui_state(ttk.DISABLED) 290 | thread = threading.Thread(target=self.pre_flight_checks, name="Pre_Flight_Checks_Thread", daemon=True) 291 | thread.start() 292 | 293 | def _process_pre_flight_result(self, pre_flight_checks_completed): 294 | if pre_flight_checks_completed: 295 | self.looped_thread_for_data_retrieval = common.PeriodicInterval(self.data_retrieval, SAMPLE_RATE) 296 | if not 1 in plt.get_fignums(): 297 | self._empty_plot_setup() 298 | self.live_trend() 299 | 300 | elif not pre_flight_checks_completed or self.thread_stop_event.is_set(): 301 | self.button_stop.configure(state=ttk.DISABLED) 302 | self.button_start.configure(state=ttk.NORMAL) 303 | self._change_gui_state(ttk.NORMAL) 304 | 305 | def pre_flight_checks(self): 306 | """ 307 | Perform pre-flight checks before starting data recording: 308 | - Test PLC/OPC communication 309 | - Verify tag names and configuration 310 | """ 311 | try: 312 | pre_flight_checks_completed = False 313 | self.process.Gain = float(self.model_gain.get()) 314 | self.process.Time_Constant = float(self.model_tc.get()) / SAMPLE_RATE 315 | self.process.Dead_Time = float(self.model_dt.get()) / SAMPLE_RATE 316 | self.process.Bias = float(self.model_bias.get()) 317 | 318 | # Check PLC communication settings and prepare for recording 319 | self.tag_list_for_reads = [self.sp_plc_tag.get(), self.cv_plc_tag.get()] 320 | self.tag_list_for_writes = [self.pv_plc_tag.get()] 321 | self.full_tag_list = self.tag_list_for_reads + self.tag_list_for_writes 322 | 323 | if self.comm_type == "logix": 324 | self.comm = PLC(ip_address=self.plc_address.get(), slot=self.slot.get()) 325 | self.comm.IPAddress = self.plc_address.get() 326 | self.comm.ProcessorSlot = self.slot.get() 327 | self.comm.SocketTimeout = 10.0 328 | test_comms = self.comm.GetPLCTime() 329 | if test_comms.Status != "Success": 330 | raise Exception("Cannot Read Logix Clock - Check Configuration") 331 | 332 | # Test Read 333 | ret_response = self.comm.Read(self.full_tag_list) 334 | ret = [x.Value for x in ret_response] 335 | self.comm.SocketTimeout = sorted([0.1, SAMPLE_RATE * 1.1, 5.0])[1] 336 | 337 | elif self.comm_type == "opc": 338 | try: 339 | self.comm = Client(url=self.plc_address.get()) 340 | self.comm.connect() 341 | except: 342 | raise Exception("Failed to Create OPC Connection - Check Configuration") 343 | else: 344 | # Test Read 345 | self.full_tag_list = [self.comm.get_node(x) for x in self.full_tag_list] 346 | ret = self.comm.read_values(self.full_tag_list) 347 | self.tag_list_for_reads = [self.comm.get_node(x) for x in self.tag_list_for_reads] 348 | self.tag_list_for_writes = [self.comm.get_node(x) for x in self.tag_list_for_writes] 349 | 350 | if any(value is None for value in ret): 351 | raise Exception("Tag Name Incorrect - Check Configuration") 352 | 353 | if self.thread_stop_event.is_set(): 354 | return 355 | 356 | except Exception as e: 357 | current_date_time = datetime.now().strftime("%d-%m-%Y %H:%M:%S") 358 | self.gui_status.set(f"{current_date_time} - Pre Flight Error: {str(e)}") 359 | 360 | else: 361 | pre_flight_checks_completed = True 362 | 363 | finally: 364 | self.root.after(0, self._process_pre_flight_result, pre_flight_checks_completed) 365 | 366 | def data_retrieval(self): 367 | """ 368 | Retrieve data from PLC/OPC, update GUI values, write data to CSV, 369 | and handle exceptions during data retrieval. 370 | """ 371 | try: 372 | # Check if stop is requested 373 | if self.thread_stop_event.is_set(): 374 | return 375 | 376 | # Read data from PLC, update GUI, and write to CSV 377 | if self.comm_type == "logix": 378 | ret_response = self.comm.Read(self.tag_list_for_reads) 379 | ret = [x.Value for x in ret_response] 380 | 381 | elif self.comm_type == "opc": 382 | ret = self.comm.read_values(self.tag_list_for_reads) 383 | 384 | if all(x is not None for x in ret): 385 | self.sp_value.set(round(ret[0], 2)) 386 | self.cv_value.set(round(ret[1], 2)) 387 | self.SP = np.append(self.SP, ret[0]) 388 | self.CV = np.append(self.CV, ret[1]) 389 | self.read_count.set(self.read_count.get() + 1) 390 | 391 | elif not self.thread_stop_event.is_set(): 392 | self.error_count.set(self.error_count.get() + 1) 393 | current_date_time = datetime.now().strftime("%d-%m-%Y %H:%M:%S") 394 | self.gui_status.set(f"{current_date_time} - Error Getting Data") 395 | 396 | # Send CV to Process 397 | self.process.stored_cv = self.CV 398 | ts = [self.scan_count, self.scan_count + 1] 399 | 400 | # Get new PV value 401 | if self.PV.size > 1: 402 | pv = self.process.update(self.PV[-1], ts) 403 | else: 404 | pv = self.process.update(float(self.model_bias.get()), ts) 405 | 406 | # Add Noise 407 | noise = random.uniform(-0.01, 0.01) 408 | pv_and_noise = pv + noise 409 | 410 | # Store PV 411 | self.PV = np.append(self.PV, pv_and_noise) 412 | 413 | # Check if stop is requested 414 | if self.thread_stop_event.is_set(): 415 | return 416 | 417 | # Write PV to PLC 418 | if self.comm_type == "logix": 419 | write = self.comm.Write(self.pv_plc_tag.get(), pv_and_noise) 420 | if write.Status != "Success": 421 | raise Exception("Write Failed") 422 | 423 | elif self.comm_type == "opc": 424 | self.tag_list_for_writes[0].write_attribute(ua.AttributeIds.Value, ua.DataValue(float(pv_and_noise))) 425 | 426 | self.pv_value.set(round(pv_and_noise, 2)) 427 | 428 | except Exception as e: 429 | current_date_time = datetime.now().strftime("%d-%m-%Y %H:%M:%S") 430 | self.gui_status.set(f"{current_date_time} - Error Retrieving Data: {str(e)}") 431 | 432 | finally: 433 | self.scan_count += 1 434 | 435 | def _change_gui_state(self, req_state): 436 | """ 437 | Modify the state (enabled or disabled) of GUI input elements. 438 | """ 439 | self.entry_sp_tag.configure(state=req_state) 440 | self.entry_pv_tag.configure(state=req_state) 441 | self.entry_cv_tag.configure(state=req_state) 442 | self.entry_plc_address.configure(state=req_state) 443 | self.entry_slot.configure(state=req_state) 444 | self.model_gain_spinbox.configure(state=req_state) 445 | self.model_tc_spinbox.configure(state=req_state) 446 | self.model_dt_spinbox.configure(state=req_state) 447 | self.model_bias_spinbox.configure(state=req_state) 448 | 449 | def live_trend(self): 450 | # Setup Plot 451 | def init(): 452 | if not 1 in plt.get_fignums(): 453 | self._empty_plot_setup() 454 | 455 | # Loop here 456 | def animate(i): 457 | with self.thread_lock: 458 | x = np.arange(len(self.SP)) / 600 459 | if len(x) == len(self.SP) == len(self.PV) == len(self.CV): 460 | self.plot_SP.set_data(x, self.SP) 461 | self.plot_CV.set_data(x, self.CV) 462 | self.plot_PV.set_data(x, self.PV) 463 | self.ax.relim() 464 | self.ax.autoscale_view() 465 | 466 | # Live Data 467 | self.anim = animation.FuncAnimation(self.fig, animate, init_func=init, frames=60, interval=1000) 468 | plt.show() 469 | 470 | def _on_plot_close(self, event): 471 | """ 472 | Stop Animation when the Live Plot is closed. 473 | Enable Button to allow plot to be re-opened. 474 | """ 475 | if self.anim: 476 | self.anim.event_source.stop() 477 | 478 | if self.looped_thread_for_data_retrieval: 479 | self.button_show_trend.configure(state=ttk.NORMAL) 480 | 481 | def show_live_trend(self): 482 | """ 483 | Control the display of the live trend plot window. 484 | """ 485 | self.button_show_trend.configure(state=ttk.DISABLED) 486 | plot_open = 1 in plt.get_fignums() 487 | if not plot_open: 488 | self._empty_plot_setup() 489 | self.live_trend() 490 | 491 | def stop(self): 492 | """ 493 | Stop the data recording process, reset GUI elements, close files, 494 | disconnect from PLC/OPC server, and handle errors during shutdown. 495 | """ 496 | try: 497 | # Stop data recording, reset GUI elements, and close files 498 | self.thread_stop_event.set() 499 | if self.looped_thread_for_data_retrieval: 500 | self.looped_thread_for_data_retrieval.stop() 501 | 502 | self._change_gui_state(req_state=ttk.NORMAL) 503 | self.button_show_trend.configure(state=ttk.DISABLED) 504 | self.button_start.configure(state=ttk.NORMAL) 505 | 506 | self.anim.event_source.stop() 507 | if self.comm: 508 | if self.comm_type == "logix": 509 | self.comm.Close() 510 | elif self.comm_type == "opc": 511 | self.comm.disconnect() 512 | 513 | except Exception as e: 514 | self.gui_status.set("Stop Error: " + str(e)) 515 | 516 | else: 517 | self.button_stop.configure(state=ttk.DISABLED) 518 | 519 | 520 | if __name__ == "__main__": 521 | emulator_app = ProcessEmulator() 522 | emulator_app.root.mainloop() 523 | -------------------------------------------------------------------------------- /pypidtune/tuner.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Updated and maintained by pidtuningireland@gmail.com 4 | Copyright 2024 pidtuningireland 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 os 18 | import threading 19 | from tkinter import filedialog as fd 20 | import numpy as np 21 | from scipy.optimize import minimize 22 | import ttkbootstrap as ttk 23 | import matplotlib.pyplot as plt 24 | import common 25 | 26 | 27 | class PIDTuner: 28 | """ 29 | PIDTuner is responsible for generating a First Order Plus Dead Time model of the process and 30 | providing PID gains based on that model. 31 | 32 | """ 33 | 34 | def __init__(self, **kwargs): 35 | """ 36 | Parameters: 37 | ----------- 38 | `**kwargs`: dict 39 | A dictionary of optional parameters for configuration. 40 | 41 | Keyword Arguments: 42 | ------------------ 43 | - `pid_type` : str, optional 44 | 45 | The type of PID controller. Must be 'pi' or 'pid'. Defaults to 'pid'. 46 | 47 | - `pid_form` : str, optional 48 | 49 | The form of PID controller. Must be 'independent' or 'dependent'. Defaults to 'independent'. 50 | 51 | - `pid_time_units` : str, optional 52 | 53 | The time units for the PID controller. Must be 'seconds' or 'minutes'. Defaults to 'seconds'. 54 | 55 | - `init_model_gain` : float, optional 56 | 57 | Initial gain of the process model. Defaults to 2.5. 58 | 59 | - `init_model_tc` : float, optional 60 | 61 | Initial time constant of the process model. Defaults to 75.5 seconds. 62 | 63 | - `init_model_dt` : float, optional 64 | 65 | Initial dead time of the process model. Defaults to 10.5 seconds. 66 | 67 | - `init_model_bias` : float, optional 68 | 69 | Initial bias of the process model. Defaults to 13.5. 70 | 71 | - `default_csv_sep` : str, optional 72 | 73 | Separator for CSV file. Defaults to ";". 74 | """ 75 | # Initialize instance attributes based on provided or default values 76 | self.pid_type = kwargs.get("pid_type", "pid") 77 | self.pid_form = kwargs.get("pid_form", "independent") 78 | self.pid_time_units = kwargs.get("pid_time_units", "seconds") 79 | if self.pid_type not in ["pi", "pid"]: 80 | raise ValueError("pid_type must be 'pi' or 'pid'") 81 | if self.pid_form not in ["independent", "dependent"]: 82 | raise ValueError("pid_form must be 'independent' or 'dependent'") 83 | if self.pid_time_units not in ["minutes", "seconds"]: 84 | raise ValueError("pid_time_units must be 'minutes' or 'seconds'") 85 | try: 86 | self.init_model_gain = float(kwargs.get("init_model_gain", common.INITIAL_MODEL_GAIN)) 87 | self.init_model_tc = float(kwargs.get("init_model_tc", common.INITIAL_MODEL_TC)) 88 | self.init_model_dt = float(kwargs.get("init_model_dt", common.INITIAL_MODEL_DT)) 89 | self.init_model_bias = float(kwargs.get("init_model_bias", common.INITIAL_MODEL_BIAS)) 90 | except ValueError: 91 | raise ValueError("init_model_gain, init_model_tc, init_model_dt and init_model_bias must all be floats") 92 | 93 | self.default_csv_sep = kwargs.get("default_csv_sep", ";") 94 | 95 | self._build_gui() 96 | self._reset_values_on_gui() 97 | self._load_empty_plot() 98 | 99 | def _build_gui(self): 100 | plt.style.use("bmh") 101 | self.root = ttk.Window() 102 | self.root.title("PID Tuner - PID Tuning Ireland©") 103 | # Adjust theme to suit 104 | style = ttk.Style(theme="yeti") 105 | style.theme.colors.bg = "#c0c0c0" 106 | style.configure("TRadiobutton", background="#c0c0c0") 107 | 108 | self.screen_width = self.root.winfo_screenwidth() 109 | self.screen_height = self.root.winfo_screenheight() 110 | self.offset = 7 111 | self.toolbar = 73 112 | self.root.resizable(True, True) 113 | self.root.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{-self.offset}+0") 114 | 115 | self.base_frame = ttk.Frame(self.root) 116 | self.base_frame.pack(expand=True, fill=ttk.BOTH) 117 | 118 | self.upper_frame = ttk.Frame(self.base_frame) 119 | self.upper_frame.pack(expand=False, side=ttk.TOP, fill=ttk.BOTH) 120 | 121 | self.middle_frame = ttk.Frame(self.base_frame) 122 | self.middle_frame.pack(expand=False, side=ttk.TOP, fill=ttk.BOTH, pady=10) 123 | 124 | self.lower_frame = ttk.Frame(self.base_frame) 125 | self.lower_frame.pack(expand=False, side=ttk.BOTTOM, fill=ttk.BOTH) 126 | 127 | self.button_frame = ttk.Frame(self.upper_frame) 128 | self.button_frame.pack(side=ttk.LEFT, fill=ttk.BOTH) 129 | 130 | self.file_frame = ttk.Frame(self.upper_frame) 131 | self.file_frame.pack(side=ttk.LEFT, fill=ttk.BOTH, pady=5) 132 | 133 | self.model_frame = ttk.Frame(self.middle_frame) 134 | self.model_frame.pack(expand=True, side=ttk.LEFT, fill=ttk.BOTH) 135 | 136 | self.tune_frame = ttk.Frame(self.middle_frame) 137 | self.tune_frame.pack(expand=True, side=ttk.RIGHT, fill=ttk.BOTH) 138 | 139 | self.file_name = ttk.StringVar() 140 | self.model_gain = ttk.StringVar(value="...") 141 | self.model_tc = ttk.StringVar(value="...") 142 | self.model_dt = ttk.StringVar(value="...") 143 | self.model_bias = ttk.StringVar(value="...") 144 | self.kp_unit = ttk.StringVar() 145 | self.ki_unit = ttk.StringVar() 146 | self.kd_unit = ttk.StringVar() 147 | self.kp = ttk.StringVar() 148 | self.ki = ttk.StringVar() 149 | self.kd = ttk.StringVar() 150 | self.gains = {} 151 | self.original_sample_rate = 100 152 | self.sample_rate = ttk.IntVar(value=self.original_sample_rate) 153 | self.csv_sep = ttk.StringVar(value=self.default_csv_sep) 154 | self.gui_status = ttk.StringVar() 155 | 156 | self.open_file_button = ttk.Button(self.button_frame, text="Open CSV File", command=self.open_file) 157 | self.open_file_button.grid(row=0, column=0, padx=10, pady=10, sticky=ttk.NSEW) 158 | 159 | self.generate_model_button = ttk.Button(self.button_frame, text="Generate Model", command=self.solve_for_minimum_error) 160 | self.generate_model_button.grid(row=1, column=0, padx=10, pady=10, sticky=ttk.NSEW) 161 | self.generate_model_button.configure(state=ttk.DISABLED) 162 | 163 | self.sim_pid_button = ttk.Button(self.button_frame, text="Simulate PID Response", command=self.sim_pid) 164 | self.sim_pid_button.grid(row=3, column=0, padx=10, pady=10, sticky=ttk.NSEW) 165 | self.sim_pid_button.configure(state=ttk.DISABLED) 166 | 167 | ttk.Label(self.file_frame, text="Filename:").grid(row=0, column=0, padx=10, pady=10, sticky=ttk.W) 168 | ttk.Label(self.file_frame, textvariable=self.file_name).grid(row=0, column=1, columnspan=3, padx=10, pady=10, sticky=ttk.W) 169 | ttk.Label(self.file_frame, text="CSV Separator:").grid(row=1, column=0, padx=10, pady=10, sticky=ttk.W) 170 | ttk.Label(self.file_frame, textvariable=self.csv_sep).grid(row=1, column=1, padx=10, pady=10, sticky=ttk.W) 171 | ttk.Label(self.file_frame, text="Data Sample Interval:").grid(row=2, column=0, padx=10, pady=10, sticky=ttk.W) 172 | self.sample_rate_entry = ttk.Entry(self.file_frame, textvariable=self.sample_rate, width=6) 173 | self.sample_rate_entry.grid(row=2, column=1, padx=10, pady=10, sticky=ttk.W) 174 | self.sample_rate_entry.bind("", self.change_sample_rate) 175 | self.sample_rate_entry.bind("", self.change_sample_rate) 176 | ttk.Label(self.file_frame, text="mS").grid(row=2, column=2, padx=10, pady=10, sticky=ttk.W) 177 | 178 | ttk.Label(self.model_frame, text="Model Gain:").grid(row=1, column=1, columnspan=2, padx=10, pady=10, sticky=ttk.W) 179 | ttk.Label(self.model_frame, text="Time Constant (sec):").grid(row=2, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.W) 180 | ttk.Label(self.model_frame, text="Dead Time (sec):").grid(row=3, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.W) 181 | ttk.Label(self.model_frame, text="Ambient/Bias:").grid(row=4, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.W) 182 | ttk.Label(self.model_frame, textvariable=self.model_gain, width=15).grid(row=1, column=2, padx=10, pady=10, sticky=ttk.NSEW) 183 | ttk.Label(self.model_frame, textvariable=self.model_tc, width=15).grid(row=2, column=2, padx=10, pady=10, sticky=ttk.NSEW) 184 | ttk.Label(self.model_frame, textvariable=self.model_dt, width=15).grid(row=3, column=2, padx=10, pady=10, sticky=ttk.NSEW) 185 | ttk.Label(self.model_frame, textvariable=self.model_bias, width=15).grid(row=4, column=2, padx=10, pady=10, sticky=ttk.NSEW) 186 | 187 | ttk.Label(self.tune_frame, textvariable=self.kp_unit).grid(row=0, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.W) 188 | ttk.Label(self.tune_frame, textvariable=self.ki_unit).grid(row=0, column=2, columnspan=1, padx=10, pady=10, sticky=ttk.W) 189 | ttk.Label(self.tune_frame, textvariable=self.kd_unit).grid(row=0, column=3, columnspan=1, padx=10, pady=10, sticky=ttk.W) 190 | 191 | ttk.Label(self.tune_frame, text="PID Gains:").grid(row=1, column=0, columnspan=1, padx=10, pady=10, sticky=ttk.W) 192 | ttk.Label(self.tune_frame, textvariable=self.kp).grid(row=1, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.W) 193 | ttk.Label(self.tune_frame, textvariable=self.ki).grid(row=1, column=2, columnspan=1, padx=10, pady=10, sticky=ttk.W) 194 | ttk.Label(self.tune_frame, textvariable=self.kd).grid(row=1, column=3, columnspan=1, padx=10, pady=10, sticky=ttk.W) 195 | 196 | ttk.Separator(self.tune_frame, orient=ttk.HORIZONTAL, bootstyle=ttk.DARK).grid(row=2, column=0, columnspan=4, padx=10, pady=10, sticky=ttk.EW) 197 | 198 | ttk.Label(self.tune_frame, text="Tune Method:").grid(row=3, column=0, columnspan=1, padx=10, pady=10, sticky=ttk.W) 199 | self.tune_method = ttk.StringVar(value="chr") 200 | self.tune_method_chr = ttk.Radiobutton(self.tune_frame, text="CHR", variable=self.tune_method, value="chr", bootstyle="primary", command=self.update_gains_on_gui) 201 | self.tune_method_chr.grid(row=3, column=1, padx=10, pady=10, sticky=ttk.NSEW) 202 | self.tune_method_imc = ttk.Radiobutton(self.tune_frame, text="IMC", variable=self.tune_method, value="imc", command=self.update_gains_on_gui) 203 | self.tune_method_imc.grid(row=3, column=2, padx=10, pady=10, sticky=ttk.NSEW) 204 | self.tune_method_aimc = ttk.Radiobutton(self.tune_frame, text="AIMC", variable=self.tune_method, value="aimc", command=self.update_gains_on_gui) 205 | self.tune_method_aimc.grid(row=3, column=3, padx=10, pady=10, sticky=ttk.NSEW) 206 | 207 | ttk.Label(self.lower_frame, text="Status:").grid(row=0, column=0, padx=10, pady=10, sticky=ttk.W) 208 | ttk.Label(self.lower_frame, textvariable=self.gui_status).grid(row=0, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.W) 209 | 210 | def open_file(self): 211 | self.open_file_button.configure(state=ttk.DISABLED) 212 | self.generate_model_button.configure(state=ttk.DISABLED) 213 | self.sim_pid_button.configure(state=ttk.DISABLED) 214 | thread = threading.Thread(target=self.open_file_thread, name="Open_File_Thread", daemon=True) 215 | thread.start() 216 | 217 | def open_file_thread(self): 218 | filename = self.file_name.get() 219 | if not filename: 220 | initial_directory = os.getcwd() 221 | else: 222 | initial_directory = os.path.dirname(filename) 223 | filetypes = (("CSV files", "*.csv"), ("All files", "*.*")) 224 | new_filename = fd.askopenfilename(title="Open a file", initialdir=initial_directory, filetypes=filetypes) 225 | if new_filename: 226 | self.file_name.set(new_filename) 227 | self._reset_values_on_gui() 228 | self.gui_status.set(f"Loading Data from File") 229 | self._get_csv_data() 230 | 231 | self.root.after(0, self._change_gui_state, ttk.NORMAL) 232 | 233 | def change_sample_rate(self, event): 234 | if self.original_sample_rate == self.sample_rate.get(): 235 | return 236 | else: 237 | self.original_sample_rate = self.sample_rate.get() 238 | if not self.file_name.get(): 239 | return 240 | 241 | thread = threading.Thread(target=self._get_csv_data, name="Open_File_Thread", daemon=True) 242 | thread.start() 243 | self._reset_values_on_gui() 244 | self._change_gui_state(ttk.DISABLED) 245 | self.gui_status.set(f"Updating Sample Rate") 246 | 247 | def _get_csv_data(self): 248 | try: 249 | if not self.file_name.get(): 250 | return 251 | 252 | data = np.genfromtxt(self.file_name.get(), delimiter=self.csv_sep.get(), dtype=None, encoding=None, names=True) 253 | headers = data.dtype.names 254 | 255 | cv_col_name = next((col for col in headers if "CV" in col.upper()), headers[0]) 256 | pv_col_name = next((col for col in headers if "PV" in col.upper()), headers[0]) 257 | auto_col_name = next((col for col in headers if "AUTO" in col.upper()), cv_col_name) 258 | sp_col_name = next((col for col in headers if "SP" in col.upper()), cv_col_name) 259 | 260 | sample_step = int(1000 / self.sample_rate.get()) 261 | 262 | self.pv_array = data[pv_col_name][::sample_step] 263 | self.cv_array = data[cv_col_name][::sample_step] 264 | self.auto_array = data[auto_col_name][::sample_step] 265 | self.sp_array = data[sp_col_name][::sample_step] 266 | 267 | except Exception as e: 268 | self.gui_status.set(f"Error extracting data from CSV: {str(e)}") 269 | 270 | else: 271 | self.gui_status.set(f"Data Updated") 272 | self.root.after(0, self._update_plot) 273 | 274 | finally: 275 | self.root.after(0, self._change_gui_state, ttk.NORMAL) 276 | 277 | def _load_empty_plot(self): 278 | try: 279 | plt.figure(num=1) 280 | mngr = plt.get_current_fig_manager() 281 | mngr.window.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{int(self.screen_width/2)-self.offset+1}+0") 282 | plt.xlabel("Time (seconds)") 283 | plt.ylabel("Value") 284 | plt.suptitle("Actual Data") 285 | plt.show(block=False) 286 | 287 | except Exception as e: 288 | self.gui_status.set(f"Error updating plot: {str(e)}") 289 | 290 | def _update_plot(self): 291 | try: 292 | plt.clf() 293 | plt.gca().legend([]) 294 | plt.plot(self.auto_array, color="purple", linewidth=2, label="Auto") 295 | plt.plot(self.sp_array, color="goldenrod", linewidth=2, label="SP") 296 | plt.plot(self.cv_array, color="darkgreen", linewidth=2, label="CV") 297 | plt.plot(self.pv_array, color="blue", linewidth=2, label="PV") 298 | if self.model_array is not None: 299 | plt.plot(self.model_array, color="red", linewidth=2, label="Model") 300 | plt.xlabel("Time (seconds)") 301 | plt.ylabel("Value") 302 | plt.suptitle("Actual Data") 303 | plt.legend(loc="best") 304 | plt.show(block=False) 305 | 306 | except Exception as e: 307 | self.gui_status.set(f"Error updating plot: {str(e)}") 308 | 309 | def solve_for_minimum_error(self): 310 | self.gui_status.set(f"Attempting to find a Model for the Process") 311 | self._change_gui_state(ttk.DISABLED) 312 | thread = threading.Thread(target=self.solve_for_minimum_error_thread, name="Solver_Thread", daemon=True) 313 | thread.start() 314 | 315 | def solve_for_minimum_error_thread(self): 316 | try: 317 | t = np.arange(len(self.pv_array)) 318 | bounds = [(None, None), (0, None), (0, None), (None, None)] 319 | if self.model_gain.get() == "...": 320 | initial_model_gain = common.INITIAL_MODEL_GAIN if self.pv_array[0] > self.pv_array[-1] else -common.INITIAL_MODEL_GAIN 321 | model_values = initial_model_gain, common.INITIAL_MODEL_TC, common.INITIAL_MODEL_DT, common.INITIAL_MODEL_BIAS 322 | else: 323 | model_values = float(self.model_gain.get()), float(self.model_tc.get()), float(self.model_dt.get()), float(self.model_bias.get()) 324 | 325 | first_model_values = minimize(self.error_function, model_values, args=(t, self.pv_array, self.cv_array), bounds=bounds, method="Nelder-Mead").x 326 | second_model_values = minimize(self.error_function, first_model_values, args=(t, self.pv_array, self.cv_array), bounds=bounds, method="Nelder-Mead").x 327 | final_model_values = minimize(self.error_function, second_model_values, args=(t, self.pv_array, self.cv_array), bounds=bounds, method="Nelder-Mead").x 328 | Gain, Tau, DeadTime, Bias = final_model_values 329 | 330 | # Find Gains 331 | if self.pid_type == "pid": 332 | self.gains = common.find_pid_gains(final_model_values) 333 | elif self.pid_type == "pi": 334 | self.gains = common.find_pi_gains(final_model_values) 335 | 336 | # Update GUI 337 | self.model_gain.set(round(Gain, 2)) 338 | self.model_tc.set(round(Tau, 2)) 339 | self.model_dt.set(round(DeadTime, 2)) 340 | self.model_bias.set(round(Bias, 2)) 341 | self.update_gains_on_gui() 342 | 343 | # Generate model with minimized values 344 | self.model_array = self.fopdt_response(Gain, Tau, DeadTime, Bias, t, self.cv_array) 345 | self.root.after(0, self._update_plot) 346 | 347 | except Exception as e: 348 | self.gui_status.set(f"Error finding a Process Model: {str(e)}") 349 | 350 | else: 351 | self.gui_status.set(f"Model Creation Complete") 352 | 353 | finally: 354 | self.root.after(0, self._change_gui_state, ttk.NORMAL) 355 | 356 | def sim_pid(self): 357 | self._change_gui_state(ttk.DISABLED) 358 | thread = threading.Thread(target=self.sim_pid_thread, name="Sim_Thread", daemon=True) 359 | thread.start() 360 | 361 | def sim_pid_thread(self): 362 | try: 363 | self.gui_status.set(f"Simulating PID loop") 364 | if self.model_gain.get() == "..." or self.model_tc.get() == "..." or self.model_dt.get() == "..." or self.model_bias.get() == "...": 365 | self.gui_status.set(f"No Model Values Available") 366 | return 367 | if self.kp.get() == "..." or self.ki.get() == "..." or self.kd.get() == "...": 368 | self.gui_status.set(f"No PID Values Available") 369 | return 370 | 371 | model_values = float(self.model_gain.get()), float(self.model_tc.get()), float(self.model_dt.get()), float(self.model_bias.get()) 372 | gain_values = float(self.kp.get()), float(self.ki.get()), float(self.kd.get()) 373 | sim_values = common.convert_gains_for_sim(gain_values, pid_form=self.pid_form, pid_time_units=self.pid_time_units) 374 | ret = common.calculate_pid_response(model_values, sim_values) 375 | self.root.after(0, self._plot_sim, ret) 376 | 377 | except Exception as e: 378 | self.gui_status.set(f"Error Simulating PID loop: {str(e)}") 379 | 380 | else: 381 | self.gui_status.set(f"PID Simulation Complete") 382 | 383 | finally: 384 | self.root.after(0, self._change_gui_state, ttk.NORMAL) 385 | 386 | def _plot_sim(self, data): 387 | plt.figure(num=2) 388 | plt.clf() 389 | plt.gca().axis("off") 390 | mngr = plt.get_current_fig_manager() 391 | mngr.window.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{int(self.screen_width/2)-self.offset+1}+0") 392 | 393 | # Setup Plot 394 | title = f"{self.kp_unit.get()} {self.kp.get()} {self.ki_unit.get()} {self.ki.get()} {self.kd_unit.get()} {self.kd.get()}" 395 | plt.suptitle(f"{self.tune_method.get().upper()} PID Response (ITAE: {round(data[-1])})") 396 | plt.title(title, fontsize=10) 397 | 398 | # PID Response 399 | plt.subplot(2, 1, 1) 400 | plt.plot(data[0], color="goldenrod", linewidth=2, label="SP") 401 | plt.plot(data[1], color="darkgreen", linewidth=2, label="CV") 402 | plt.plot(data[2], color="blue", linewidth=2, label="PV") 403 | plt.ylabel("Value") 404 | plt.legend(loc="best") 405 | 406 | # Individual components 407 | plt.subplot(2, 1, 2) 408 | plt.plot(data[3], color="lime", linewidth=2, label="P Term") 409 | plt.plot(data[4], color="orange", linewidth=2, label="I Term") 410 | plt.plot(data[5], color="purple", linewidth=2, label="D Term") 411 | plt.xlabel("Time (seconds)") 412 | plt.ylabel("Value") 413 | plt.legend(loc="best") 414 | plt.grid(True) 415 | plt.show(block=False) 416 | 417 | def update_gains_on_gui(self): 418 | if self.gains and self.model_gain.get() != "...": 419 | tune_method = self.tune_method.get() 420 | gains = self.gains[tune_method] 421 | kp_val = gains["kp"] 422 | ki_val = gains["ki"] 423 | kd_val = gains["kd"] 424 | 425 | if self.pid_form == "independent" and self.pid_time_units == "seconds": 426 | self.kp.set(kp_val) 427 | self.ki.set(ki_val) 428 | self.kd.set(kd_val) 429 | 430 | elif self.pid_form == "independent" and self.pid_time_units == "minutes": 431 | self.kp.set(kp_val) 432 | self.ki.set(ki_val * 60) 433 | self.kd.set(round(kd_val / 60, 4)) 434 | 435 | elif self.pid_form == "dependent" and self.pid_time_units == "seconds": 436 | self.kp.set(kp_val) 437 | self.ki.set(round(kp_val / ki_val, 4)) 438 | self.kd.set(round(kd_val / kp_val, 4)) 439 | 440 | elif self.pid_form == "dependent" and self.pid_time_units == "minutes": 441 | self.kp.set(kp_val) 442 | self.ki.set(round((kp_val / ki_val) / 60, 4)) 443 | self.kd.set(round((kd_val / kp_val) / 60, 4)) 444 | 445 | def _reset_values_on_gui(self): 446 | self.model_gain.set("...") 447 | self.model_tc.set("...") 448 | self.model_dt.set("...") 449 | self.model_bias.set("...") 450 | self.kp.set("...") 451 | self.ki.set("...") 452 | self.kd.set("...") 453 | self.model_array = None 454 | 455 | if self.pid_form == "independent" and self.pid_time_units == "seconds": 456 | self.kp_unit.set("Kp:") 457 | self.ki_unit.set("Ki (1/sec):") 458 | self.kd_unit.set("Kd (sec):") 459 | 460 | elif self.pid_form == "independent" and self.pid_time_units == "minutes": 461 | self.kp_unit.set("Kp:") 462 | self.ki_unit.set("Ki (1/min):") 463 | self.kd_unit.set("Kd (min):") 464 | 465 | elif self.pid_form == "dependent" and self.pid_time_units == "seconds": 466 | self.kp_unit.set("Kc:") 467 | self.ki_unit.set("Ti (sec/repeat):") 468 | self.kd_unit.set("Td (sec):") 469 | 470 | elif self.pid_form == "dependent" and self.pid_time_units == "minutes": 471 | self.kp_unit.set("Kc:") 472 | self.ki_unit.set("Ti (min/repeat):") 473 | self.kd_unit.set("Td (min):") 474 | 475 | def _change_gui_state(self, req_state): 476 | """ 477 | Modify the state (enabled or disabled) of GUI input elements. 478 | """ 479 | self.sample_rate_entry.configure(state=req_state) 480 | self.open_file_button.configure(state=req_state) 481 | if self.file_name.get(): 482 | self.sim_pid_button.configure(state=req_state) 483 | self.generate_model_button.configure(state=req_state) 484 | 485 | def error_function(self, model_params, t, actual_pv, actual_cv) -> float: 486 | """ 487 | Calculate the error between the model predictions and actual data. 488 | 489 | Parameters: 490 | - model_params: Tuple of model parameters (gain, tau, deadtime, bias). 491 | - t: List of time points. 492 | - actual_pv: Actual process variable values. 493 | - actual_cv: Actual control variable values. 494 | 495 | Returns: 496 | - iae: Integral of absolute error. 497 | """ 498 | gain, tau, deadtime, bias = model_params 499 | model_output = self.fopdt_response(gain, tau, deadtime, bias, t, actual_cv) 500 | iae = np.sum(np.abs(model_output - actual_pv)) * (max(t) - min(t)) / len(t) 501 | return iae 502 | 503 | def fopdt_response(self, gain, tau, deadtime, bias, t, cv_array) -> np.array: 504 | """ 505 | Calculate the FOPDT model output for given parameters over time. 506 | 507 | Parameters: 508 | - gain: Process gain. 509 | - tau: Time constant. 510 | - deadtime: Dead time. 511 | - bias: Bias term. 512 | - t: Array of time points. 513 | - cv_array: Control variable values over time. 514 | 515 | Returns: 516 | - output: Model output values. 517 | """ 518 | deadtime = max(0.01, deadtime) 519 | tau = max(0.01, tau) 520 | 521 | offset = np.where(cv_array[:-1] != cv_array[1:])[0] 522 | t = t - offset[0] 523 | 524 | response = gain * (1 - np.exp(-(t - deadtime) / tau)) 525 | response[t < deadtime] = 0 526 | 527 | calculated_bias = gain * cv_array[0] + bias 528 | output = response * (cv_array - cv_array[0]) + calculated_bias 529 | 530 | return output 531 | 532 | 533 | if __name__ == "__main__": 534 | tuner_app = PIDTuner() 535 | tuner_app.root.mainloop() 536 | -------------------------------------------------------------------------------- /docs/prc.csv: -------------------------------------------------------------------------------- 1 | PV;CV 2 | 11.7;0 3 | 11.7;0 4 | 11.9;0 5 | 11.9;0 6 | 11.9;0 7 | 11.9;0 8 | 11.9;0 9 | 11.9;0 10 | 11.9;0 11 | 11.9;0 12 | 11.9;0 13 | 11.9;0 14 | 11.15;0 15 | 11.15;0 16 | 11.15;0 17 | 11.15;0 18 | 11.15;0 19 | 11.15;0 20 | 11.15;0 21 | 11.15;0 22 | 11.15;0 23 | 11.15;0 24 | 11.44;0 25 | 11.44;0 26 | 11.44;0 27 | 11.44;0 28 | 11.44;0 29 | 11.44;0 30 | 11.44;0 31 | 11.44;0 32 | 11.44;0 33 | 11.44;0 34 | 11.22;0 35 | 11.22;0 36 | 11.22;0 37 | 11.22;0 38 | 11.22;0 39 | 11.22;0 40 | 11.22;0 41 | 11.22;0 42 | 11.22;0 43 | 11.22;0 44 | 11.65;0 45 | 11.65;0 46 | 11.65;0 47 | 11.65;0 48 | 11.65;0 49 | 11.65;0 50 | 11.65;0 51 | 11.65;0 52 | 11.65;0 53 | 11.65;0 54 | 11.45;0 55 | 11.45;0 56 | 11.45;0 57 | 11.45;0 58 | 11.45;0 59 | 11.45;10 60 | 11.45;10 61 | 11.45;10 62 | 11.45;10 63 | 11.45;10 64 | 11.43;10 65 | 11.43;10 66 | 11.43;10 67 | 11.43;10 68 | 11.43;10 69 | 11.43;10 70 | 11.43;10 71 | 11.43;10 72 | 11.43;10 73 | 11.43;10 74 | 11.48;10 75 | 11.48;10 76 | 11.48;10 77 | 11.48;10 78 | 11.48;10 79 | 11.48;10 80 | 11.48;10 81 | 11.48;10 82 | 11.48;10 83 | 11.48;10 84 | 11.25;10 85 | 11.25;10 86 | 11.25;10 87 | 11.25;10 88 | 11.25;10 89 | 11.25;10 90 | 11.25;10 91 | 11.25;10 92 | 11.25;10 93 | 11.25;10 94 | 11.79;10 95 | 11.79;10 96 | 11.79;10 97 | 11.79;10 98 | 11.79;10 99 | 11.79;10 100 | 11.79;10 101 | 11.79;10 102 | 11.79;10 103 | 11.79;10 104 | 11.47;10 105 | 11.47;10 106 | 11.47;10 107 | 11.47;10 108 | 11.47;10 109 | 11.5;10 110 | 11.56;10 111 | 11.61;10 112 | 11.67;10 113 | 11.73;10 114 | 12.17;10 115 | 12.23;10 116 | 12.28;10 117 | 12.34;10 118 | 12.4;10 119 | 12.45;10 120 | 12.51;10 121 | 12.56;10 122 | 12.62;10 123 | 12.67;10 124 | 12.4;10 125 | 12.45;10 126 | 12.51;10 127 | 12.56;10 128 | 12.61;10 129 | 12.67;10 130 | 12.72;10 131 | 12.78;10 132 | 12.83;10 133 | 12.88;10 134 | 12.62;10 135 | 12.67;10 136 | 12.72;10 137 | 12.77;10 138 | 12.83;10 139 | 12.88;10 140 | 12.93;10 141 | 12.98;10 142 | 13.04;10 143 | 13.09;10 144 | 13.58;10 145 | 13.63;10 146 | 13.68;10 147 | 13.73;10 148 | 13.78;10 149 | 13.84;10 150 | 13.89;10 151 | 13.94;10 152 | 13.99;10 153 | 14.04;10 154 | 14.23;10 155 | 14.28;10 156 | 14.33;10 157 | 14.38;10 158 | 14.43;10 159 | 14.48;10 160 | 14.53;10 161 | 14.58;10 162 | 14.62;10 163 | 14.67;10 164 | 14.45;10 165 | 14.5;10 166 | 14.55;10 167 | 14.6;10 168 | 14.65;10 169 | 14.69;10 170 | 14.74;10 171 | 14.79;10 172 | 14.84;10 173 | 14.89;10 174 | 14.56;10 175 | 14.61;10 176 | 14.66;10 177 | 14.71;10 178 | 14.75;10 179 | 14.8;10 180 | 14.85;10 181 | 14.89;10 182 | 14.94;10 183 | 14.98;10 184 | 15.47;10 185 | 15.52;10 186 | 15.56;10 187 | 15.61;10 188 | 15.65;10 189 | 15.7;10 190 | 15.74;10 191 | 15.79;10 192 | 15.83;10 193 | 15.88;10 194 | 16.26;10 195 | 16.31;10 196 | 16.35;10 197 | 16.4;10 198 | 16.44;10 199 | 16.49;10 200 | 16.53;10 201 | 16.57;10 202 | 16.62;10 203 | 16.66;10 204 | 16.22;10 205 | 16.26;10 206 | 16.3;10 207 | 16.35;10 208 | 16.47;10 209 | 16.52;10 210 | 16.52;10 211 | 16.52;10 212 | 16.56;10 213 | 16.6;10 214 | 16.59;10 215 | 16.64;10 216 | 16.68;10 217 | 16.72;10 218 | 16.76;10 219 | 16.8;10 220 | 16.85;10 221 | 16.89;10 222 | 16.93;10 223 | 16.97;10 224 | 17.19;10 225 | 17.23;10 226 | 17.27;10 227 | 17.31;10 228 | 17.35;10 229 | 17.4;10 230 | 17.44;10 231 | 17.48;10 232 | 17.52;10 233 | 17.56;10 234 | 17.11;10 235 | 17.15;10 236 | 17.19;10 237 | 17.23;10 238 | 17.27;10 239 | 17.3;10 240 | 17.34;10 241 | 17.38;10 242 | 17.42;10 243 | 17.46;10 244 | 18.3;10 245 | 18.34;10 246 | 18.38;10 247 | 18.42;10 248 | 18.45;10 249 | 18.49;10 250 | 18.53;10 251 | 18.57;10 252 | 18.61;10 253 | 18.64;10 254 | 18.72;10 255 | 18.76;10 256 | 18.8;10 257 | 18.84;10 258 | 18.87;10 259 | 18.91;10 260 | 18.95;10 261 | 19.06;10 262 | 19.06;10 263 | 18.69;10 264 | 18.69;10 265 | 18.73;10 266 | 18.77;10 267 | 18.8;10 268 | 18.84;10 269 | 18.88;10 270 | 18.91;10 271 | 18.95;10 272 | 18.98;10 273 | 19.02;10 274 | 19.19;10 275 | 19.22;10 276 | 19.26;10 277 | 19.36;10 278 | 19.4;10 279 | 19.4;10 280 | 19.4;10 281 | 19.43;10 282 | 19.47;10 283 | 19.5;10 284 | 19.62;10 285 | 19.65;10 286 | 19.69;10 287 | 19.72;10 288 | 19.75;10 289 | 19.79;10 290 | 19.82;10 291 | 19.86;10 292 | 19.89;10 293 | 19.92;10 294 | 19.71;10 295 | 19.74;10 296 | 19.77;10 297 | 19.81;10 298 | 19.84;10 299 | 19.87;10 300 | 19.91;10 301 | 19.94;10 302 | 19.97;10 303 | 20.01;10 304 | 19.93;10 305 | 19.96;10 306 | 19.99;10 307 | 20.03;10 308 | 20.06;10 309 | 20.09;10 310 | 20.12;10 311 | 20.16;10 312 | 20.19;10 313 | 20.22;10 314 | 20.42;10 315 | 20.45;10 316 | 20.49;10 317 | 20.52;10 318 | 20.55;10 319 | 20.58;10 320 | 20.61;10 321 | 20.64;10 322 | 20.67;10 323 | 20.7;10 324 | 20.88;10 325 | 20.92;10 326 | 20.95;10 327 | 20.98;10 328 | 21.01;10 329 | 21.04;10 330 | 21.07;10 331 | 21.1;10 332 | 21.13;10 333 | 21.16;10 334 | 21.48;10 335 | 21.51;10 336 | 21.54;10 337 | 21.57;10 338 | 21.6;10 339 | 21.63;10 340 | 21.66;10 341 | 21.69;10 342 | 21.72;10 343 | 21.75;10 344 | 20.88;10 345 | 20.91;10 346 | 20.94;10 347 | 20.97;10 348 | 21;10 349 | 21.03;10 350 | 21.06;10 351 | 21.09;10 352 | 21.12;10 353 | 21.14;10 354 | 21.65;10 355 | 21.68;10 356 | 21.71;10 357 | 21.74;10 358 | 21.77;10 359 | 21.79;10 360 | 21.82;10 361 | 21.85;10 362 | 21.88;10 363 | 21.9;10 364 | 21.67;10 365 | 21.7;10 366 | 21.73;10 367 | 21.75;10 368 | 21.78;10 369 | 21.81;10 370 | 21.84;10 371 | 21.86;10 372 | 21.89;10 373 | 21.92;10 374 | 22.4;10 375 | 22.43;10 376 | 22.46;10 377 | 22.48;10 378 | 22.51;10 379 | 22.54;10 380 | 22.56;10 381 | 22.59;10 382 | 22.61;10 383 | 22.64;10 384 | 22.13;10 385 | 22.15;10 386 | 22.18;10 387 | 22.2;10 388 | 22.23;10 389 | 22.26;10 390 | 22.28;10 391 | 22.31;10 392 | 22.33;10 393 | 22.36;10 394 | 23.04;10 395 | 23.07;10 396 | 23.09;10 397 | 23.12;10 398 | 23.14;10 399 | 23.17;10 400 | 23.19;10 401 | 23.22;10 402 | 23.24;10 403 | 23.27;10 404 | 22.52;10 405 | 22.55;10 406 | 22.57;10 407 | 22.6;10 408 | 22.62;10 409 | 22.64;10 410 | 22.67;10 411 | 22.69;10 412 | 22.72;10 413 | 22.74;10 414 | 23.5;10 415 | 23.53;10 416 | 23.55;10 417 | 23.58;10 418 | 23.6;10 419 | 23.62;10 420 | 23.65;10 421 | 23.67;10 422 | 23.69;10 423 | 23.72;10 424 | 23.84;10 425 | 23.86;10 426 | 23.89;10 427 | 23.91;10 428 | 23.93;10 429 | 23.96;10 430 | 23.98;10 431 | 24;10 432 | 24.02;10 433 | 24.05;10 434 | 24.01;10 435 | 24.03;10 436 | 24.05;10 437 | 24.08;10 438 | 24.1;10 439 | 24.12;10 440 | 24.14;10 441 | 24.17;10 442 | 24.19;10 443 | 24.21;10 444 | 23.64;10 445 | 23.66;10 446 | 23.68;10 447 | 23.71;10 448 | 23.73;10 449 | 23.75;10 450 | 23.77;10 451 | 23.79;10 452 | 23.81;10 453 | 23.84;10 454 | 23.71;10 455 | 23.73;10 456 | 23.75;10 457 | 23.77;10 458 | 23.79;10 459 | 23.81;10 460 | 23.83;10 461 | 23.85;10 462 | 23.88;10 463 | 23.9;10 464 | 23.97;10 465 | 23.99;10 466 | 24.01;10 467 | 24.03;10 468 | 24.05;10 469 | 24.07;10 470 | 24.09;10 471 | 24.11;10 472 | 24.13;10 473 | 24.15;10 474 | 24.92;10 475 | 24.94;10 476 | 24.96;10 477 | 24.98;10 478 | 25;10 479 | 25.02;10 480 | 25.04;10 481 | 25.06;10 482 | 25.08;10 483 | 25.1;10 484 | 24.63;10 485 | 24.65;10 486 | 24.67;10 487 | 24.69;10 488 | 24.71;10 489 | 24.73;10 490 | 24.75;10 491 | 24.76;10 492 | 24.78;10 493 | 24.8;10 494 | 24.72;10 495 | 24.74;10 496 | 24.76;10 497 | 24.78;10 498 | 24.8;10 499 | 24.82;10 500 | 24.83;10 501 | 24.85;10 502 | 24.84;10 503 | 24.86;10 504 | 24.86;10 505 | 24.86;10 506 | 24.88;10 507 | 24.89;10 508 | 24.91;10 509 | 24.93;10 510 | 24.95;10 511 | 24.97;10 512 | 24.99;10 513 | 25;10 514 | 25.26;10 515 | 25.28;10 516 | 25.3;10 517 | 25.31;10 518 | 25.33;10 519 | 25.35;10 520 | 25.37;10 521 | 25.39;10 522 | 25.4;10 523 | 25.42;10 524 | 25.65;10 525 | 25.67;10 526 | 25.68;10 527 | 25.7;10 528 | 25.72;10 529 | 25.73;10 530 | 25.75;10 531 | 25.77;10 532 | 25.79;10 533 | 25.8;10 534 | 25.41;10 535 | 25.43;10 536 | 25.44;10 537 | 25.46;10 538 | 25.48;10 539 | 25.49;10 540 | 25.51;10 541 | 25.53;10 542 | 25.54;10 543 | 25.56;10 544 | 26.11;10 545 | 26.12;10 546 | 26.14;10 547 | 26.16;10 548 | 26.17;10 549 | 26.19;10 550 | 26.2;10 551 | 26.22;10 552 | 26.24;10 553 | 26.25;10 554 | 26.03;10 555 | 26.05;10 556 | 26.06;10 557 | 26.08;10 558 | 26.09;10 559 | 26.11;10 560 | 26.12;10 561 | 26.14;10 562 | 26.16;10 563 | 26.17;10 564 | 25.94;10 565 | 25.95;10 566 | 25.97;10 567 | 25.98;10 568 | 26;10 569 | 26.01;10 570 | 26.03;10 571 | 26.04;10 572 | 26.06;10 573 | 26.08;10 574 | 26.49;10 575 | 26.51;10 576 | 26.52;10 577 | 26.54;10 578 | 26.55;10 579 | 26.57;10 580 | 26.58;10 581 | 26.6;10 582 | 26.61;10 583 | 26.62;10 584 | 25.88;10 585 | 25.89;10 586 | 25.91;10 587 | 25.92;10 588 | 25.94;10 589 | 25.95;10 590 | 25.97;10 591 | 25.98;10 592 | 26;10 593 | 26.01;10 594 | 26.01;10 595 | 26.03;10 596 | 26.04;10 597 | 26.06;10 598 | 26.07;10 599 | 26.08;10 600 | 26.1;10 601 | 26.11;10 602 | 26.13;10 603 | 26.14;10 604 | 26.68;10 605 | 26.7;10 606 | 26.71;10 607 | 26.73;10 608 | 26.74;10 609 | 26.75;10 610 | 26.77;10 611 | 26.78;10 612 | 26.79;10 613 | 26.81;10 614 | 27.08;10 615 | 27.09;10 616 | 27.11;10 617 | 27.12;10 618 | 27.14;10 619 | 27.15;10 620 | 27.16;10 621 | 27.17;10 622 | 27.19;10 623 | 27.2;10 624 | 27.09;10 625 | 27.11;10 626 | 27.12;10 627 | 27.13;10 628 | 27.15;10 629 | 27.16;10 630 | 27.17;10 631 | 27.19;10 632 | 27.2;10 633 | 27.21;10 634 | 26.62;10 635 | 26.64;10 636 | 26.65;10 637 | 26.66;10 638 | 26.67;10 639 | 26.69;10 640 | 26.7;10 641 | 26.71;10 642 | 26.72;10 643 | 26.74;10 644 | 27.15;10 645 | 27.16;10 646 | 27.17;10 647 | 27.19;10 648 | 27.2;10 649 | 27.21;10 650 | 27.22;10 651 | 27.23;10 652 | 27.25;10 653 | 27.26;10 654 | 27.78;10 655 | 27.79;10 656 | 27.81;10 657 | 27.82;10 658 | 27.83;10 659 | 27.84;10 660 | 27.85;10 661 | 27.86;10 662 | 27.88;10 663 | 27.89;10 664 | 27.33;10 665 | 27.34;10 666 | 27.35;10 667 | 27.36;10 668 | 27.38;10 669 | 27.39;10 670 | 27.4;10 671 | 27.41;10 672 | 27.42;10 673 | 27.43;10 674 | 27.17;10 675 | 27.19;10 676 | 27.2;10 677 | 27.21;10 678 | 27.22;10 679 | 27.23;10 680 | 27.24;10 681 | 27.25;10 682 | 27.26;10 683 | 27.28;10 684 | 28.08;10 685 | 28.09;10 686 | 28.1;10 687 | 28.11;10 688 | 28.12;10 689 | 28.13;10 690 | 28.14;10 691 | 28.15;10 692 | 28.16;10 693 | 28.17;10 694 | 28.05;10 695 | 28.06;10 696 | 28.07;10 697 | 28.08;10 698 | 28.09;10 699 | 28.1;10 700 | 28.11;10 701 | 28.12;10 702 | 28.13;10 703 | 28.14;10 704 | 28.06;10 705 | 28.07;10 706 | 28.08;10 707 | 28.09;10 708 | 28.1;10 709 | 28.11;10 710 | 28.12;10 711 | 28.13;10 712 | 28.14;10 713 | 28.15;10 714 | 28.2;10 715 | 28.21;10 716 | 28.22;10 717 | 28.23;10 718 | 28.24;10 719 | 28.25;10 720 | 28.26;10 721 | 28.27;10 722 | 28.28;10 723 | 28.29;10 724 | 28.07;10 725 | 28.08;10 726 | 28.09;10 727 | 28.1;10 728 | 28.11;10 729 | 28.12;10 730 | 28.13;10 731 | 28.14;10 732 | 28.15;10 733 | 28.16;10 734 | 28.47;10 735 | 28.48;10 736 | 28.49;10 737 | 28.5;10 738 | 28.51;10 739 | 28.52;10 740 | 28.53;10 741 | 28.54;10 742 | 28.55;10 743 | 28.56;10 744 | 28.42;10 745 | 28.42;10 746 | 28.43;10 747 | 28.44;10 748 | 28.45;10 749 | 28.46;10 750 | 28.47;10 751 | 28.48;10 752 | 28.49;10 753 | 28.5;10 754 | 28.7;10 755 | 28.71;10 756 | 28.72;10 757 | 28.72;10 758 | 28.73;10 759 | 28.74;10 760 | 28.75;10 761 | 28.76;10 762 | 28.77;10 763 | 28.78;10 764 | 27.99;10 765 | 27.99;10 766 | 28;10 767 | 28.01;10 768 | 28.02;10 769 | 28.03;10 770 | 28.04;10 771 | 28.05;10 772 | 28.06;10 773 | 28.06;10 774 | 28.21;10 775 | 28.22;10 776 | 28.23;10 777 | 28.24;10 778 | 28.25;10 779 | 28.26;10 780 | 28.26;10 781 | 28.27;10 782 | 28.28;10 783 | 28.29;10 784 | 29.03;10 785 | 29.04;10 786 | 29.04;10 787 | 29.05;10 788 | 29.06;10 789 | 29.07;10 790 | 29.08;10 791 | 29.08;10 792 | 29.09;10 793 | 29.1;10 794 | 28.36;10 795 | 28.37;10 796 | 28.37;10 797 | 28.38;10 798 | 28.39;10 799 | 28.4;10 800 | 28.41;10 801 | 28.41;10 802 | 28.42;10 803 | 28.43;10 804 | 28.51;10 805 | 28.52;10 806 | 28.52;10 807 | 28.53;10 808 | 28.54;10 809 | 28.55;10 810 | 28.55;10 811 | 28.56;10 812 | 28.57;10 813 | 28.58;10 814 | 28.73;10 815 | 28.73;10 816 | 28.74;10 817 | 28.75;10 818 | 28.76;10 819 | 28.76;10 820 | 28.77;10 821 | 28.78;10 822 | 28.79;10 823 | 28.79;10 824 | 28.7;10 825 | 28.71;10 826 | 28.72;10 827 | 28.72;10 828 | 28.73;10 829 | 28.74;10 830 | 28.74;10 831 | 28.75;10 832 | 28.76;10 833 | 28.77;10 834 | 28.86;10 835 | 28.87;10 836 | 28.88;10 837 | 28.88;10 838 | 28.89;10 839 | 28.9;10 840 | 28.91;10 841 | 28.91;10 842 | 28.92;10 843 | 28.93;10 844 | 28.92;10 845 | 28.93;10 846 | 28.94;10 847 | 28.95;10 848 | 28.95;10 849 | 28.96;10 850 | 28.97;10 851 | 28.97;10 852 | 28.98;10 853 | 28.99;10 854 | 28.84;10 855 | 28.85;10 856 | 28.86;10 857 | 28.86;10 858 | 28.87;10 859 | 28.88;10 860 | 28.88;10 861 | 28.89;10 862 | 28.9;10 863 | 28.9;10 864 | 29.04;10 865 | 29.05;10 866 | 29.05;10 867 | 29.06;10 868 | 29.07;10 869 | 29.07;10 870 | 29.08;10 871 | 29.09;10 872 | 29.09;10 873 | 29.1;10 874 | 29.06;10 875 | 29.06;10 876 | 29.07;10 877 | 29.07;10 878 | 29.08;10 879 | 29.09;10 880 | 29.09;10 881 | 29.1;10 882 | 29.11;10 883 | 29.11;10 884 | 29.31;10 885 | 29.31;10 886 | 29.32;10 887 | 29.33;10 888 | 29.33;10 889 | 29.34;10 890 | 29.35;10 891 | 29.35;10 892 | 29.36;10 893 | 29.36;10 894 | 29.25;10 895 | 29.26;10 896 | 29.26;10 897 | 29.27;10 898 | 29.27;10 899 | 29.28;10 900 | 29.29;10 901 | 29.29;10 902 | 29.3;10 903 | 29.3;10 904 | 29.32;10 905 | 29.33;10 906 | 29.33;10 907 | 29.34;10 908 | 29.34;10 909 | 29.35;10 910 | 29.35;10 911 | 29.36;10 912 | 29.37;10 913 | 29.37;10 914 | 29.02;10 915 | 29.02;10 916 | 29.03;10 917 | 29.03;10 918 | 29.04;10 919 | 29.05;10 920 | 29.05;10 921 | 29.06;10 922 | 29.06;10 923 | 29.07;10 924 | 29.21;10 925 | 29.22;10 926 | 29.22;10 927 | 29.23;10 928 | 29.24;10 929 | 29.24;10 930 | 29.25;10 931 | 29.25;10 932 | 29.26;10 933 | 29.26;10 934 | 29.13;10 935 | 29.13;10 936 | 29.14;10 937 | 29.14;10 938 | 29.15;10 939 | 29.16;10 940 | 29.16;10 941 | 29.17;10 942 | 29.17;10 943 | 29.18;10 944 | 29.73;10 945 | 29.74;10 946 | 29.74;10 947 | 29.75;10 948 | 29.75;10 949 | 29.76;10 950 | 29.76;10 951 | 29.77;10 952 | 29.77;10 953 | 29.78;10 954 | 30.05;10 955 | 30.06;10 956 | 30.06;10 957 | 30.07;10 958 | 30.07;10 959 | 30.08;10 960 | 30.08;10 961 | 30.09;10 962 | 30.09;10 963 | 30.1;10 964 | 29.85;10 965 | 29.86;10 966 | 29.86;10 967 | 29.87;10 968 | 29.87;10 969 | 29.88;10 970 | 29.88;10 971 | 29.89;10 972 | 29.89;10 973 | 29.9;10 974 | 29.69;10 975 | 29.7;10 976 | 29.7;10 977 | 29.71;10 978 | 29.71;10 979 | 29.72;10 980 | 29.72;10 981 | 29.73;10 982 | 29.73;10 983 | 29.74;10 984 | 29.6;10 985 | 29.61;10 986 | 29.61;10 987 | 29.61;10 988 | 29.62;10 989 | 29.62;10 990 | 29.63;10 991 | 29.63;10 992 | 29.64;10 993 | 29.64;10 994 | 29.77;10 995 | 29.77;10 996 | 29.78;10 997 | 29.78;10 998 | 29.78;10 999 | 29.79;10 1000 | 29.79;10 1001 | 29.8;10 1002 | 29.8;10 1003 | 29.81;10 1004 | 30.08;10 1005 | 30.09;10 1006 | 30.09;10 1007 | 30.09;10 1008 | 30.1;10 1009 | 30.1;10 1010 | 30.11;10 1011 | 30.11;10 1012 | 30.12;10 1013 | 30.12;10 1014 | 29.67;10 1015 | 29.67;10 1016 | 29.67;10 1017 | 29.68;10 1018 | 29.68;10 1019 | 29.69;10 1020 | 29.69;10 1021 | 29.69;10 1022 | 29.7;10 1023 | 29.7;10 1024 | 29.84;10 1025 | 29.84;10 1026 | 29.85;10 1027 | 29.85;10 1028 | 29.85;10 1029 | 29.86;10 1030 | 29.86;10 1031 | 29.87;10 1032 | 29.87;10 1033 | 29.87;10 1034 | 30.3;10 1035 | 30.3;10 1036 | 30.31;10 1037 | 30.31;10 1038 | 30.31;10 1039 | 30.32;10 1040 | 30.32;10 1041 | 30.33;10 1042 | 30.33;10 1043 | 30.33;10 1044 | 30.01;10 1045 | 30.01;10 1046 | 30.02;10 1047 | 30.02;10 1048 | 30.02;10 1049 | 30.03;10 1050 | 30.03;10 1051 | 30.04;10 1052 | 30.04;10 1053 | 30.04;10 1054 | 29.75;10 1055 | 29.75;10 1056 | 29.76;10 1057 | 29.76;10 1058 | 29.76;10 1059 | 29.77;10 1060 | 29.77;10 1061 | 29.77;10 1062 | 29.78;10 1063 | 29.78;10 1064 | 30.13;10 1065 | 30.13;10 1066 | 30.13;10 1067 | 30.14;10 1068 | 30.14;10 1069 | 30.14;10 1070 | 30.15;10 1071 | 30.15;10 1072 | 30.15;10 1073 | 30.16;10 1074 | 30.38;10 1075 | 30.39;10 1076 | 30.39;10 1077 | 30.39;10 1078 | 30.4;10 1079 | 30.4;10 1080 | 30.4;10 1081 | 30.41;10 1082 | 30.41;10 1083 | 30.41;10 1084 | 30.4;10 1085 | 30.4;10 1086 | 30.4;10 1087 | 30.41;10 1088 | 30.41;10 1089 | 30.42;10 1090 | 30.42;10 1091 | 30.42;10 1092 | 30.43;10 1093 | 30.43;10 1094 | 30.07;10 1095 | 30.08;10 1096 | 30.08;10 1097 | 30.08;10 1098 | 30.09;10 1099 | 30.09;10 1100 | 30.09;10 1101 | 30.1;10 1102 | 30.1;10 1103 | 30.1;10 1104 | 30.36;10 1105 | 30.36;10 1106 | 30.36;10 1107 | 30.37;10 1108 | 30.37;10 1109 | 30.37;10 1110 | 30.38;10 1111 | 30.38;10 1112 | 30.38;10 1113 | 30.39;10 1114 | 30.8;10 1115 | 30.8;10 1116 | 30.81;10 1117 | 30.81;10 1118 | 30.81;10 1119 | 30.82;10 1120 | 30.82;10 1121 | 30.82;10 1122 | 30.82;10 1123 | 30.83;10 1124 | 30.8;10 1125 | 30.8;10 1126 | 30.81;10 1127 | 30.81;10 1128 | 30.81;10 1129 | 30.82;10 1130 | 30.82;10 1131 | 30.82;10 1132 | 30.83;10 1133 | 30.83;10 1134 | 30.63;10 1135 | 30.63;10 1136 | 30.64;10 1137 | 30.64;10 1138 | 30.64;10 1139 | 30.65;10 1140 | 30.65;10 1141 | 30.65;10 1142 | 30.66;10 1143 | 30.66;10 1144 | 30.73;10 1145 | 30.73;10 1146 | 30.74;10 1147 | 30.74;10 1148 | 30.74;10 1149 | 30.75;10 1150 | 30.75;10 1151 | 30.75;10 1152 | 30.76;10 1153 | 30.76;10 1154 | 30.08;10 1155 | 30.08;10 1156 | 30.09;10 1157 | 30.09;10 1158 | 30.09;10 1159 | 30.1;10 1160 | 30.1;10 1161 | 30.1;10 1162 | 30.1;10 1163 | 30.11;10 1164 | 30.93;10 1165 | 30.93;10 1166 | 30.94;10 1167 | 30.94;10 1168 | 30.94;10 1169 | 30.94;10 1170 | 30.95;10 1171 | 30.95;10 1172 | 30.95;10 1173 | 30.95;10 1174 | 30.05;10 1175 | 30.05;10 1176 | 30.05;10 1177 | 30.06;10 1178 | 30.06;10 1179 | 30.06;10 1180 | 30.06;10 1181 | 30.07;10 1182 | 30.07;10 1183 | 30.07;10 1184 | 30.29;10 1185 | 30.3;10 1186 | 30.3;10 1187 | 30.3;10 1188 | 30.3;10 1189 | 30.31;10 1190 | 30.31;10 1191 | 30.31;10 1192 | 30.31;10 1193 | 30.32;10 1194 | 30.82;10 1195 | 30.82;10 1196 | 30.83;10 1197 | 30.83;10 1198 | 30.83;10 1199 | 30.83;10 1200 | 30.84;10 1201 | 30.84;10 1202 | 30.84;10 1203 | 30.84;10 1204 | 30.43;10 1205 | 30.43;10 1206 | 30.43;10 1207 | 30.43;10 1208 | 30.44;10 1209 | 30.44;10 1210 | 30.44;10 1211 | 30.44;10 1212 | 30.45;10 1213 | 30.45;10 1214 | 30.8;10 1215 | 30.8;10 1216 | 30.8;10 1217 | 30.81;10 1218 | 30.81;10 1219 | 30.81;10 1220 | 30.81;10 1221 | 30.82;10 1222 | 30.82;10 1223 | 30.82;10 1224 | 31.12;10 1225 | 31.13;10 1226 | 31.13;10 1227 | 31.13;10 1228 | 31.13;10 1229 | 31.14;10 1230 | 31.14;10 1231 | 31.14;10 1232 | 31.14;10 1233 | 31.14;10 1234 | 30.44;10 1235 | 30.44;10 1236 | 30.44;10 1237 | 30.44;10 1238 | 30.45;10 1239 | 30.45;10 1240 | 30.45;10 1241 | 30.45;10 1242 | 30.46;10 1243 | 30.46;10 1244 | 31.2;10 1245 | 31.2;10 1246 | 31.2;10 1247 | 31.21;10 1248 | 31.21;10 1249 | 31.21;10 1250 | 31.21;10 1251 | 31.22;10 1252 | 31.22;10 1253 | 31.22;10 1254 | 30.54;10 1255 | 30.54;10 1256 | 30.55;10 1257 | 30.55;10 1258 | 30.55;10 1259 | 30.55;10 1260 | 30.55;10 1261 | 30.56;10 1262 | 30.56;10 1263 | 30.56;10 1264 | 31.02;10 1265 | 31.03;10 1266 | 31.03;10 1267 | 31.03;10 1268 | 31.03;10 1269 | 31.03;10 1270 | 31.04;10 1271 | 31.04;10 1272 | 31.04;10 1273 | 31.04;10 1274 | 31.16;10 1275 | 31.17;10 1276 | 31.17;10 1277 | 31.17;10 1278 | 31.17;10 1279 | 31.17;10 1280 | 31.18;10 1281 | 31.18;10 1282 | 31.18;10 1283 | 31.18;10 1284 | 30.37;10 1285 | 30.38;10 1286 | 30.38;10 1287 | 30.38;10 1288 | 30.38;10 1289 | 30.38;10 1290 | 30.39;10 1291 | 30.39;10 1292 | 30.39;10 1293 | 30.39;10 1294 | 30.54;10 1295 | 30.55;10 1296 | 30.55;10 1297 | 30.55;10 1298 | 30.55;10 1299 | 30.55;10 1300 | 30.56;10 1301 | 30.56;10 1302 | 30.56;10 1303 | 30.56;10 1304 | 30.47;10 1305 | 30.47;10 1306 | 30.48;10 1307 | 30.48;10 1308 | 30.48;10 1309 | 30.48;10 1310 | 30.48;10 1311 | 30.49;10 1312 | 30.49;10 1313 | 30.49;10 1314 | 30.41;10 1315 | 30.41;10 1316 | 30.41;10 1317 | 30.42;10 1318 | 30.42;10 1319 | 30.42;10 1320 | 30.42;10 1321 | 30.42;10 1322 | 30.43;10 1323 | 30.43;10 1324 | 31.32;10 1325 | 31.32;10 1326 | 31.32;10 1327 | 31.32;10 1328 | 31.33;10 1329 | 31.33;10 1330 | 31.33;10 1331 | 31.33;10 1332 | 31.33;10 1333 | 31.33;10 1334 | 30.88;10 1335 | 30.88;10 1336 | 30.88;10 1337 | 30.88;10 1338 | 30.88;10 1339 | 30.89;10 1340 | 30.89;10 1341 | 30.89;10 1342 | 30.89;10 1343 | 30.89;10 1344 | 30.71;10 1345 | 30.72;10 1346 | 30.72;10 1347 | 30.72;10 1348 | 30.72;10 1349 | 30.72;10 1350 | 30.72;10 1351 | 30.73;10 1352 | 30.73;10 1353 | 30.73;10 1354 | 31.39;10 1355 | 31.39;10 1356 | 31.39;10 1357 | 31.4;10 1358 | 31.4;10 1359 | 31.4;10 1360 | 31.4;10 1361 | 31.4;10 1362 | 31.4;10 1363 | 31.4;10 1364 | 31.32;10 1365 | 31.32;10 1366 | 31.32;10 1367 | 31.32;10 1368 | 31.32;10 1369 | 31.32;10 1370 | 31.33;10 1371 | 31.33;10 1372 | 31.33;10 1373 | 31.33;10 1374 | 30.89;10 1375 | 30.89;10 1376 | 30.89;10 1377 | 30.9;10 1378 | 30.9;10 1379 | 30.9;10 1380 | 30.9;10 1381 | 30.9;10 1382 | 30.9;10 1383 | 30.91;10 1384 | 31.34;10 1385 | 31.34;10 1386 | 31.34;10 1387 | 31.34;10 1388 | 31.34;10 1389 | 31.34;10 1390 | 31.35;10 1391 | 31.35;10 1392 | 31.35;10 1393 | 31.35;10 1394 | 31.24;10 1395 | 31.24;10 1396 | 31.24;10 1397 | 31.25;10 1398 | 31.25;10 1399 | 31.25;10 1400 | 31.25;10 1401 | 31.25;10 1402 | 31.25;10 1403 | 31.25;10 1404 | 31.15;10 1405 | 31.15;10 1406 | 31.15;10 1407 | 31.15;10 1408 | 31.15;10 1409 | 31.15;10 1410 | 31.15;10 1411 | 31.16;10 1412 | 31.16;10 1413 | 31.16;10 1414 | 30.63;10 1415 | 30.63;10 1416 | 30.63;10 1417 | 30.63;10 1418 | 30.64;10 1419 | 30.64;10 1420 | 30.64;10 1421 | 30.64;10 1422 | 30.64;10 1423 | 30.64;10 1424 | 31.15;10 1425 | 31.15;10 1426 | 31.16;10 1427 | 31.16;10 1428 | 31.16;10 1429 | 31.16;10 1430 | 31.16;10 1431 | 31.16;10 1432 | 31.16;10 1433 | 31.17;10 1434 | 30.84;10 1435 | 30.84;10 1436 | 30.84;10 1437 | 30.84;10 1438 | 30.84;10 1439 | 30.84;10 1440 | 30.84;10 1441 | 30.85;10 1442 | 30.85;10 1443 | 30.85;10 1444 | 31.54;10 1445 | 31.54;10 1446 | 31.54;10 1447 | 31.54;10 1448 | 31.54;10 1449 | 31.55;10 1450 | 31.55;10 1451 | 31.55;10 1452 | 31.55;10 1453 | 31.55;10 1454 | 31.56;10 1455 | 31.56;10 1456 | 31.56;10 1457 | 31.57;10 1458 | 31.57;10 1459 | 31.57;10 1460 | 31.57;10 1461 | 31.57;10 1462 | 31.57;10 1463 | 31.57;10 1464 | 30.86;10 1465 | 30.86;10 1466 | 30.87;10 1467 | 30.87;10 1468 | 30.87;10 1469 | 30.87;10 1470 | 30.87;10 1471 | 30.87;10 1472 | 30.87;10 1473 | 30.87;10 1474 | 30.99;10 1475 | 30.99;10 1476 | 30.99;10 1477 | 30.99;10 1478 | 30.99;10 1479 | 30.99;10 1480 | 30.99;10 1481 | 30.99;10 1482 | 30.99;10 1483 | 31;10 1484 | 31.43;10 1485 | 31.43;10 1486 | 31.43;10 1487 | 31.43;10 1488 | 31.43;10 1489 | 31.43;10 1490 | 31.43;10 1491 | 31.43;10 1492 | 31.44;10 1493 | 31.44;10 1494 | 31.12;10 1495 | 31.12;10 1496 | 31.12;10 1497 | 31.12;10 1498 | 31.12;10 1499 | 31.12;10 1500 | 31.12;10 1501 | 31.13;10 1502 | 31.13;10 1503 | 31.13;10 1504 | 30.72;10 1505 | 30.72;10 1506 | 30.72;10 1507 | 30.72;10 1508 | 30.72;10 1509 | 30.72;10 1510 | 30.72;10 1511 | 30.73;10 1512 | 30.73;10 1513 | 30.73;10 1514 | 31.49;10 1515 | 31.49;10 1516 | 31.49;10 1517 | 31.49;10 1518 | 31.49;10 1519 | 31.49;10 1520 | 31.5;10 1521 | 31.5;10 1522 | 31.5;10 1523 | 31.5;10 1524 | 31.19;10 1525 | 31.19;10 1526 | 31.19;10 1527 | 31.19;10 1528 | 31.19;10 1529 | 31.19;10 1530 | 31.2;10 1531 | 31.2;10 1532 | 31.2;10 1533 | 31.2;10 1534 | 31.21;10 1535 | 31.21;10 1536 | 31.21;10 1537 | 31.21;10 1538 | 31.21;10 1539 | 31.21;10 1540 | 31.21;10 1541 | 31.22;10 1542 | 31.22;10 1543 | 31.22;10 1544 | 31.46;10 1545 | 31.46;10 1546 | 31.46;10 1547 | 31.46;10 1548 | 31.46;10 1549 | 31.46;10 1550 | 31.46;10 1551 | 31.47;10 1552 | 31.47;10 1553 | 31.47;10 1554 | 30.93;10 1555 | 30.93;10 1556 | 30.93;10 1557 | 30.93;10 1558 | 30.93;10 1559 | 30.93;10 1560 | 30.93;10 1561 | 30.93;10 1562 | 30.94;10 1563 | 30.94;10 1564 | 30.9;10 1565 | 30.9;10 1566 | 30.9;10 1567 | 30.9;10 1568 | 30.9;10 1569 | 30.9;10 1570 | 30.9;10 1571 | 30.9;10 1572 | 30.9;10 1573 | 30.91;10 1574 | 31.02;10 1575 | 31.02;10 1576 | 31.02;10 1577 | 31.02;10 1578 | 31.02;10 1579 | 31.02;10 1580 | 31.02;10 1581 | 31.02;10 1582 | 31.02;10 1583 | 31.02;10 1584 | 31.39;10 1585 | 31.4;10 1586 | 31.4;10 1587 | 31.4;10 1588 | 31.4;10 1589 | 31.4;10 1590 | 31.4;10 1591 | 31.4;10 1592 | 31.4;10 1593 | 31.4;10 1594 | 31.04;10 1595 | 31.04;10 1596 | 31.04;10 1597 | 31.05;10 1598 | 31.05;10 1599 | 31.05;10 1600 | 31.05;10 1601 | 31.05;10 1602 | 31.05;10 1603 | 31.05;10 1604 | 30.91;10 1605 | 30.91;10 1606 | 30.91;10 1607 | 30.91;10 1608 | 30.91;10 1609 | 30.91;10 1610 | 30.92;10 1611 | 30.92;10 1612 | 30.92;10 1613 | 30.92;10 1614 | 31.71;10 1615 | 31.71;10 1616 | 31.71;10 1617 | 31.71;10 1618 | 31.71;10 1619 | 31.71;10 1620 | 31.71;10 1621 | 31.71;10 1622 | 31.71;10 1623 | 31.72;10 1624 | 31.21;10 1625 | 31.21;10 1626 | 31.21;10 1627 | 31.21;10 1628 | 31.21;10 1629 | 31.21;10 1630 | 31.21;10 1631 | 31.21;10 1632 | 31.21;10 1633 | 31.21;10 1634 | 31.35;10 1635 | 31.35;10 1636 | 31.36;10 1637 | 31.36;10 1638 | 31.36;10 1639 | 31.36;10 1640 | 31.36;10 1641 | 31.36;10 1642 | 31.36;10 1643 | 31.36;10 1644 | 30.84;10 1645 | 30.84;10 1646 | 30.84;10 1647 | 30.84;10 1648 | 30.84;10 1649 | 30.84;10 1650 | 30.85;10 1651 | 30.85;10 1652 | 30.85;10 1653 | 30.85;10 1654 | 31.16;10 1655 | 31.16;10 1656 | 31.16;10 1657 | 31.16;10 1658 | 31.16;10 1659 | 31.16;10 1660 | 31.16;10 1661 | 31.16;10 1662 | 31.16;10 1663 | 31.16;10 1664 | 31.04;10 1665 | 31.05;10 1666 | 31.05;10 1667 | 31.05;10 1668 | 31.05;10 1669 | 31.05;10 1670 | 31.05;10 1671 | 31.05;10 1672 | 31.05;10 1673 | 31.05;10 1674 | 31.02;10 1675 | 31.02;10 1676 | 31.02;10 1677 | 31.02;10 1678 | 31.02;10 1679 | 31.02;10 1680 | 31.03;10 1681 | 31.03;10 1682 | 31.03;10 1683 | 31.03;10 1684 | 30.82;10 1685 | 30.82;10 1686 | 30.82;10 1687 | 30.82;10 1688 | 30.82;10 1689 | 30.82;10 1690 | 30.82;10 1691 | 30.82;10 1692 | 30.82;10 1693 | 30.82;10 1694 | 30.96;10 1695 | 30.96;10 1696 | 30.97;10 1697 | 30.97;10 1698 | 30.97;10 1699 | 30.97;10 1700 | 30.97;10 1701 | 30.97;10 1702 | 30.97;10 1703 | 30.97;10 1704 | 30.97;10 1705 | 30.97;10 1706 | 30.97;10 1707 | 30.97;10 1708 | 30.97;10 1709 | 30.97;10 1710 | 30.97;10 1711 | 30.97;10 1712 | 30.97;10 1713 | 30.98;10 1714 | 31.7;10 1715 | 31.7;10 1716 | 31.7;10 1717 | 31.7;10 1718 | 31.7;10 1719 | 31.7;10 1720 | 31.7;10 1721 | 31.7;10 1722 | 31.7;10 1723 | 31.7;10 1724 | 30.86;10 1725 | 30.86;10 1726 | 30.86;10 1727 | 30.86;10 1728 | 30.86;10 1729 | 30.86;10 1730 | 30.87;10 1731 | 30.87;10 1732 | 30.87;10 1733 | 30.87;10 1734 | 31.64;10 1735 | 31.64;10 1736 | 31.64;10 1737 | 31.64;10 1738 | 31.64;10 1739 | 31.64;10 1740 | 31.64;10 1741 | 31.64;10 1742 | 31.64;10 1743 | 31.64;10 1744 | 31.14;10 1745 | 31.14;10 1746 | 31.14;10 1747 | 31.14;10 1748 | 31.14;10 1749 | 31.15;10 1750 | 31.15;10 1751 | 31.15;10 1752 | 31.15;10 1753 | 31.15;10 1754 | 31.17;10 1755 | 31.17;10 1756 | 31.17;10 1757 | 31.17;10 1758 | 31.17;10 1759 | 31.17;10 1760 | 31.17;10 1761 | 31.17;10 1762 | 31.17;10 1763 | 31.17;10 1764 | 31.54;10 1765 | 31.54;10 1766 | 31.54;10 1767 | 31.54;10 1768 | 31.55;10 1769 | 31.55;10 1770 | 31.55;10 1771 | 31.55;10 1772 | 31.55;10 1773 | 31.55;10 1774 | 31.31;10 1775 | 31.31;10 1776 | 31.31;10 1777 | 31.31;10 1778 | 31.31;10 1779 | 31.31;10 1780 | 31.31;10 1781 | 31.31;10 1782 | 31.31;10 1783 | 31.31;10 1784 | 30.88;10 1785 | 30.88;10 1786 | 30.88;10 1787 | 30.88;10 1788 | 30.88;10 1789 | 30.89;10 1790 | 30.89;10 1791 | 30.89;10 1792 | 30.89;10 1793 | 30.89;10 1794 | 30.93;10 1795 | 30.93;10 1796 | -------------------------------------------------------------------------------- /pypidtune/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Updated and maintained by pidtuningireland@gmail.com 4 | Copyright 2024 pidtuningireland 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 os 18 | import csv 19 | import time 20 | import threading 21 | from datetime import datetime 22 | import numpy as np 23 | import ttkbootstrap as ttk 24 | import matplotlib.pyplot as plt 25 | from matplotlib import animation 26 | from pylogix import PLC 27 | from asyncua.sync import Client, ua 28 | import common 29 | 30 | 31 | class PIDLogger: 32 | """ 33 | PIDLogger is responsible for setting up PLC communication and initializing the GUI 34 | for monitoring and logging PID controller data. 35 | """ 36 | 37 | def __init__(self, **kwargs): 38 | """ 39 | Parameters: 40 | ----------- 41 | `**kwargs`: dict 42 | A dictionary of optional parameters for configuration. 43 | 44 | Keyword Arguments: 45 | ------------------ 46 | - `comm_type` : str, optional 47 | 48 | The type of communication to use, either 'logix' or 'opc'. Defaults to 'logix'. 49 | 50 | - `init_logix_ip_address` : str, optional 51 | 52 | The initial IP address for Logix communication. Defaults to a predefined constant. 53 | 54 | - `init_logix_slot` : int, optional 55 | 56 | The initial slot for Logix communication. Defaults to a predefined constant. 57 | 58 | - `init_opc_address` : str, optional 59 | 60 | The initial address for OPC communication. Defaults to a predefined constant. 61 | 62 | - `init_sp_tag` : str, optional 63 | 64 | The initial setpoint tag for the PID controller. Defaults to a predefined constant. 65 | 66 | - `init_pv_tag` : str, optional 67 | 68 | The initial process variable tag for the PID controller. Defaults to a predefined constant. 69 | 70 | - `init_cv_tag` : str, optional 71 | 72 | The initial control variable tag for the PID controller. Defaults to a predefined constant. 73 | """ 74 | # Initialize instance attributes based on provided or default values 75 | self.comm_type = kwargs.get("comm_type", "logix") 76 | self.init_logix_ip_address = kwargs.get("init_logix_ip_address", common.INITIAL_LOGIX_IP_ADDRESS) 77 | self.init_logix_slot = kwargs.get("init_logix_slot", common.INITIAL_LOGIX_SLOT) 78 | self.init_opc_address = kwargs.get("init_opc_address", common.INITIAL_OPC_ADDRESS) 79 | self.init_sp_tag = kwargs.get("init_sp_tag", common.INITIAL_SP_TAG) 80 | self.init_pv_tag = kwargs.get("init_pv_tag", common.INITIAL_PV_TAG) 81 | self.init_cv_tag = kwargs.get("init_cv_tag", common.INITIAL_CV_TAG) 82 | 83 | # Initialize GUI setup, initial setup, and empty plot setup 84 | self._build_gui() 85 | self._initial_setup() 86 | self._empty_plot_setup() 87 | 88 | def _build_gui(self): 89 | """ 90 | Set up the graphical user interface (GUI) elements using ttkbootstrap. 91 | """ 92 | plt.style.use("bmh") 93 | self.root = ttk.Window() 94 | self.root.title("PID Data Logger - PID Tuning Ireland©") 95 | # Adjust theme to suit 96 | style = ttk.Style(theme="yeti") 97 | style.theme.colors.bg = "#c0c0c0" 98 | self.screen_width = self.root.winfo_screenwidth() 99 | self.screen_height = self.root.winfo_screenheight() 100 | self.offset = 7 101 | self.toolbar = 73 102 | 103 | # Configure GUI window size and appearance 104 | self.root.resizable(True, True) 105 | self.root.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{-self.offset}+0") 106 | 107 | self.main_frame = ttk.Frame(self.root) 108 | self.main_frame.pack(expand=True, fill=ttk.BOTH) 109 | 110 | # Define various variables to be used in the GUI 111 | self.sp_value = ttk.DoubleVar() 112 | self.pv_value = ttk.DoubleVar() 113 | self.cv_value = ttk.DoubleVar() 114 | 115 | self.read_count = ttk.IntVar() 116 | self.error_count = ttk.IntVar() 117 | self.gui_status = ttk.StringVar() 118 | 119 | self.sp_plc_tag = ttk.StringVar(value=self.init_sp_tag) 120 | self.pv_plc_tag = ttk.StringVar(value=self.init_pv_tag) 121 | self.cv_plc_tag = ttk.StringVar(value=self.init_cv_tag) 122 | 123 | self.sp_write_value = ttk.StringVar() 124 | self.cv_write_value = ttk.StringVar() 125 | 126 | self.slot = ttk.IntVar(value=self.init_logix_slot) 127 | 128 | if self.comm_type == "logix": 129 | self.plc_address = ttk.StringVar(value=self.init_logix_ip_address) 130 | elif self.comm_type == "opc": 131 | self.plc_address = ttk.StringVar(value=self.init_opc_address) 132 | 133 | self.delta_t = ttk.DoubleVar(value=100) 134 | self.file_name = ttk.StringVar(value=r"D:\Trend.csv") 135 | 136 | self.tags_frame = ttk.LabelFrame(self.main_frame, padding=5, text="Control") 137 | self.tags_frame.pack(expand=True, fill=ttk.BOTH, padx=5, pady=5) 138 | self.settings_frame = ttk.LabelFrame(self.main_frame, padding=5, text="Settings") 139 | self.settings_frame.pack(expand=True, fill=ttk.BOTH, padx=5, pady=5) 140 | 141 | # Column 0: Labels 142 | ttk.Label(self.tags_frame, text="Tag").grid(row=0, column=0, padx=10, pady=10, sticky=ttk.W) 143 | ttk.Label(self.tags_frame, text="SP:").grid(row=1, column=0, padx=10, pady=10, sticky=ttk.W) 144 | ttk.Label(self.tags_frame, text="PV:").grid(row=2, column=0, padx=10, pady=10, sticky=ttk.W) 145 | ttk.Label(self.tags_frame, text="CV:").grid(row=3, column=0, padx=10, pady=10, sticky=ttk.W) 146 | 147 | # Column 1: PLC Tags 148 | ttk.Label(self.tags_frame, text="PLC Tag").grid(row=0, column=1, padx=10, pady=10, sticky=ttk.W) 149 | self.entry_sp_tag = ttk.Entry(self.tags_frame, textvariable=self.sp_plc_tag) 150 | self.entry_sp_tag.grid(row=1, column=1, padx=10, pady=10, sticky=ttk.NSEW) 151 | self.entry_pv_tag = ttk.Entry(self.tags_frame, textvariable=self.pv_plc_tag) 152 | self.entry_pv_tag.grid(row=2, column=1, padx=10, pady=10, sticky=ttk.NSEW) 153 | self.entry_cv_tag = ttk.Entry(self.tags_frame, textvariable=self.cv_plc_tag) 154 | self.entry_cv_tag.grid(row=3, column=1, padx=10, pady=10, sticky=ttk.NSEW) 155 | 156 | # Column 2: Actual Values 157 | ttk.Label(self.tags_frame, text=" Value ").grid(row=0, column=2, padx=10, pady=10) 158 | ttk.Label(self.tags_frame, textvariable=self.sp_value).grid(row=1, column=2, padx=10, pady=10) 159 | ttk.Label(self.tags_frame, textvariable=self.pv_value).grid(row=2, column=2, padx=10, pady=10) 160 | ttk.Label(self.tags_frame, textvariable=self.cv_value).grid(row=3, column=2, padx=10, pady=10) 161 | 162 | # Column 4: Send - Write Values 163 | ttk.Label(self.tags_frame, text="Write to PLC").grid(row=0, column=4, padx=10, pady=10, sticky=ttk.NSEW) 164 | self.entry_sp_write = ttk.Entry(self.tags_frame, textvariable=self.sp_write_value) 165 | self.entry_sp_write.grid(row=1, column=4, padx=10, pady=10, sticky=ttk.NSEW) 166 | self.entry_cv_write = ttk.Entry(self.tags_frame, textvariable=self.cv_write_value) 167 | self.entry_cv_write.grid(row=3, column=4, padx=10, pady=10, sticky=ttk.NSEW) 168 | 169 | # Buttons 170 | self.button_start = ttk.Button(self.tags_frame, text="Record Data", command=lambda: [self.start()]) 171 | self.button_start.grid(row=4, column=1, columnspan=2, padx=10, pady=10, sticky=ttk.NSEW) 172 | 173 | self.button_livetrend = ttk.Button(self.tags_frame, text="Live Plot", command=lambda: [self.show_live_trend()]) 174 | self.button_livetrend.grid(row=4, column=3, columnspan=1, padx=10, pady=10, sticky=ttk.NSEW) 175 | self.button_livetrend.configure(state=ttk.DISABLED) 176 | 177 | self.button_write = ttk.Button(self.tags_frame, text="Write", command=lambda: [self.write_to_PLC()]) 178 | self.button_write.grid(row=4, column=4, columnspan=1, padx=10, pady=10, sticky=ttk.NSEW) 179 | 180 | self.button_stop = ttk.Button(self.tags_frame, text="Stop Recording", command=lambda: [self.stop()]) 181 | self.button_stop.grid(row=5, column=1, columnspan=3, padx=10, pady=10, sticky=ttk.NSEW) 182 | self.button_stop.configure(state=ttk.DISABLED) 183 | 184 | self.button_show_file_data = ttk.Button(self.tags_frame, text="Plot Data From CSV", command=lambda: [self.open_trend_from_file()]) 185 | self.button_show_file_data.grid(row=5, column=4, columnspan=1, padx=10, pady=10, sticky=ttk.NSEW) 186 | 187 | # Settings 188 | ttk.Label(self.settings_frame, text="PLC Address:").grid(row=0, column=0, padx=10, pady=10, sticky=ttk.W) 189 | self.entry_plc_address = ttk.Entry(self.settings_frame, textvariable=self.plc_address) 190 | self.entry_plc_address.grid(row=0, column=1, columnspan=2, padx=10, pady=10, sticky=ttk.NSEW) 191 | 192 | ttk.Label(self.settings_frame, text="PLC Slot:").grid(row=1, column=0, padx=10, pady=10, sticky=ttk.W) 193 | self.entry_slot = ttk.Entry(self.settings_frame, textvariable=self.slot, width=6) 194 | self.entry_slot.grid(row=1, column=1, padx=10, pady=10, sticky=ttk.W) 195 | 196 | ttk.Label(self.settings_frame, text="Interval (mS):").grid(row=2, column=0, padx=10, pady=10, sticky=ttk.W) 197 | self.entry_delta_t = ttk.Entry(self.settings_frame, textvariable=self.delta_t, width=6) 198 | self.entry_delta_t.grid(row=2, column=1, columnspan=1, padx=10, pady=10, sticky=ttk.W) 199 | 200 | ttk.Label(self.settings_frame, text="File Name:").grid(row=3, column=0, padx=10, pady=10, sticky=ttk.W) 201 | self.entry_file_name = ttk.Entry(self.settings_frame, textvariable=self.file_name) 202 | self.entry_file_name.grid(row=3, column=1, columnspan=3, padx=10, pady=10, sticky=ttk.NSEW) 203 | 204 | ttk.Label(self.settings_frame, text="Read Count:").grid(row=4, column=0, padx=10, pady=10, sticky=ttk.W) 205 | ttk.Label(self.settings_frame, textvariable=self.read_count).grid(row=4, column=1, padx=10, pady=10, sticky=ttk.W) 206 | 207 | ttk.Label(self.settings_frame, text="Error Count:").grid(row=5, column=0, padx=10, pady=10, sticky=ttk.W) 208 | ttk.Label(self.settings_frame, textvariable=self.error_count).grid(row=5, column=1, padx=10, pady=10, sticky=ttk.W) 209 | 210 | ttk.Label(self.settings_frame, text="Status:").grid(row=6, column=0, padx=10, pady=10, sticky=ttk.W) 211 | ttk.Label(self.settings_frame, textvariable=self.gui_status).grid(row=6, column=1, columnspan=5, padx=10, pady=10, sticky=ttk.W) 212 | 213 | def _initial_setup(self): 214 | """ 215 | Initialize necessary resources like threads, PLC/OPC communication objects, 216 | CSV file handling, and client connections. 217 | """ 218 | self.thread_stop_event = threading.Event() 219 | self.thread_lock = threading.Lock() 220 | self.PV = np.zeros(0) 221 | self.CV = np.zeros(0) 222 | self.SP = np.zeros(0) 223 | self.looped_thread_for_data_retrieval = None 224 | self.csv_file = None 225 | self.csv_file_writer = None 226 | self.comm = None 227 | self.comm_write = None 228 | self.anim = None 229 | self.scale = None 230 | if self.comm_type == "logix": 231 | self.comm = PLC(ip_address=self.plc_address.get(), slot=self.slot.get()) 232 | elif self.comm_type == "opc": 233 | self.comm = Client(self.plc_address.get()) 234 | 235 | def _empty_plot_setup(self): 236 | """ 237 | Configure an empty plot window using matplotlib. 238 | """ 239 | self.fig = plt.figure(num=1) 240 | self.ax = plt.axes() 241 | (self.plot_SP,) = self.ax.plot([], [], color="goldenrod", linewidth=2, label="SP") 242 | (self.plot_CV,) = self.ax.plot([], [], color="darkgreen", linewidth=2, label="CV") 243 | (self.plot_PV,) = self.ax.plot([], [], color="blue", linewidth=2, label="PV") 244 | plt.ylabel("Value") 245 | plt.xlabel("Time (minutes)") 246 | plt.suptitle("Live Data") 247 | plt.legend(loc="upper right") 248 | self.scale = int(60000 / int(self.delta_t.get())) 249 | mngr = plt.get_current_fig_manager() 250 | mngr.window.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{int(self.screen_width/2)-self.offset+1}+0") 251 | plt.gcf().canvas.mpl_connect("close_event", self._on_plot_close) 252 | plt.show(block=False) 253 | 254 | def start(self): 255 | """ 256 | Start data recording process: 257 | - Clear previous data 258 | - Update GUI state 259 | - Start a new thread for Pre-Flight checks 260 | """ 261 | self.thread_stop_event.clear() 262 | self.PV = np.zeros(0) 263 | self.CV = np.zeros(0) 264 | self.SP = np.zeros(0) 265 | self.error_count.set(0) 266 | self.read_count.set(0) 267 | self.gui_status.set("") 268 | self.button_stop.configure(state=ttk.NORMAL) 269 | self.button_start.configure(state=ttk.DISABLED) 270 | self._change_gui_state(ttk.DISABLED) 271 | thread = threading.Thread(target=self.pre_flight_checks, name="Pre_Flight_Checks_Thread", daemon=True) 272 | thread.start() 273 | 274 | def _process_pre_flight_result(self, pre_flight_checks_completed): 275 | if pre_flight_checks_completed: 276 | self.looped_thread_for_data_retrieval = common.PeriodicInterval(self.data_retrieval, int(self.delta_t.get()) / 1000) 277 | if not 1 in plt.get_fignums(): 278 | self._empty_plot_setup() 279 | self.live_trend() 280 | 281 | elif not pre_flight_checks_completed or self.thread_stop_event.is_set(): 282 | self.button_stop.configure(state=ttk.DISABLED) 283 | self.button_start.configure(state=ttk.NORMAL) 284 | self._change_gui_state(ttk.NORMAL) 285 | 286 | def pre_flight_checks(self): 287 | """ 288 | Perform pre-flight checks before starting data recording: 289 | - Test PLC/OPC communication 290 | - Open CSV file for data logging 291 | - Verify tag names and configuration 292 | """ 293 | 294 | # Check PLC communication settings and prepare for recording 295 | try: 296 | pre_flight_checks_completed = False 297 | self.tag_list = [self.sp_plc_tag.get(), self.pv_plc_tag.get(), self.cv_plc_tag.get()] 298 | if self.comm_type == "logix": 299 | self.comm = PLC(ip_address=self.plc_address.get(), slot=self.slot.get()) 300 | self.comm.IPAddress = self.plc_address.get() 301 | self.comm.ProcessorSlot = self.slot.get() 302 | self.comm.SocketTimeout = 10.0 303 | test_comms = self.comm.GetPLCTime() 304 | if test_comms.Status != "Success": 305 | raise Exception("Cannot Read Logix Clock - Check Configuration") 306 | elif self.comm_type == "opc": 307 | try: 308 | self.comm = Client(url=self.plc_address.get()) 309 | self.comm.connect() 310 | except: 311 | raise Exception("Failed to Create OPC Connection - Check Configuration") 312 | 313 | if self.thread_stop_event.is_set(): 314 | return 315 | 316 | # Open CSV File 317 | self.csv_file = open(self.file_name.get(), "a") 318 | self.csv_file_writer = csv.writer(self.csv_file, delimiter=";", lineterminator="\n", quotechar="/", quoting=csv.QUOTE_MINIMAL) 319 | if os.stat(self.file_name.get()).st_size == 0: 320 | self.csv_file_writer.writerow(("SP", "PV", "CV", "TimeStamp")) 321 | 322 | if self.comm_type == "logix": 323 | ret_response = self.comm.Read(self.tag_list) 324 | ret = [x.Value for x in ret_response] 325 | self.comm.SocketTimeout = sorted([0.1, self.delta_t.get() * 1.1 / 1000, 5.0])[1] 326 | 327 | elif self.comm_type == "opc": 328 | self.tag_list = [self.comm.get_node(x) for x in self.tag_list] 329 | ret = self.comm.read_values(self.tag_list) 330 | 331 | if any(value is None for value in ret): 332 | raise Exception("Tag Name Incorrect - Check Configuration") 333 | 334 | if self.thread_stop_event.is_set(): 335 | return 336 | 337 | except Exception as e: 338 | current_date_time = datetime.now().strftime("%d-%m-%Y %H:%M:%S") 339 | self.gui_status.set(f"{current_date_time} - Pre-Flight Error: {str(e)}") 340 | 341 | else: 342 | pre_flight_checks_completed = True 343 | 344 | finally: 345 | self.root.after(0, self._process_pre_flight_result, pre_flight_checks_completed) 346 | 347 | def data_retrieval(self): 348 | """ 349 | Retrieve data from PLC/OPC, update GUI values, write data to CSV, 350 | and handle exceptions during data retrieval. 351 | """ 352 | try: 353 | # Read data from PLC, update GUI, and write to CSV 354 | if self.comm_type == "logix": 355 | ret_response = self.comm.Read(self.tag_list) 356 | ret = [x.Value for x in ret_response] 357 | elif self.comm_type == "opc": 358 | ret = self.comm.read_values(self.tag_list) 359 | 360 | gui_tags = [self.sp_value, self.pv_value, self.cv_value] 361 | 362 | if self.thread_stop_event.is_set(): 363 | return 364 | 365 | if all(x is not None for x in ret): 366 | current_date_time_ms = np.datetime64("now", "ms") 367 | for tag, value in zip(gui_tags, ret): 368 | tag.set(round(value, 3)) 369 | self.csv_file_writer.writerow(ret + [current_date_time_ms]) 370 | self.SP, self.PV, self.CV = np.append(self.SP, ret[0]), np.append(self.PV, ret[1]), np.append(self.CV, ret[2]) 371 | self.read_count.set(self.read_count.get() + 1) 372 | else: 373 | self.error_count.set(self.error_count.get() + 1) 374 | current_date_time = datetime.now().strftime("%d-%m-%Y %H:%M:%S") 375 | self.gui_status.set(f"{current_date_time} - Error Getting Data") 376 | 377 | except Exception as e: 378 | current_date_time = datetime.now().strftime("%d-%m-%Y %H:%M:%S") 379 | self.gui_status.set(f"{current_date_time} - Error: {str(e)}") 380 | 381 | def write_to_PLC(self): 382 | """ 383 | Write setpoint (SP) and control variable (CV) values to the PLC/OPC server. 384 | """ 385 | if self.sp_write_value.get() == "" and self.cv_write_value.get() == "": 386 | self.gui_status.set("No Values to Write") 387 | return 388 | 389 | try: 390 | # Write values to the PLC 391 | if not self.comm_write: 392 | if self.comm_type == "logix": 393 | self.comm_write = PLC() 394 | self.comm_write.IPAddress = self.plc_address.get() 395 | self.comm_write.ProcessorSlot = self.slot.get() 396 | self.comm_write.SocketTimeout = 1 397 | elif self.comm_type == "opc": 398 | self.comm_write = Client(url=self.plc_address.get()) 399 | self.comm_write.connect() 400 | 401 | if self.sp_write_value.get() != "": 402 | sp = float(self.sp_write_value.get()) 403 | if self.comm_type == "logix": 404 | ret = self.comm_write.Write(self.sp_plc_tag.get(), sp) 405 | if ret.Status != "Success": 406 | raise Exception(ret.TagName + " " + ret.Status) 407 | elif self.comm_type == "opc": 408 | tag = self.comm_write.get_node(self.sp_plc_tag.get()) 409 | tag.write_attribute(ua.AttributeIds.Value, ua.DataValue(float(sp))) 410 | 411 | if self.cv_write_value.get() != "": 412 | cv = float(self.cv_write_value.get()) 413 | if self.comm_type == "logix": 414 | ret = self.comm_write.Write(self.sp_plc_tag.get(), cv) 415 | if ret.Status != "Success": 416 | raise Exception(ret.TagName + " " + ret.Status) 417 | elif self.comm_type == "opc": 418 | tag = self.comm_write.get_node(self.cv_plc_tag.get()) 419 | tag.write_attribute(ua.AttributeIds.Value, ua.DataValue(float(cv))) 420 | 421 | except Exception as e: 422 | self.gui_status.set("Write Error: " + str(e)) 423 | 424 | else: 425 | self.gui_status.set("Data sent to PLC") 426 | 427 | finally: 428 | if self.comm_type == "logix": 429 | self.comm_write.Close() 430 | elif self.comm_type == "opc": 431 | self.comm_write.disconnect() 432 | 433 | def live_trend(self): 434 | """ 435 | Set up a live trend plot using matplotlib, continuously updating 436 | with setpoint, process variable, and control variable data. 437 | """ 438 | 439 | # Set up a live trend plot 440 | def init(): 441 | if not 1 in plt.get_fignums(): 442 | self._empty_plot_setup() 443 | 444 | def animate(i): 445 | with self.thread_lock: 446 | x = np.arange(len(self.SP)) / self.scale 447 | if len(x) == len(self.SP) == len(self.PV) == len(self.CV): 448 | self.plot_SP.set_data(x, self.SP) 449 | self.plot_CV.set_data(x, self.CV) 450 | self.plot_PV.set_data(x, self.PV) 451 | self.ax.relim() 452 | self.ax.autoscale_view() 453 | 454 | self.anim = animation.FuncAnimation(self.fig, animate, init_func=init, frames=60, interval=1000) 455 | plt.show(block=False) 456 | 457 | def _on_plot_close(self, event): 458 | """ 459 | Stop Animation when the Live Plot is closed. 460 | Enable Button to allow plot to be re-opened. 461 | """ 462 | if self.anim: 463 | self.anim.event_source.stop() 464 | self.anim = None 465 | if self.looped_thread_for_data_retrieval: 466 | self.button_livetrend.configure(state=ttk.NORMAL) 467 | 468 | def show_live_trend(self): 469 | """ 470 | Control the display of the live trend plot window. 471 | """ 472 | self.button_livetrend.configure(state=ttk.DISABLED) 473 | plot_open = 1 in plt.get_fignums() 474 | if not plot_open: 475 | self._empty_plot_setup() 476 | self.live_trend() 477 | 478 | def open_trend_from_file(self): 479 | self.button_show_file_data.configure(state=ttk.DISABLED) 480 | thread = threading.Thread(target=self._open_trend_from_file_thread, name="Parse_File_Thread", daemon=True) 481 | thread.start() 482 | 483 | def _open_trend_from_file_thread(self): 484 | """ 485 | Read data from a CSV file and plot setpoint, process variable, and control 486 | variable trends using matplotlib. 487 | """ 488 | try: 489 | headers, timestamps, values = None, None, None 490 | if self.looped_thread_for_data_retrieval: 491 | self.csv_file.flush() 492 | 493 | # Read CSV file into a numpy array 494 | with open(self.file_name.get(), "r", encoding="utf-8") as historical_csv_file: 495 | csvreader = csv.reader(historical_csv_file, delimiter=";") 496 | headers = next(csvreader) 497 | data = np.array(list(csvreader)) 498 | 499 | # Convert the timestamp column to datetime 500 | timestamps = np.array(data[:, -1], dtype="datetime64[ms]") 501 | # Convert other columns to float 502 | values = data[:, :-1].astype(float) 503 | 504 | except Exception as e: 505 | self.gui_status.set("CSV Read Error: " + str(e)) 506 | 507 | finally: 508 | self.root.after(0, self._process_historical_plot, headers, timestamps, values) 509 | 510 | def _process_historical_plot(self, headers=None, timestamps=None, values=None): 511 | """ 512 | Show plot 513 | """ 514 | self.button_show_file_data.configure(state=ttk.NORMAL) 515 | 516 | if headers is not None and timestamps is not None and values is not None: 517 | # Set plot location on screen 518 | plt.figure(num=2) 519 | plt.clf() 520 | plt.gca().legend([]) 521 | mngr = plt.get_current_fig_manager() 522 | mngr.window.geometry(f"{int(self.screen_width/2)}x{self.screen_height-self.toolbar}+{int(self.screen_width/2)-self.offset}+0") 523 | 524 | # Plot each column 525 | plt.plot(timestamps, values[:, 0], color="blue", linewidth=2, label=headers[0]) 526 | plt.plot(timestamps, values[:, 1], color="red", linewidth=2, label=headers[1]) 527 | plt.plot(timestamps, values[:, 2], color="darkgreen", linewidth=2, label=headers[2]) 528 | 529 | # Customize plot 530 | plt.ylabel("Value") 531 | plt.xlabel("Time") 532 | plt.title(self.file_name.get()) 533 | plt.legend(loc="upper right") 534 | plt.gcf().autofmt_xdate() 535 | 536 | # Show the plot 537 | plt.show(block=False) 538 | 539 | def _change_gui_state(self, req_state): 540 | """ 541 | Modify the state (enabled or disabled) of GUI input elements. 542 | """ 543 | self.entry_sp_tag.configure(state=req_state) 544 | self.entry_pv_tag.configure(state=req_state) 545 | self.entry_cv_tag.configure(state=req_state) 546 | self.entry_delta_t.configure(state=req_state) 547 | self.entry_plc_address.configure(state=req_state) 548 | self.entry_slot.configure(state=req_state) 549 | self.entry_file_name.configure(state=req_state) 550 | 551 | def stop(self): 552 | """ 553 | Stop the data recording process, reset GUI elements, close files, 554 | disconnect from PLC/OPC server, and handle errors during shutdown. 555 | """ 556 | try: 557 | # Stop data recording, reset GUI elements, and close files 558 | self.thread_stop_event.set() 559 | time.sleep(self.delta_t.get() / 1000) 560 | if self.looped_thread_for_data_retrieval: 561 | self.looped_thread_for_data_retrieval.stop() 562 | self.looped_thread_for_data_retrieval = None 563 | 564 | self._change_gui_state(req_state=ttk.NORMAL) 565 | self.button_livetrend.configure(state=ttk.DISABLED) 566 | self.button_start.configure(state=ttk.NORMAL) 567 | 568 | if self.csv_file: 569 | if not self.csv_file.closed: 570 | self.csv_file.close() 571 | 572 | self.anim = None 573 | if self.comm: 574 | if self.comm_type == "logix": 575 | self.comm.Close() 576 | elif self.comm_type == "opc": 577 | self.comm.disconnect() 578 | 579 | except Exception as e: 580 | self.gui_status.set("Stop Error: " + str(e)) 581 | 582 | else: 583 | self.button_stop.configure(state=ttk.DISABLED) 584 | 585 | 586 | if __name__ == "__main__": 587 | logger_app = PIDLogger() 588 | logger_app.root.mainloop() 589 | --------------------------------------------------------------------------------