├── .gitignore ├── README.md ├── __init__.py ├── controllers ├── __init__.py ├── pid.py └── pidnn.py ├── main.py ├── plants ├── __init__.py └── motor_position.py ├── requirements.txt └── utils ├── __init__.py └── evaluator.py /.gitignore: -------------------------------------------------------------------------------- 1 | # VSCode 2 | .vscode 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neural_pid 2 | Adaptive PID neural network controller implementation in Python. Tested in Python 3.9.7. 3 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/titoirfan/neural_pid/e8a1c24ae85fc2796478349c24d052998b60965e/__init__.py -------------------------------------------------------------------------------- /controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/titoirfan/neural_pid/e8a1c24ae85fc2796478349c24d052998b60965e/controllers/__init__.py -------------------------------------------------------------------------------- /controllers/pid.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains the classic PID controller implementation. 3 | """ 4 | 5 | __author__ = "irfantitok@gmail.com" 6 | 7 | import numpy as np 8 | 9 | class PID(object): 10 | """ 11 | Classic PID Controller 12 | """ 13 | def __init__( 14 | self, 15 | constants: tuple, 16 | timestep: float) -> None: 17 | # Timestep 18 | self.timestep = timestep 19 | 20 | # Hidden layer connection weights 21 | self.hidden_weights = np.array([[-1, -1, -1], [1, 1, 1]], dtype=float) 22 | # Output layer connection weights 23 | self.output_weights = np.array(constants, dtype=float) 24 | 25 | # Indexed according to [t, t - 1] 26 | # System feedback vector 27 | self.y = np.zeros([2], dtype=float) 28 | # Reference vector 29 | self.r = np.zeros([2], dtype=float) 30 | 31 | # Feedback input vector 32 | self.input_y = np.zeros([2], dtype=float) 33 | # Reference input vector 34 | self.input_r = np.zeros([2], dtype=float) 35 | 36 | # Hidden layer inputs 37 | self.hidden_input_p = np.zeros([2], dtype=float) 38 | self.hidden_input_i = np.zeros([2], dtype=float) 39 | self.hidden_input_d = np.zeros([2], dtype=float) 40 | 41 | # Hidden layer outputs 42 | self.hidden_output_p = np.zeros([2], dtype=float) 43 | self.hidden_output_i = np.zeros([2], dtype=float) 44 | self.hidden_output_d = np.zeros([2], dtype=float) 45 | 46 | # Control effort vector 47 | self.v = np.zeros([2], dtype=float) 48 | 49 | def p_transfer_function(self, v: float) -> float: 50 | return v 51 | 52 | def i_transfer_function(self, v: float, accumulated_v: float) -> float: 53 | return v * self.timestep + accumulated_v 54 | 55 | def d_transfer_function(self, v: float, past_v: float) -> float: 56 | return (v - past_v) / self.timestep 57 | 58 | def predict(self, reference: float, feedback: float) -> float: 59 | # Update variable history 60 | self.y[1] = self.y[0] 61 | self.r[1] = self.r[0] 62 | self.y[0] = feedback 63 | self.r[0] = reference 64 | 65 | self.input_y[1] = self.input_y[0] 66 | self.input_r[1] = self.input_r[0] 67 | self.hidden_input_p[1] = self.hidden_input_p[0] 68 | self.hidden_input_i[1] = self.hidden_input_i[0] 69 | self.hidden_input_d[1] = self.hidden_input_d[0] 70 | self.hidden_output_p[1] = self.hidden_output_p[0] 71 | self.hidden_output_i[1] = self.hidden_output_i[0] 72 | self.hidden_output_d[1] = self.hidden_output_d[0] 73 | 74 | self.v[1] = self.v[0] 75 | 76 | # Calculate input neuron outputs 77 | self.input_y[0] = self.p_transfer_function(feedback) 78 | self.input_r[0] = self.p_transfer_function(reference) 79 | 80 | # Calculate hidden P neurons outputs 81 | self.hidden_input_p[0] = ( 82 | self.input_y[0] * self.hidden_weights[0][0] 83 | + self.input_r[0] * self.hidden_weights[1][0]) 84 | self.hidden_output_p[0] = self.p_transfer_function(self.hidden_input_p[0]) 85 | 86 | # Calculate hidden I neurons outputs 87 | self.hidden_input_i[0] = ( 88 | self.input_y[0] * self.hidden_weights[0][1] 89 | + self.input_r[0] * self.hidden_weights[1][1]) 90 | self.hidden_output_i[0] = self.i_transfer_function( 91 | self.hidden_input_i[0], self.hidden_output_i[1]) 92 | 93 | # Calculate hidden D neurons outputs 94 | self.hidden_input_d[0] = ( 95 | self.input_y[0] * self.hidden_weights[0][2] 96 | + self.input_r[0] * self.hidden_weights[1][2]) 97 | self.hidden_output_d[0] = self.d_transfer_function( 98 | self.hidden_input_d[0], self.hidden_input_d[1]) 99 | 100 | # Calculate output neuron outputs 101 | self.v[0] = ( 102 | self.hidden_output_p[0] * self.output_weights[0] 103 | + self.hidden_output_i[0] * self.output_weights[1] 104 | + self.hidden_output_d[0] * self.output_weights[2]) 105 | 106 | return self.v[0] 107 | -------------------------------------------------------------------------------- /controllers/pidnn.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains the adaptive PID neural network 3 | controller implementation. 4 | """ 5 | 6 | __author__ = "irfantitok@gmail.com" 7 | 8 | import math 9 | import numpy as np 10 | from typing import Any 11 | 12 | class PIDNN(object): 13 | """ 14 | Adaptive PID Neural Network Controller 15 | """ 16 | def __init__( 17 | self, 18 | initial_constants: tuple, 19 | learning_rate: float, 20 | max_weight_change: float, 21 | tolerance: float, 22 | timestep: float) -> None: 23 | # The network learning rate 24 | self.eta = learning_rate 25 | # Error tolerance 26 | self.tol = tolerance 27 | # Maximum weight change 28 | self.max_weight_chg = max_weight_change 29 | # Maximum tolerance for weight change 30 | self.weight_chg_tol = self.max_weight_chg * math.log(2) / self.eta 31 | # Division by zero tolerance 32 | self.div_by_zero_tol = 1e-20 33 | # Timestep 34 | self.timestep = timestep 35 | 36 | # Hidden layer connection weights 37 | self.hidden_weights = np.array([[-1, -1, -1], [1, 1, 1]], dtype=float) 38 | # Output layer connection weights 39 | self.output_weights = np.array(initial_constants, dtype=float) 40 | 41 | # Indexed according to [t, t - 1] 42 | # System feedback vector 43 | self.y = np.zeros([2], dtype=float) 44 | # Reference vector 45 | self.r = np.zeros([2], dtype=float) 46 | 47 | # Feedback input vector 48 | self.input_y = np.zeros([2], dtype=float) 49 | # Reference input vector 50 | self.input_r = np.zeros([2], dtype=float) 51 | 52 | # Hidden layer inputs 53 | self.hidden_input_p = np.zeros([2], dtype=float) 54 | self.hidden_input_i = np.zeros([2], dtype=float) 55 | self.hidden_input_d = np.zeros([2], dtype=float) 56 | 57 | # Hidden layer outputs 58 | self.hidden_output_p = np.zeros([2], dtype=float) 59 | self.hidden_output_i = np.zeros([2], dtype=float) 60 | self.hidden_output_d = np.zeros([2], dtype=float) 61 | 62 | # Control effort vector 63 | self.v = np.zeros([2], dtype=float) 64 | 65 | def threshold_div_by_zero(self, value: float) -> float: 66 | if math.fabs(value) < self.div_by_zero_tol: 67 | if value >= 0: 68 | return self.div_by_zero_tol 69 | else: 70 | return -self.div_by_zero_tol 71 | return value 72 | 73 | def p_transfer_function(self, v: float) -> float: 74 | return v 75 | 76 | def i_transfer_function(self, v: float, accumulated_v: float) -> float: 77 | return v * self.timestep + accumulated_v 78 | 79 | def d_transfer_function(self, v: float, past_v: float) -> float: 80 | return (v - past_v) / self.timestep 81 | 82 | def predict(self, reference: float, feedback: float) -> float: 83 | # Update weights 84 | self.learn(feedback) 85 | 86 | # Update variable history 87 | self.y[1] = self.y[0] 88 | self.r[1] = self.r[0] 89 | self.y[0] = feedback 90 | self.r[0] = reference 91 | 92 | self.input_y[1] = self.input_y[0] 93 | self.input_r[1] = self.input_r[0] 94 | self.hidden_input_p[1] = self.hidden_input_p[0] 95 | self.hidden_input_i[1] = self.hidden_input_i[0] 96 | self.hidden_input_d[1] = self.hidden_input_d[0] 97 | self.hidden_output_p[1] = self.hidden_output_p[0] 98 | self.hidden_output_i[1] = self.hidden_output_i[0] 99 | self.hidden_output_d[1] = self.hidden_output_d[0] 100 | 101 | self.v[1] = self.v[0] 102 | 103 | # Calculate input neuron outputs 104 | self.input_y[0] = self.p_transfer_function(feedback) 105 | self.input_r[0] = self.p_transfer_function(reference) 106 | 107 | # Calculate hidden P neurons outputs 108 | self.hidden_input_p[0] = ( 109 | self.input_y[0] * self.hidden_weights[0][0] 110 | + self.input_r[0] * self.hidden_weights[1][0]) 111 | self.hidden_output_p[0] = self.p_transfer_function(self.hidden_input_p[0]) 112 | 113 | # Calculate hidden I neurons outputs 114 | self.hidden_input_i[0] = ( 115 | self.input_y[0] * self.hidden_weights[0][1] 116 | + self.input_r[0] * self.hidden_weights[1][1]) 117 | self.hidden_output_i[0] = self.i_transfer_function( 118 | self.hidden_input_i[0], self.hidden_output_i[1]) 119 | 120 | # Calculate hidden D neurons outputs 121 | self.hidden_input_d[0] = ( 122 | self.input_y[0] * self.hidden_weights[0][2] 123 | + self.input_r[0] * self.hidden_weights[1][2]) 124 | self.hidden_output_d[0] = self.d_transfer_function( 125 | self.hidden_input_d[0], self.hidden_input_d[1]) 126 | 127 | # Calculate output neuron outputs 128 | self.v[0] = ( 129 | self.hidden_output_p[0] * self.output_weights[0] 130 | + self.hidden_output_i[0] * self.output_weights[1] 131 | + self.hidden_output_d[0] * self.output_weights[2]) 132 | 133 | return self.v[0] 134 | 135 | def learn(self, feedback: float) -> None: 136 | # Backprop 137 | delta_r = self.r[0] - self.y[0] 138 | delta_y = feedback - self.y[0] 139 | 140 | delta_output_weights = self.backprop(delta_r, delta_y) 141 | 142 | # Update weights when error is larger than the tolerance 143 | if delta_r >= self.tol: 144 | for idx in range(delta_output_weights.shape[0]): 145 | if math.fabs(delta_output_weights[idx]) > self.weight_chg_tol: 146 | if delta_output_weights[idx] > 0: 147 | delta_output_weights[idx] = self.weight_chg_tol 148 | elif delta_output_weights[idx] < 0: 149 | delta_output_weights[idx] = -self.weight_chg_tol 150 | 151 | self.output_weights[idx] = self.output_weights[idx] - self.eta * delta_output_weights[idx] 152 | 153 | def backprop(self, delta_r: float, delta_y: float) -> Any: 154 | delta_v = self.threshold_div_by_zero(self.v[0] - self.v[1]) 155 | 156 | # Output layer weight changes 157 | delta_output_weights = np.zeros([3], dtype=float) 158 | delta_output_weights[0] = ( 159 | -2 * delta_r * delta_y * self.hidden_output_p[0] / delta_v) 160 | delta_output_weights[1] = ( 161 | -2 * delta_r * delta_y * self.hidden_output_i[0] / delta_v) 162 | delta_output_weights[2] = ( 163 | -2 * delta_r * delta_y * self.hidden_output_d[0] / delta_v) 164 | 165 | return delta_output_weights 166 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains a usage example of the controllers. 3 | """ 4 | 5 | __author__ = "irfantitok@gmail.com" 6 | 7 | import numpy as np 8 | import matplotlib.pyplot as plt 9 | 10 | from plants.motor_position import MotorPosition 11 | from utils.evaluator import * 12 | from controllers.pidnn import PIDNN 13 | from controllers.pid import PID 14 | 15 | # Simulation parameters 16 | t_end = 20 17 | d_t = 0.01 18 | T = np.arange(0.0, t_end, d_t) 19 | it = np.nditer(T, flags=['f_index']) 20 | r = np.full_like(T, 1.0) 21 | 22 | num_changes = 2 23 | interval = int(len(T) / num_changes) 24 | 25 | for i in range(num_changes): 26 | r[i * interval:(i + 1) * interval] = i + 1 27 | 28 | # Motor position model 29 | plant = MotorPosition( 30 | K = 1, 31 | tau = 0.5) 32 | 33 | # Kp, Ki, Kd 34 | constants = [22.34, 7.579, 1.956] 35 | 36 | # PIDNN Controller 37 | pidnn = PIDNN( 38 | initial_constants = constants, 39 | learning_rate = 1, 40 | max_weight_change = 100, 41 | tolerance = 1e-8, 42 | timestep = d_t) 43 | 44 | pid = PID( 45 | constants = constants, 46 | timestep = d_t) 47 | 48 | # Simulate response 49 | y_pidnn = [0.0] 50 | y_pid = [0.0] 51 | 52 | # Adaptive 53 | plant.reset_states() 54 | it.reset() 55 | for i in it: 56 | u = pidnn.predict(reference = r[it.index], feedback = y_pidnn[-1]) 57 | y_pidnn.append(plant.simulate_one_step(u, i)) 58 | 59 | # Non-adaptive 60 | plant.reset_states() 61 | it.reset() 62 | for i in it: 63 | u = pid.predict(reference = r[it.index], feedback = y_pid[-1]) 64 | y_pid.append(plant.simulate_one_step(u, i)) 65 | 66 | # Tweak lengths to match the time series 67 | y_pidnn = y_pidnn[1:] 68 | y_pid = y_pid[1:] 69 | 70 | # Performance metrics 71 | settling_time_pidnn = calc_settling_time(y_pidnn, T) 72 | rise_time_pidnn = calc_rise_time(y_pidnn, T) 73 | overshoot_pidnn = calc_overshoot_percent(y_pidnn) 74 | 75 | settling_time_pid = calc_settling_time(y_pid, T) 76 | rise_time_pid = calc_rise_time(y_pid, T) 77 | overshoot_pid = calc_overshoot_percent(y_pid) 78 | 79 | print("PIDNN - TS: {:.2f} s - TR: {:.2f} s - OV: {:.2f}%".format( 80 | settling_time_pidnn, 81 | rise_time_pidnn, 82 | overshoot_pidnn)) 83 | 84 | print("Classic PID - TS: {:.2f} s - TR: {:.2f} s - OV: {:.2f}%".format( 85 | settling_time_pid, 86 | rise_time_pid, 87 | overshoot_pid)) 88 | 89 | # Plot the results 90 | plt.plot(T, r, 'k--', label = 'Reference') 91 | plt.plot(T, y_pidnn, 'c', label = 'PIDNN') 92 | plt.plot(T, y_pid, 'm', label = 'Classic PID') 93 | plt.xlabel('time (s)') 94 | plt.ylabel('position (m)') 95 | plt.legend(loc = 'lower right', shadow = False, fontsize = 'medium') 96 | plt.show(block = True) 97 | -------------------------------------------------------------------------------- /plants/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/titoirfan/neural_pid/e8a1c24ae85fc2796478349c24d052998b60965e/plants/__init__.py -------------------------------------------------------------------------------- /plants/motor_position.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains the motor position plant model implementation. 3 | """ 4 | 5 | __author__ = "irfantitok@gmail.com" 6 | 7 | import numpy as np 8 | import control.matlab 9 | 10 | class MotorPosition: 11 | """ 12 | Motor position plant model 13 | """ 14 | def __init__(self, K: float, tau: float) -> None: 15 | # System characteristics 16 | self._K = K 17 | self._tau = tau 18 | self._G = control.matlab.TransferFunction([K], [tau, 1, 0]) 19 | 20 | # System state 21 | self._X = 0.0 22 | self._U = np.array([0.0]) 23 | self._T = np.array([0.0]) 24 | 25 | def reset_states(self) -> None: 26 | self._X = 0.0 27 | self._U = np.array([0.0]) 28 | self._T = np.array([0.0]) 29 | 30 | def simulate_one_step(self, U_input: float, T_input: float) -> float: 31 | self._T = np.array([self._T[-1], T_input]) 32 | self._U = np.array([self._U[-1], U_input]) 33 | y, _, x = control.matlab.lsim(self._G, self._U, self._T, X0 = self._X) 34 | self._X = x[-1] 35 | 36 | return y[-1] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.21.4 2 | scipy==1.7.3 3 | control==0.9.0 4 | matplotlib==3.5.0 5 | typing_extensions==4.0.1 -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/titoirfan/neural_pid/e8a1c24ae85fc2796478349c24d052998b60965e/utils/__init__.py -------------------------------------------------------------------------------- /utils/evaluator.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains utility functions to evaluate system responses. 3 | """ 4 | 5 | __author__ = "irfantitok@gmail.com" 6 | 7 | import numpy as np 8 | 9 | def calc_settling_time( 10 | y: list(), 11 | t: list(), 12 | settling_time_threshold = 0.02) -> float: 13 | steady_state_val = y[-1] 14 | upper_margin = (1.0 + settling_time_threshold) * steady_state_val 15 | lower_margin = (1.0 - settling_time_threshold) * steady_state_val 16 | 17 | for i in reversed(range(len(t))): 18 | if y[i] <= lower_margin or y[i] >= upper_margin: 19 | settling_time = t[i] 20 | break 21 | 22 | return settling_time 23 | 24 | def calc_rise_time( 25 | y: list(), 26 | t: list(), 27 | rise_time_lower_val_bound = 0.1, 28 | rise_time_upper_val_bound = 0.9) -> float: 29 | steady_state_val = y[-1] 30 | 31 | rise_time_lower_idx = (np.where(y >= rise_time_lower_val_bound * steady_state_val)[0])[0] 32 | rise_time_upper_idx = (np.where(y >= rise_time_upper_val_bound * steady_state_val)[0])[0] 33 | 34 | rise_time = t[rise_time_upper_idx] - t[rise_time_lower_idx] 35 | 36 | return rise_time 37 | 38 | def calc_overshoot_percent(y: list()) -> float: 39 | steady_state_val = y[-1] 40 | overshoot_val = max(y) / steady_state_val - 1 41 | overshoot_percent = overshoot_val * 100 42 | 43 | return overshoot_percent --------------------------------------------------------------------------------