pyadrc 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyadrc/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Top-level package for pyadrc."""
2
+
3
+ __author__ = """Ongun Türkcüoglu"""
4
+ __email__ = 'onguntoglu@gmail.com'
5
+ __version__ = '0.5.0'
6
+
7
+ from .pyadrc import StateSpace, TransferFunction, FeedbackTF, saturation
8
+ from .models import QuadAltitude, System
9
+
10
+ __all__ = ['StateSpace', 'TransferFunction', 'FeedbackTF', 'saturation',
11
+ 'QuadAltitude', 'System', '__version__']
pyadrc/models.py ADDED
@@ -0,0 +1,180 @@
1
+ import numpy as np
2
+ import scipy.signal
3
+
4
+
5
+ class QuadAltitude(object):
6
+ """Discrete-time model of altitude of a quadcopter
7
+
8
+ Parameters
9
+ ----------
10
+ dt : float, optional
11
+ Discretization time (zero-order hold), by default 0.001
12
+ m : float, optional
13
+ Mass of the quadcopter, by default 0.028
14
+ g : float, optional
15
+ Gravitational acceleration, by default 9.807
16
+ """
17
+
18
+ def __init__(self, dt: float = 0.001,
19
+ m: float = 0.028, g: float = 9.807):
20
+
21
+ assert isinstance(g, float), 'Gravity needs to be of type float'
22
+ assert isinstance(m, float), 'Mass of quadcopter \
23
+ needs to be of type float'
24
+
25
+ self.g = g
26
+ self.m = m
27
+ self.dt = dt
28
+
29
+ self.vel = 0
30
+ self.pos = 0
31
+ self.acc = 0
32
+
33
+ def __call__(self, u: float) -> float:
34
+ """Input port to the quadcopter
35
+
36
+ Parameters
37
+ ----------
38
+ u : float
39
+ Thrust of the rotors
40
+
41
+ Returns
42
+ -------
43
+ float:
44
+ Current altitude of quadcopter
45
+
46
+ """
47
+
48
+ acc = u * (1/self.m) - self.g
49
+
50
+ self.vel += acc * self.dt
51
+ self.pos += self.vel * self.dt
52
+ self.acc = acc
53
+
54
+ # Include ground
55
+ if self.pos <= 0:
56
+ self.vel = 0
57
+ self.pos = 0
58
+
59
+ return self.pos
60
+
61
+ @property
62
+ def states(self):
63
+ """Return the state of the quadcopter
64
+ """
65
+
66
+ return self.pos, self.vel, self.acc
67
+
68
+ @states.setter
69
+ def states(self, value):
70
+ """Sets the state of the quadcopter to value
71
+ value = (pos, vel, acc). If a state is None, it is not changed.
72
+
73
+ Parameters
74
+ ----------
75
+ value : tuple
76
+ new state vector of quadcopter
77
+ """
78
+
79
+ self.pos = value[0] if isinstance(value[0], (float, int)) else self.pos
80
+ self.vel = value[1] if isinstance(value[1], (float, int)) else self.vel
81
+ self.acc = value[2] if isinstance(value[2], (float, int)) else self.acc
82
+
83
+ def reset(self):
84
+ """Reset position and velocity to zero
85
+ """
86
+
87
+ self.pos = 0.
88
+ self.vel = 0.
89
+
90
+
91
+ class System(object):
92
+
93
+ """Python class to generate a first-or second-order process
94
+ for simulation and verification purposes
95
+
96
+ Parameters
97
+ ----------
98
+ K : float, optional
99
+ system gain, by default 1.0
100
+ T : float, optional
101
+ time constant, by default 1.0
102
+ D : float, optional
103
+ damping factor, by default None
104
+ delta : float, optional
105
+ discretization time in seconds, by default 0.001
106
+ """
107
+
108
+ def __init__(self, K: float = 1.0, T: float = 1.0,
109
+ D: float = None, delta: float = 0.001) -> None:
110
+
111
+ assert isinstance(K, float)
112
+ assert isinstance(T, float)
113
+ assert delta > 0, "Sampling time has to be positive"
114
+
115
+ # First-order process
116
+ num = np.array([K]).reshape(-1)
117
+ den = np.array([T, 1]).reshape(-1)
118
+
119
+ if D is not None:
120
+ assert isinstance(D, float),\
121
+ "Damping factor D must be type float"
122
+ # Second-order process
123
+ den = np.array([T**2, 2 * D * T, 1]).reshape(-1)
124
+
125
+ system = scipy.signal.tf2ss(num, den)
126
+
127
+ system = scipy.signal.cont2discrete(system, delta)
128
+
129
+ self.A = system[0]
130
+ self.B = system[1]
131
+ self.C = system[2]
132
+
133
+ self.x = np.zeros((len(den) - 1, 1), dtype=np.float64)
134
+
135
+ def __call__(self, u: float) -> float:
136
+ """System response w.r.t. control signal u
137
+
138
+ Parameters
139
+ ----------
140
+ u : float
141
+ control signal u
142
+
143
+ Returns
144
+ -------
145
+ float
146
+ system response y
147
+ """
148
+
149
+ self.x = self.A.dot(self.x) + self.B.dot(u)
150
+
151
+ return (self.C @ self.x).item()
152
+
153
+
154
+ class RandomSystem(object):
155
+
156
+ def __init__(self, states=1) -> None:
157
+ """Create random discrete state space system
158
+
159
+ Args:
160
+ states (int, optional): number of states. Defaults to 1.
161
+
162
+ Returns:
163
+ None: [description]
164
+ """
165
+
166
+ import control
167
+
168
+ system = control.drss(states=states)
169
+
170
+ self.A = np.array(system.A)
171
+ self.B = np.array(system.B)
172
+ self.C = np.array(system.C)
173
+ self.D = np.array(system.D)
174
+
175
+ self.x = np.zeros((states, 1), dtype=np.float64)
176
+
177
+ def __call__(self, u: float) -> float:
178
+
179
+ self.x = self.A.dot(self.x) + self.B.dot(u)
180
+ return (self.C @ self.x + self.D * u).item()
pyadrc/pyadrc.py ADDED
@@ -0,0 +1,949 @@
1
+ """Active Disturbance Rejection Control for Python
2
+
3
+ It is highly recommended to check the documentation first before attempting
4
+ to use the package. Although it is simple in nature, ADRC requires some
5
+ basic knowledge about PID control, observers and state-feedback.
6
+
7
+ """
8
+
9
+ import numpy as np
10
+ import time
11
+
12
+ from collections import deque
13
+
14
+
15
+ def saturation(_limits: tuple, _val: float) -> float:
16
+ """Saturation function
17
+
18
+ Parameters
19
+ ----------
20
+ _limits : tuple
21
+ saturation limits (low, high)
22
+ _val : float
23
+ sat(_val)
24
+
25
+ Returns
26
+ -------
27
+ float
28
+ saturated signal
29
+ """
30
+
31
+ lo, hi = _limits
32
+
33
+ if _val is None:
34
+ return None
35
+ elif hi is not None and _val > hi:
36
+ return hi
37
+ elif lo is not None and _val < lo:
38
+ return lo
39
+
40
+ return _val
41
+
42
+
43
+ class StateSpace():
44
+
45
+ """Discrete linear time-invariant state space implementation\
46
+ of ADRC
47
+
48
+ Parameters
49
+ ----------
50
+ order : int
51
+ first- or second-order ADRC
52
+ delta : float
53
+ sampling time in seconds
54
+ b0 : float
55
+ gain parameter b0
56
+ w_cl : float
57
+ desired closed-loop bandwidth [rad/s], 4 / w_cl and 6 / w_cl is the
58
+ corresponding settling time in seconds for first- and second-order ADRC
59
+ respectively
60
+ k_eso : float
61
+ relational observer bandwidth
62
+ inc_form : float
63
+ toggle incremental form of ADRC, by default False. If the incremental
64
+ form is toggled, the controller will return the incrementation to the
65
+ current control signal. This value needs to be accumulated by the user.
66
+ Functionally identical to the non-incremental (normal) form.
67
+ eso_init : tuple, optional
68
+ initial state for the extended state observer, by default False
69
+ r_lim : tuple, optional
70
+ rate limits for the control signal, by default (None, None)
71
+ m_lim : tuple, optional
72
+ magnitude limits for the control signal, by default (None, None)
73
+ half_gain : tuple, optional
74
+ half gain tuning for controller/observer gains,\
75
+ by default (False, False)
76
+
77
+ References
78
+ ----------
79
+ .. [1] G. Herbst, "Practical active disturbance rejection control:
80
+ Bumpless transfer, rate limitation, and incremental algorithm",
81
+ https://arxiv.org/abs/1908.04610
82
+
83
+ .. [2] G. Herbst, "Half-Gain Tuning for Active Disturbance Rejection
84
+ Control", https://arxiv.org/abs/2003.03986
85
+ """
86
+
87
+ def __init__(self,
88
+ order: int,
89
+ delta: float,
90
+ b0: float,
91
+ w_cl: float,
92
+ k_eso: float,
93
+ inc_form: bool = False,
94
+ eso_init: tuple = False,
95
+ r_lim: tuple = (None, None),
96
+ m_lim: tuple = (None, None),
97
+ half_gain: tuple = (False, False)):
98
+
99
+ assert (order == 1) or (order == 2),\
100
+ 'Only first- and second-order ADRC is implemented'
101
+
102
+ self.b0 = b0
103
+ nx = order + 1
104
+ self.delta = delta
105
+
106
+ if order == 1:
107
+ self.Ad = np.vstack(([1, delta], [0, 1]))
108
+ self.Bd = np.vstack((b0 * delta, 0))
109
+ self.Cd = np.hstack((1, 0)).reshape(1, -1)
110
+ self.Dd = 0
111
+
112
+ # Controller parameters for closed-loop dynamics
113
+ t_settle = 4 / w_cl
114
+ sCL = -4 / t_settle
115
+ self.Kp = -sCL
116
+
117
+ # Observer dynamics
118
+ sESO = k_eso * sCL
119
+ zESO = np.exp(sESO * delta)
120
+
121
+ # Observer gains resulting in common-location observer poles
122
+ self.L = np.array([1 - (zESO)**2,
123
+ (1 / delta) * (1 - zESO)**2]).reshape(-1, 1)
124
+
125
+ # Controller gains
126
+ self.w = np.array([self.Kp / self.b0,
127
+ 1 / self.b0]).reshape(-1, 1)
128
+
129
+ elif order == 2:
130
+ self.Ad = np.vstack((
131
+ [1, delta, (delta**2) / 2],
132
+ [0, 1, delta],
133
+ [0, 0, 1]))
134
+ self.Bd = np.vstack((b0 * (delta**2) / 2, b0 * delta, 0))
135
+ self.Cd = np.hstack((1, 0, 0)).reshape(1, -1)
136
+ self.Dd = 0
137
+
138
+ # Controller parameters for closed-loop dynamics
139
+ t_settle = 6 / w_cl
140
+ sCL = -6 / t_settle
141
+ self.Kp = sCL**2
142
+ self.Kd = -2 * sCL
143
+
144
+ # Observer dynamics
145
+ sESO = k_eso * sCL
146
+ zESO = np.exp(sESO * delta)
147
+
148
+ # Observer gains resulting in common-location observer poles
149
+ self.L = np.array([1 - (zESO)**3,
150
+ (3 / (2 * delta)) * (1 - zESO)**2 * (1 + zESO),
151
+ (1 / delta**2) * (1 - zESO)**3]
152
+ ).reshape(-1, 1)
153
+
154
+ # Controller gains
155
+ self.w = np.array([self.Kp / self.b0,
156
+ self.Kd / self.b0,
157
+ 1 / self.b0]).reshape(-1, 1)
158
+
159
+ self.xhat = np.zeros((nx, 1), dtype=np.float64)
160
+
161
+ self.ukm1 = 0.
162
+
163
+ self.m_lim = m_lim
164
+ self.r_lim = r_lim
165
+
166
+ if half_gain[0] is True:
167
+ self.w = self.w / 2
168
+ if half_gain[1] is True:
169
+ self.L = self.L / 2
170
+
171
+ if eso_init is not False:
172
+ assert len(eso_init) == nx,\
173
+ 'Length of initial state vector of LESO not compatible\
174
+ with order'
175
+ self.xhat = np.fromiter(eso_init, np.float64).reshape(-1, 1)
176
+
177
+ self._last_time = None
178
+ self._last_output = None
179
+ self._last_input = None
180
+ self.inc_form = inc_form
181
+
182
+ self._linear_extended_state_observer()
183
+
184
+ def _linear_extended_state_observer(self):
185
+ """Internal function implementing the one-step update
186
+ equation for the linear extended state observer
187
+ """
188
+
189
+ self.oA = self.Ad - self.L @ self.Cd @ self.Ad
190
+ self.oB = self.Bd - self.L @ self.Cd @ self.Bd
191
+ self.oC = self.Cd
192
+
193
+ if self.inc_form is True:
194
+ self.oA = self.oA - np.eye(self.oA.shape[0])
195
+ self.rkm1 = 0.
196
+
197
+ def _update_eso(self, y: float, ukm1: float):
198
+ """Update the linear extended state observer
199
+
200
+ Parameters
201
+ ----------
202
+ y : float
203
+ Current measurement y[k]
204
+ ukm1 : float
205
+ Previous control signal u[k-1]
206
+ """
207
+ if self.inc_form is False:
208
+ self.xhat = self.oA.dot(self.xhat) + self.oB.dot(
209
+ ukm1).reshape(-1, 1) + self.L.dot(y)
210
+ else: # self.inc_form is True
211
+ self.xhat_delta = self.oA.dot(self.xhat) + self.oB.dot(
212
+ ukm1).reshape(-1, 1) + self.L.dot(y)
213
+
214
+ self.xhat = self.xhat + self.xhat_delta
215
+
216
+ def _limiter(self, u_control: float) -> float:
217
+ """Implements rate and magnitude limiter
218
+
219
+ Parameters
220
+ ----------
221
+ u_control : float
222
+ control signal to be limited
223
+
224
+ Returns
225
+ -------
226
+ float
227
+ float: rate and magnitude limited control signal
228
+ """
229
+
230
+ # Limiting the rate of u (delta_u)
231
+ delta_u = saturation((self.r_lim[0], self.r_lim[1]),
232
+ u_control - self.ukm1)
233
+
234
+ # Limiting the magnitude of u
235
+ self.ukm1 = saturation((self.m_lim[0],
236
+ self.m_lim[1]),
237
+ delta_u + self.ukm1)
238
+
239
+ return self.ukm1
240
+
241
+ @property
242
+ def eso_states(self) -> tuple:
243
+ """Returns the states of the linear extended state observer
244
+
245
+ Returns
246
+ -------
247
+ tuple
248
+ States of the linear extended state observer
249
+ """
250
+
251
+ return self.xhat
252
+
253
+ @property
254
+ def limiter(self) -> tuple:
255
+ """Returns the value of both limiters of the controller
256
+
257
+ Returns
258
+ -------
259
+ tuple of tuples
260
+ Returns (magnitude_limits, rate_limits)
261
+ """
262
+
263
+ return self.m_lim, self.r_lim
264
+
265
+ @limiter.setter
266
+ def limiter(self, lim_tuple: tuple) -> None:
267
+ """Setter for magnitude and rate limiter
268
+
269
+ Parameters
270
+ ----------
271
+ lim_tuple : tuple of tuples
272
+ New magnitude limits
273
+ """
274
+
275
+ assert len(lim_tuple) == 2
276
+ assert len(lim_tuple[0]) == 2 and len(lim_tuple[1]) == 2
277
+ # assert lim[0] < lim[1]
278
+ self.m_lim = lim_tuple[0]
279
+ self.r_lim = lim_tuple[1]
280
+
281
+ def __call__(self, y: float, u: float, r: float, zoh: bool = False):
282
+ """Returns value of the control signal depending on current
283
+ measurements, previous control action, reference signal.
284
+
285
+ Parameters
286
+ ----------
287
+ y : float
288
+ Current measurement y[k] of the process
289
+ u : float
290
+ Previous control signal u[k-1]
291
+ r : float
292
+ Current reference signal r[k]
293
+ zoh : bool, optional
294
+ Only update every delta seconds, by default False
295
+
296
+ Returns
297
+ -------
298
+ float
299
+ Current control signal u[k]
300
+ """
301
+
302
+ now = time.monotonic()
303
+
304
+ if zoh is True:
305
+ try:
306
+ dt = now - self._last_time if now - self._last_time else 1e-16
307
+ except TypeError:
308
+ dt = 1e-16
309
+
310
+ if zoh is True and dt < self.delta and self._last_output is not None:
311
+ # Return last output of the controller if dt < delta
312
+ return self._last_output
313
+
314
+ self._update_eso(y, u)
315
+
316
+ if self.inc_form is False:
317
+ u = (self.Kp / self.b0) * r - (self.w.T @ self.xhat).item()
318
+ u = self._limiter(u)
319
+ else: # self.inc_form is True:
320
+ delta_r = r - self.rkm1
321
+ delta_u = ((self.Kp / self.b0) * delta_r
322
+ - (self.w.T @ self.xhat_delta).item())
323
+ u = delta_u
324
+
325
+ self.rkm1 = r
326
+
327
+ self._last_output = float(u)
328
+ self._last_time = now
329
+ return float(u)
330
+
331
+ def reset(self, x0=None):
332
+ """Resets the extended state observer
333
+
334
+ Parameters
335
+ ----------
336
+ x0 : np.array, optional
337
+ new initial state vector for the extended state observer,
338
+ by default None, i.e. resets to zero
339
+ """
340
+
341
+ nx = len(self.xhat)
342
+
343
+ assert x0 is None or len(np.fromiter(x0, float)) == nx,\
344
+ "Invalid state vector"
345
+
346
+ if x0 is None:
347
+ self.xhat = np.zeros((nx, 1), dtype=np.float64)
348
+ else:
349
+ self.xhat = np.fromiter(x0, np.float64).reshape(-1, 1)
350
+
351
+
352
+ class FeedbackTF(object):
353
+
354
+ """Minimal footprint ADRC implemented with transfer functions in\
355
+ feedback path
356
+
357
+ Parameters
358
+ ----------
359
+ order : int
360
+ first- or second-order ADRC
361
+ delta : float
362
+ sampling time in seconds
363
+ b0 : float
364
+ gain parameter b0
365
+ w_cl : float
366
+ desired closed-loop bandwidth [rad/s], 4 / w_cl and 6 / w_cl is the
367
+ corresponding settling time in seconds for first- and second-order ADRC
368
+ respectively
369
+ k_eso : float
370
+ relational observer bandwidth
371
+ x_init : tuple, optional
372
+ initial state for the storage variables, by default False
373
+ r_lim : tuple, optional
374
+ rate limits of the controller, by default (None, None)
375
+ m_lim : tuple, optional
376
+ magnitude limits of the controller, by default (None, None)
377
+
378
+ References
379
+ ----------
380
+ .. [3] G. Herbst, "A Minimum-Footprint Implementation
381
+ of Discrete-Time ADRC", https://arxiv.org/abs/2104.01943
382
+ """
383
+
384
+ def __init__(self,
385
+ order: int,
386
+ delta: float,
387
+ b0: float,
388
+ w_cl: float,
389
+ k_eso: float,
390
+ x_init: tuple = False,
391
+ r_lim: tuple = (None, None),
392
+ m_lim: tuple = (None, None)) -> None:
393
+
394
+ assert order == 1 or order == 2, 'First- and second-order FBTF-ADRC\
395
+ implemented'
396
+
397
+ self.order = order
398
+ self.delta = delta
399
+
400
+ zESO = np.exp(-k_eso * w_cl * delta)
401
+
402
+ if order == 1:
403
+ self.coeff = {'a1': -2 * zESO, 'a2': zESO**2,
404
+ 'b0': delta * w_cl * zESO**2 - (1 - zESO)**2,
405
+ 'b1': - delta * w_cl * zESO**2,
406
+ 'g0': (1/(b0 * delta)) * (delta * w_cl
407
+ * (1-zESO**2)
408
+ + (1 - zESO)**2),
409
+ 'g1': (1/(b0 * delta)) * (2 * delta * w_cl
410
+ * (zESO**2 - zESO)
411
+ - (1 - zESO)**2),
412
+ 'k': w_cl / b0}
413
+
414
+ self.states = {'x1': 0., 'x2': 0.}
415
+
416
+ elif order == 2:
417
+ _GC = 1/(b0 * delta**2) # coefficient gamma_n constant
418
+ self.coeff = {'a1': -3 * zESO, 'a2': 3 * zESO**2, 'a3': -zESO**3,
419
+ 'b0': 0.5 * (- delta * w_cl * zESO**3
420
+ * (4 - delta * w_cl)
421
+ + delta * w_cl * (1 + zESO)**3
422
+ - (1 - zESO)**3),
423
+ 'b1': 0.5 * (- delta * w_cl
424
+ * (1 + zESO)**3 - (1 - zESO)**3),
425
+ 'b2': 0.5 * (delta * w_cl * zESO**3
426
+ * (4 - delta * w_cl)),
427
+ 'g0': _GC * (delta**2 * w_cl**2 * (1 - zESO**3)
428
+ + 3 * delta * w_cl
429
+ * (1 - zESO - zESO**2 + zESO**3)
430
+ + (1 - zESO)**3),
431
+ 'g1': _GC * (3 * delta**2 * w_cl**2
432
+ * (- zESO + zESO**3)
433
+ + 4 * delta * w_cl
434
+ * (-1 + 3 * zESO**2 - 2*zESO**3)
435
+ - 2 * (1 - zESO)**3),
436
+ 'g2': _GC * (3 * delta**2 * w_cl**2
437
+ * (zESO**2 - zESO**3)
438
+ + delta * w_cl
439
+ * (1 + 3 * zESO - 9*zESO**2 + 5*zESO**3)
440
+ + (1 - zESO)**3),
441
+ 'k': w_cl**2 / b0}
442
+
443
+ self.states = {'x1': 0., 'x2': 0., 'x3': 0.}
444
+
445
+ # Initialize limiters
446
+ self.ukm1 = 0.
447
+ self.r_lim = r_lim
448
+ self.m_lim = m_lim
449
+
450
+ # Zero-order hold variables
451
+ self._last_time = None
452
+ self._last_output = None
453
+ self._last_input = None
454
+
455
+ @property
456
+ def x_states(self) -> tuple:
457
+ """Returns the storage variables
458
+
459
+ Returns
460
+ -------
461
+ tuple
462
+ Storage variables
463
+ """
464
+
465
+ return tuple(self.states.values())
466
+
467
+ @property
468
+ def limiter(self) -> tuple:
469
+ """Returns the value of both limiters of the controller
470
+
471
+ Returns
472
+ -------
473
+ tuple of tuples
474
+ Returns (magnitude_limits, rate_limits)
475
+ """
476
+
477
+ return self.m_lim, self.r_lim
478
+
479
+ @limiter.setter
480
+ def limiter(self, lim_tuple: tuple) -> None:
481
+ """Setter for magnitude and rate limiter
482
+
483
+ Parameters
484
+ ----------
485
+ lim_tuple : tuple of tuples
486
+ New magnitude limits
487
+ """
488
+
489
+ assert len(lim_tuple) == 2
490
+ assert len(lim_tuple[0]) == 2 and len(lim_tuple[1]) == 2
491
+ # assert lim[0] < lim[1]
492
+ self.m_lim = lim_tuple[0]
493
+ self.r_lim = lim_tuple[1]
494
+
495
+ def _limiter(self, u_control: float) -> float:
496
+ """Implements rate and magnitude limiter
497
+
498
+ Parameters
499
+ ----------
500
+ u_control : float
501
+ control signal to be limited
502
+
503
+ Returns
504
+ -------
505
+ float
506
+ float: rate and magnitude limited control signal
507
+ """
508
+
509
+ # Limiting the rate of u (delta_u)
510
+ delta_u = saturation((self.r_lim[0], self.r_lim[1]),
511
+ u_control - self.ukm1)
512
+
513
+ # Limiting the magnitude of u
514
+ self.ukm1 = saturation((self.m_lim[0],
515
+ self.m_lim[1]),
516
+ delta_u + self.ukm1)
517
+
518
+ return self.ukm1
519
+
520
+ def _update_xn(self, ck: float, u_lim: float, y: float) -> None:
521
+ """Internal function to update the storage variables after the control
522
+ signal is calculated
523
+
524
+ Parameters
525
+ ----------
526
+ ck : float
527
+ combined transfer function output
528
+ u_lim : float
529
+ limited control signal
530
+ y : float
531
+ plant output
532
+ """
533
+ self.states['x1'] = (self.states['x2']
534
+ - self.coeff['a1'] * ck
535
+ + self.coeff['b0'] * u_lim
536
+ + self.coeff['g1'] * y)
537
+
538
+ if self.order == 1:
539
+ self.states['x2'] = (- self.coeff['a2'] * ck
540
+ + self.coeff['b1'] * u_lim)
541
+
542
+ elif self.order == 2:
543
+ self.states['x2'] = (self.states['x3']
544
+ - self.coeff['a2'] * ck
545
+ + self.coeff['b1'] * u_lim
546
+ + self.coeff['g2'] * y)
547
+
548
+ self.states['x3'] = (- self.coeff['a3'] * ck
549
+ + self.coeff['b2'] * u_lim)
550
+
551
+ def __call__(self, y: float, r: float, zoh: bool = False) -> float:
552
+ """Returns value of the control signal depending on current
553
+ measurements and reference signal.
554
+
555
+ Parameters
556
+ ----------
557
+ y : float
558
+ Current measurement y[k] of the process
559
+ r : float
560
+ Current reference signal r[k]
561
+ zoh : bool, optional
562
+ Only update every delta seconds, by default False
563
+
564
+ Returns
565
+ -------
566
+ float
567
+ Current control signal u[k]
568
+ """
569
+ now = time.monotonic()
570
+
571
+ if zoh is True:
572
+ try:
573
+ dt = now - self._last_time if now - self._last_time else 1e-16
574
+ except TypeError:
575
+ dt = 1e-16
576
+
577
+ if zoh is True and dt < self.delta and self._last_output is not None:
578
+ # Return last output of the controller if dt < delta
579
+ return self._last_output
580
+
581
+ # Compute the combined transfer function output c(k)
582
+ ck = self.coeff['g0'] * y + self.states['x1']
583
+ # Compute unlimited control output u(k)
584
+ u = self.coeff['k'] * r - ck
585
+ # Limit controller output to obtain u_lim(k)
586
+ u_lim = self._limiter(u)
587
+
588
+ # Update storage variables x_n
589
+ self._update_xn(ck, u_lim, y)
590
+
591
+ self._last_output = float(u_lim)
592
+ self._last_time = now
593
+ return float(u_lim)
594
+
595
+
596
+ class TransferFunction(object):
597
+
598
+ """Discrete time linear active disturbance rejection control\
599
+ in transfer function representation
600
+
601
+ Parameters
602
+ ----------
603
+ order : int
604
+ first- or second-order ADRC TF
605
+ delta : float
606
+ sampling time in seconds
607
+ b0 : float
608
+ modelling parameter b0
609
+ w_cl : float
610
+ desired closed-loop bandwidth [rad/s], 4 / w_cl and 6 / w_cl is the
611
+ corresponding settling time in seconds for first- and second-order ADRC
612
+ respectively
613
+ k_eso : float
614
+ observer bandwidth is parametrized as k_eso-multiple faster than w_cl
615
+ method : str, optional, 'general_terms' or 'bandwidth'
616
+ method with which the transfer function parameters are calculated,
617
+ functionally identical, by default 'general_terms'
618
+
619
+ References
620
+ ----------
621
+ .. [4] G. Herbst, Transfer Function Analysis and
622
+ Implementation of Active Disturbance Rejection Control
623
+ https://arxiv.org/abs/2011.01044
624
+ """
625
+
626
+ def __init__(self, order: int, delta: float, b0: float,
627
+ w_cl: float, k_eso: float,
628
+ method: str = 'general_terms') -> None:
629
+
630
+ assert order == 1 or order == 2, 'First- and second-order ADRC TF\
631
+ implemented'
632
+
633
+ # Define attributes
634
+ self.order = order
635
+ self.acc = 0.
636
+ zESO = np.exp(-k_eso * w_cl * delta)
637
+
638
+ self.params = self._calculate_parameters(order, delta, b0, w_cl, zESO,
639
+ method=method)
640
+
641
+ self._calculate_coeffs()
642
+ self._create_states()
643
+
644
+ @property
645
+ def accumulator(self):
646
+ """Get accumulator
647
+
648
+ Returns
649
+ -------
650
+ float
651
+ accumulator output
652
+ """
653
+ return self.acc
654
+
655
+ @accumulator.setter
656
+ def accumulator(self, value):
657
+ """Set accumulator output to value
658
+
659
+ Parameters
660
+ ----------
661
+ value : float
662
+ value to set the accumulator
663
+ """
664
+ self.acc = float(value)
665
+
666
+ @property
667
+ def parameters(self):
668
+ """Get parameters
669
+
670
+ Returns
671
+ -------
672
+ dict
673
+ calculated paramaters
674
+ """
675
+ return self.params
676
+
677
+ def _create_states(self):
678
+ """Create state vectors to store past values for transfer functions
679
+ """
680
+
681
+ if self.order == 1:
682
+
683
+ # Reference prefilter
684
+ self.r_k = deque([0, 0], maxlen=2)
685
+ self.r_yk = deque([0], maxlen=1)
686
+
687
+ # Feedback controller
688
+ self.e_k = deque([0], maxlen=1)
689
+ self.u_k = deque([0], maxlen=1)
690
+
691
+ else:
692
+
693
+ # Reference prefilter
694
+ self.r_k = deque([0, 0, 0], maxlen=3)
695
+ self.r_yk = deque([0, 0], maxlen=2)
696
+
697
+ # Feedback controller
698
+ self.e_k = deque([0, 0], maxlen=2)
699
+ self.u_k = deque([0, 0], maxlen=2)
700
+
701
+ def _control_loop(self, y, r_k):
702
+
703
+ if self.order == 1:
704
+
705
+ # reference prefilter
706
+ ref_yk = (r_k * self.pf['num'][0]
707
+ + self.r_k[0] * self.pf['num'][1]
708
+ + self.r_k[1] * self.pf['num'][2]
709
+ - self.r_yk[0] * self.pf['den'][1])
710
+
711
+ # advance time
712
+ self.r_k.appendleft(r_k)
713
+ self.r_yk.appendleft(ref_yk)
714
+
715
+ # calculate error
716
+ e_k = ref_yk - y
717
+
718
+ # feedback controller
719
+ u_k = (e_k * self.fb['num'][0]
720
+ + self.e_k[0] * self.fb['num'][1]
721
+ - self.u_k[0] * self.fb['den'][1])
722
+
723
+ # advance time
724
+ self.e_k.appendleft(e_k)
725
+ self.u_k.appendleft(u_k)
726
+
727
+ else: # self.order == 2
728
+
729
+ # reference prefilter
730
+ ref_yk = (r_k * self.pf['num'][0]
731
+ + self.r_k[0] * self.pf['num'][1]
732
+ + self.r_k[1] * self.pf['num'][2]
733
+ + self.r_k[2] * self.pf['num'][3]
734
+ - self.r_yk[0] * self.pf['den'][1]
735
+ - self.r_yk[1] * self.pf['den'][2])
736
+
737
+ # advance time
738
+ self.r_k.appendleft(r_k)
739
+ self.r_yk.appendleft(ref_yk)
740
+
741
+ # calculate error
742
+ e_k = ref_yk - y
743
+
744
+ # feedback controller
745
+ u_k = (e_k * self.fb['num'][0]
746
+ + self.e_k[0] * self.fb['num'][1]
747
+ + self.e_k[1] * self.fb['num'][2]
748
+ - self.u_k[0] * self.fb['den'][1]
749
+ - self.u_k[1] * self.fb['den'][2])
750
+
751
+ # advance time
752
+ self.e_k.appendleft(e_k)
753
+ self.u_k.appendleft(u_k)
754
+
755
+ # Accumulate feedback controller output
756
+ self.accumulator = self.accumulator + u_k
757
+
758
+ return self.accumulator
759
+
760
+ def _calculate_coeffs(self):
761
+ """Internal function to calculate the coefficients for the reference
762
+ prefilter and feedback controller (denominator and numerator)
763
+ Uses the z^-1 notation, coefficients in descending order, i.e
764
+ z^0, z^-1, ..., z^-n
765
+ """
766
+
767
+ if self.order == 1:
768
+ fb = {'num': [self.params['be0'], self.params['be1']],
769
+ 'den': [1, self.params['a1']]}
770
+ pf = {'num': [self.params['g0'],
771
+ self.params['g1'], self.params['g2']],
772
+ 'den': [1, self.params['be1'] / self.params['be0']]}
773
+ else:
774
+ fb = {'num': [self.params['be0'], self.params['be1'],
775
+ self.params['be2']],
776
+ 'den': [1, self.params['a1'], self.params['a2']]}
777
+ pf = {'num': [self.params['g0'], self.params['g1'],
778
+ self.params['g2'], self.params['g3']],
779
+ 'den': [1, self.params['be1'] / self.params['be0'],
780
+ self.params['be2'] / self.params['be0']]}
781
+
782
+ self.fb = fb
783
+ self.pf = pf
784
+
785
+ def _calculate_parameters(self, order, delta, b0,
786
+ w_cl, zESO, method='general_terms'):
787
+
788
+ """Calculate the coefficients of num and den of TransferFunction ADRC,
789
+ using either general terms or bandwidth parameterization
790
+
791
+ Parameters
792
+ ----------
793
+ order : int
794
+ internal variable
795
+ delta : float
796
+ internal variable
797
+ b0 : float
798
+ internal variable
799
+ w_cl : float
800
+ internal variable
801
+ zESO : float
802
+ internal variable
803
+ method : str, optional
804
+ Calculation via general terms (i.e. controller and observer gains)
805
+ or bandwidth parametrization, functionally identical,
806
+ by default 'general_terms'
807
+
808
+ Returns
809
+ -------
810
+ list
811
+ Ordered transfer function parameters
812
+
813
+ Notes
814
+ -----
815
+ * For first-order ADRC: [a1, be0, be1, g0, g1, g2]
816
+ * For second-order ADRC: [a1, a2, be0, be1, be2, g0, g1, g2, g3]
817
+ """
818
+
819
+ if method == 'general_terms':
820
+ if order == 1:
821
+ # Controller gains
822
+ k1 = w_cl
823
+
824
+ # Observer gains
825
+ l1 = 1 - zESO**2
826
+ l2 = (1 / delta) * (1 - zESO)**2
827
+
828
+ _C_TERM = (k1 * l1 + l2)
829
+
830
+ a1 = (delta * k1 - 1) * (1 - l1)
831
+ be0 = (1 / b0) * _C_TERM
832
+ be1 = (1 / b0) * (delta * k1 * l2 - k1 * l1 - l2)
833
+ g0 = k1 / _C_TERM
834
+ g1 = k1 * (delta * l2 + l1 - 2) / _C_TERM
835
+ g2 = k1 * (1 - l1) / _C_TERM
836
+
837
+ elif order == 2:
838
+
839
+ k1 = w_cl**2
840
+ k2 = 2 * w_cl
841
+
842
+ l1 = 1 - zESO**3
843
+ l2 = (3 / (2 * delta)) * (1 - zESO)**2 * (1 + zESO)
844
+ l3 = (1 / delta**2) * (1 - zESO)**3
845
+
846
+ _C_TERM = (k1 * l1 + k2 * l2 + l3)
847
+
848
+ a1 = ((delta ** 2 / 2) * (k1 - k1 * l1 - k2 * l2)
849
+ + delta * k2 + delta * l2 + l1 - 2)
850
+
851
+ a2 = ((((delta**2 * k1) / 2) - delta * k2 + 1) * (1 - l1))
852
+
853
+ be0 = (1 / b0) * _C_TERM
854
+
855
+ be1 = ((1 / b0) * (((delta**2 * k1 * l3) / 2)
856
+ + delta * k1 * l2 + delta
857
+ * k2 * l3 - 2
858
+ * _C_TERM))
859
+
860
+ be2 = ((1 / b0) * (((delta**2 * k1 * l3) / 2)
861
+ - delta * k1 * l2 - delta
862
+ * k2 * l3 + _C_TERM))
863
+
864
+ g0 = k1 / _C_TERM
865
+
866
+ g1 = ((k1 * (delta**2 * l3 + 2 * delta * l2 + 2 * l1 - 6))
867
+ / (2 * _C_TERM))
868
+
869
+ g2 = ((k1 * (delta**2 * l3 - 2 * delta * l2 - 4 * l1 + 6))
870
+ / (2 * _C_TERM))
871
+
872
+ g3 = (k1 * (l1 - 1)) / _C_TERM
873
+
874
+ elif method == 'bandwidth':
875
+ if order == 1:
876
+
877
+ _C_TERM = ((delta * w_cl) * (1 - zESO**2) + (1 - zESO)**2)
878
+
879
+ a1 = (delta * w_cl - 1) * zESO**2
880
+
881
+ be0 = ((1/(b0 * delta)) * _C_TERM)
882
+
883
+ be1 = ((1/(b0 * delta)) * ((-2 * delta * w_cl * zESO)
884
+ * (1 - zESO) - (1 - zESO)**2))
885
+
886
+ g0 = (delta * w_cl) / _C_TERM
887
+ g1 = (-2 * delta * w_cl * zESO) / _C_TERM
888
+ g2 = (delta * w_cl * zESO**2) / _C_TERM
889
+
890
+ elif order == 2:
891
+
892
+ _C_TERM = (delta**2 * w_cl**2 * (1 - zESO**3)
893
+ + 3 * delta * w_cl * (1 - zESO - zESO**2 + zESO**3)
894
+ + (1 - zESO)**3)
895
+
896
+ a1 = ((1/2) * (delta**2 * w_cl**2 * zESO**3
897
+ + delta * w_cl
898
+ * (1 + 3 * zESO + 3 * zESO**2 - 3 * zESO**3)
899
+ + (1 - 3 * zESO - 3 * zESO**2 + zESO**3)))
900
+
901
+ a2 = ((zESO**3 / 2)
902
+ * (delta**2 * w_cl**2 - 4 * delta * w_cl + 2))
903
+
904
+ be0 = ((1 / (b0 * delta**2)) * _C_TERM)
905
+
906
+ be1 = ((1 / (b0 * delta**2))
907
+ * (-3 * delta**2 * w_cl**2 * zESO * (1 - zESO**2)
908
+ - 4 * delta * w_cl * (1 - 3 * zESO**2 + 2 * zESO**3)
909
+ - 2 * (1 - zESO)**3))
910
+
911
+ be2 = ((1 / (b0 * delta**2))
912
+ * (3 * delta**2 * w_cl**2 * zESO**2 * (1 - zESO)
913
+ + delta * w_cl
914
+ * (1 + 3 * zESO - 9 * zESO**2 + 5 * zESO**3)
915
+ + (1 - zESO)**3))
916
+
917
+ g0 = (delta**2 * w_cl**2) / _C_TERM
918
+ g1 = (-3 * delta**2 * w_cl**2 * zESO) / _C_TERM
919
+ g2 = (3 * delta**2 * w_cl**2 * zESO**2) / _C_TERM
920
+ g3 = (-1 * delta**2 * w_cl**2 * zESO**3) / _C_TERM
921
+
922
+ if order == 1:
923
+ params = {'a1': a1, 'be0': be0,
924
+ 'be1': be1, 'g0': g0,
925
+ 'g1': g1, 'g2': g2}
926
+ else:
927
+ params = {'a1': a1, 'a2': a2, 'be0': be0,
928
+ 'be1': be1, 'be2': be2, 'g0': g0,
929
+ 'g1': g1, 'g2': g2, 'g3': g3}
930
+
931
+ return params
932
+
933
+ def __call__(self, y, r):
934
+ """Control loop call
935
+
936
+ Parameters
937
+ ----------
938
+ y : float
939
+ current output signal
940
+ r : float
941
+ current reference signal
942
+
943
+ Returns
944
+ -------
945
+ float
946
+ control signal to the plant (accumulator output)
947
+ """
948
+
949
+ return self._control_loop(y, r)
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyadrc
3
+ Version: 0.5.0
4
+ Summary: Active Disturbance Rejection Control for Python
5
+ Author-email: Ongun Türkcüoglu <onguntoglu@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/onguntoglu/pyadrc
8
+ Project-URL: Documentation, https://pyadrc.readthedocs.io
9
+ Keywords: pyadrc,adrc,control
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Natural Language :: English
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ License-File: AUTHORS.rst
22
+ Requires-Dist: numpy
23
+ Requires-Dist: scipy
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Requires-Dist: control; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # pyadrc's Documentation
31
+
32
+ [![PyPI](https://img.shields.io/pypi/v/pyadrc.svg)](https://pypi.python.org/pypi/pyadrc)
33
+ [![CI](https://github.com/onguntoglu/pyadrc/actions/workflows/ci.yml/badge.svg)](https://github.com/onguntoglu/pyadrc/actions/workflows/ci.yml)
34
+ [![Documentation Status](https://readthedocs.org/projects/pyadrc/badge/?version=latest)](https://pyadrc.readthedocs.io/en/latest/?badge=latest)
35
+ [![Licence](https://img.shields.io/github/license/onguntoglu/pyadrc.svg)](https://github.com/onguntoglu/pyadrc/blob/master/LICENSE)
36
+ [![Downloads](https://pepy.tech/badge/pyadrc)](https://pepy.tech/project/pyadrc)
37
+
38
+ Active Disturbance Rejection Control for Python
39
+
40
+ A simple, yet powerful control method for when a PID controller just doesn't cut it.
41
+
42
+ ## Features
43
+
44
+ - Discrete linear time invariant active disturbance rejection controller for digital control systems.
45
+ - First- and second-order ADRC in state-space, transfer function and minimum-footprint (FeedbackTF) form.
46
+ - For practical application - half-gain tuning, magnitude and rate limiter for actuator limitations
47
+ - User guide and/or theoretical background (for when you don't have anything else to read).
48
+ - A first/second-order LTI model generator and a quadcopter altitude model for experimentation, testing and verification.
49
+
50
+ ## Installation
51
+
52
+ Installing **pyadrc** is really simple, just run this command in your terminal:
53
+
54
+ ```bash
55
+ pip install pyadrc
56
+ ```
57
+
58
+ or you can clone the repository and install it manually:
59
+
60
+ ```bash
61
+ git clone https://github.com/onguntoglu/pyadrc
62
+ cd pyadrc
63
+ pip install .
64
+ ```
65
+
66
+ ## Licence
67
+
68
+ **pyadrc** is licenced under the MIT Licence
69
+
70
+ ## Acknowledgements
71
+
72
+ None of this work would be possible without **Gernot Herbst** and his papers:
73
+
74
+ - *[Practical Active Disturbance Rejection Control: Bumpless Transfer, Rate Limitation and Incremental Algorithm](https://arxiv.org/abs/1908.04610)*
75
+ - *[A Simulative Study on Active Disturbance Rejection Control (ADRC) as a Control Tool for Practitioners](https://arxiv.org/abs/1908.04596)*
76
+ - *[Transfer Function Analysis and Implementation of Active Disturbance Rejection Control](https://arxiv.org/abs/2011.01044)*
77
+ - *[Half-Gain Tuning for Active Disturbance Rejection Control](https://arxiv.org/abs/2003.03986)*
78
+ - *[A Minimum-Footprint Implementation of Discrete-Time ADRC](https://arxiv.org/abs/2104.01943)*
@@ -0,0 +1,9 @@
1
+ pyadrc/__init__.py,sha256=EjRk0BEOE8D0dllkwHj14Dtwr5aJJUCijh1WXWmb614,373
2
+ pyadrc/models.py,sha256=C9srGX5FJBTO9sunvwXvCQrVrRQrkIX74bt_d6WGl5I,4477
3
+ pyadrc/pyadrc.py,sha256=wx6OZvWo3UKQB_3aGm26cxvDzSGwZ8xjfzuG5dTil0c,30169
4
+ pyadrc-0.5.0.dist-info/licenses/AUTHORS.rst,sha256=w9A68eGmFO3a9taKcnJh_NWUZbkj38cuPTLLiOB31Gc,164
5
+ pyadrc-0.5.0.dist-info/licenses/LICENSE,sha256=_0pf3xnvyOF8LaQNKgm75XW9Byjp2CBgYbZbhwlpDQw,1077
6
+ pyadrc-0.5.0.dist-info/METADATA,sha256=4DL5qjBz0VGsXuT5mvdmEZbfIEVHNeZK_8HSNh5Jnjg,3361
7
+ pyadrc-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ pyadrc-0.5.0.dist-info/top_level.txt,sha256=DiqtzK4DShwozIn-DOeRaXW19dIqGTnuoO13_r1gF0A,7
9
+ pyadrc-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,13 @@
1
+ =======
2
+ Credits
3
+ =======
4
+
5
+ Development Lead
6
+ ----------------
7
+
8
+ * Ongun Türkcüoglu <onguntoglu@gmail.com>
9
+
10
+ Contributors
11
+ ------------
12
+
13
+ None yet. Why not be the first?
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021, Ongun Türkcüoglu
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
+
@@ -0,0 +1 @@
1
+ pyadrc