17
17
import math
18
18
import numpy as np
19
19
import matplotlib .pyplot as plt
20
+ import scipy
20
21
from numpy import where , dstack , diff , meshgrid
21
22
from warnings import warn
22
23
@@ -28,7 +29,8 @@ def sector_bounds(fcn):
28
29
raise NotImplementedError ("function not currently implemented" )
29
30
30
31
31
- def describing_function (fcn , amp , num_points = 100 , zero_check = True ):
32
+ def describing_function (
33
+ fcn , amp , num_points = 100 , zero_check = True , try_method = True ):
32
34
"""Numerical compute the describing function of a nonlinear function
33
35
34
36
The describing function of a static nonlinear function is given by
@@ -47,6 +49,18 @@ def describing_function(fcn, amp, num_points=100, zero_check=True):
47
49
amp : float or array
48
50
The amplitude(s) at which the describing function should be calculated.
49
51
52
+ zero_check : bool, optional
53
+ If `True` (default) then `amp` is zero, the function will be evaluated
54
+ and checked to make sure it is zero. If not, a `TypeError` exception
55
+ is raised. If zero_check is `False`, no check is made on the value of
56
+ the function at zero.
57
+
58
+ try_method : bool, optional
59
+ If `True` (default), check the `fcn` argument to see if it is an
60
+ object with a `describing_function` method and use this to compute the
61
+ describing function. See the :class:`NonlienarFunction` class for
62
+ more information on the `describing_function` method.
63
+
50
64
Returns
51
65
-------
52
66
df : complex or array of complex
@@ -58,6 +72,14 @@ def describing_function(fcn, amp, num_points=100, zero_check=True):
58
72
If amp < 0 or if amp = 0 and the function fcn(0) is non-zero.
59
73
60
74
"""
75
+ # If there is an analytical solution, trying using that first
76
+ if try_method and hasattr (fcn , 'describing_function' ):
77
+ # Go through all of the amplitudes we were given
78
+ df = []
79
+ for a in np .atleast_1d (amp ):
80
+ df .append (fcn .describing_function (a ))
81
+ return np .array (df ).reshape (np .shape (amp ))
82
+
61
83
#
62
84
# The describing function of a nonlinear function F() can be computed by
63
85
# evaluating the nonlinearity over a sinusoid. The Fourier series for a
@@ -83,7 +105,7 @@ def describing_function(fcn, amp, num_points=100, zero_check=True):
83
105
#
84
106
# From these we can compute M1 and \phi.
85
107
#
86
-
108
+
87
109
# Evaluate over a full range of angles
88
110
theta = np .linspace (0 , 2 * np .pi , num_points )
89
111
dtheta = theta [1 ] - theta [0 ]
@@ -122,7 +144,8 @@ def describing_function(fcn, amp, num_points=100, zero_check=True):
122
144
return np .array (df ).reshape (np .shape (amp ))
123
145
124
146
125
- def describing_function_plot (H , F , a , omega = None ):
147
+ def describing_function_plot (
148
+ H , F , a , omega = None , refine = True , label = "%5.2g @ %-5.2g" , ** kwargs ):
126
149
"""Plot a Nyquist plot with a describing function for a nonlinear system.
127
150
128
151
This function generates a Nyquist plot for a closed loop system consisting
@@ -133,24 +156,98 @@ def describing_function_plot(H, F, a, omega=None):
133
156
H : LTI system
134
157
Linear time-invariant (LTI) system (state space, transfer function, or
135
158
FRD)
136
- F : static nonlinear function
159
+ F : static nonlinear function
137
160
A static nonlinearity, either a scalar function or a single-input,
138
161
single-output, static input/output system.
139
162
a : list
140
163
List of amplitudes to be used for the describing function plot.
141
164
omega : list, optional
142
165
List of frequences to be used for the linear system Nyquist curve.
143
166
167
+ Returns
168
+ -------
169
+ intersection_list : 1D array of 2-tuples
170
+ A list of all amplitudes and frequencies in which :math:`H(j\\ omega)
171
+ N(a) = -1`, where :math:`N(a)` is the describing function associated
172
+ with `F`, or `None` if there are no such points. Each pair represents
173
+ a potential limit cycle for the closed loop system.
174
+
144
175
"""
145
176
# Start by drawing a Nyquist curve
146
- H_real , H_imag , H_omega = nyquist_plot (H , omega , plot = True )
177
+ H_real , H_imag , H_omega = nyquist_plot (H , omega , plot = True , ** kwargs )
178
+ H_vals = H_real + 1j * H_imag
147
179
148
180
# Compute the describing function
149
181
df = describing_function (F , a )
150
- dfinv = - 1 / df
151
-
152
- # Now add on the describing function
153
- plt .plot (dfinv .real , dfinv .imag )
182
+ N_vals = - 1 / df
183
+
184
+ # Now add the describing function curve to the plot
185
+ plt .plot (N_vals .real , N_vals .imag )
186
+
187
+ # Look for intersection points
188
+ intersections = []
189
+ for i in range (N_vals .size - 1 ):
190
+ for j in range (H_vals .size - 1 ):
191
+ intersect = _find_intersection (
192
+ N_vals [i ], N_vals [i + 1 ], H_vals [j ], H_vals [j + 1 ])
193
+ if intersect == None :
194
+ continue
195
+
196
+ # Found an intersection, compute a and omega
197
+ s_amp , s_omega = intersect
198
+ a_guess = (1 - s_amp ) * a [i ] + s_amp * a [i + 1 ]
199
+ omega_guess = (1 - s_omega ) * H_omega [j ] + s_omega * H_omega [j + 1 ]
200
+
201
+ # Refine the coarse estimate to get better intersection point
202
+ a_final , omega_final = a_guess , omega_guess
203
+ if refine :
204
+ # Refine the answer to get more accuracy
205
+ def _cost (x ):
206
+ return abs (1 + H (1j * x [1 ]) *
207
+ describing_function (F , x [0 ]))** 2
208
+ res = scipy .optimize .minimize (_cost , [a_guess , omega_guess ])
209
+
210
+ if not res .success :
211
+ warn ("not able to refine result; returning estimate" )
212
+ else :
213
+ a_final , omega_final = res .x [0 ], res .x [1 ]
214
+
215
+ # Add labels to the intersection points
216
+ if label :
217
+ pos = H (1j * omega_final )
218
+ plt .text (pos .real , pos .imag , label % (a_final , omega_final ))
219
+
220
+ # Save the final estimate
221
+ intersections .append ((a_final , omega_final ))
222
+
223
+ return intersections
224
+
225
+ # Figure out whether two line segments intersection
226
+ def _find_intersection (L1a , L1b , L2a , L2b ):
227
+ # Compute the tangents for the segments
228
+ L1t = L1b - L1a
229
+ L2t = L2b - L2a
230
+
231
+ # Set up components of the solution: b = M s
232
+ b = L1a - L2a
233
+ detM = L1t .imag * L2t .real - L1t .real * L2t .imag
234
+ if abs (detM ) < 1e-8 : # TODO: fix magic number
235
+ return None
236
+
237
+ # Solve for the intersection points on each line segment
238
+ s1 = (L2t .imag * b .real - L2t .real * b .imag ) / detM
239
+ if s1 < 0 or s1 > 1 :
240
+ return None
241
+
242
+ s2 = (L1t .imag * b .real - L1t .real * b .imag ) / detM
243
+ if s2 < 0 or s2 > 1 :
244
+ return None
245
+
246
+ # Debugging test
247
+ np .testing .assert_almost_equal (L1a + s1 * L1t , L2a + s2 * L2t )
248
+
249
+ # Intersection is within segments; return proportional distance
250
+ return (s1 , s2 )
154
251
155
252
156
253
# Class for nonlinear functions
@@ -195,49 +292,38 @@ def describing_function(self, A):
195
292
return (math .sin (alpha + beta ) * math .cos (alpha - beta ) +
196
293
(alpha + beta )) / math .pi
197
294
198
-
199
- # Hysteresis w/ deadzone (#40 in Gelb and Vander Velde, 1968 )
200
- class hysteresis_deadzone_nonlinearity (NonlinearFunction ):
201
- def __init__ (self , delta , D , m ):
295
+
296
+ # Relay with hysteresis (FBS2e, Example 10.12 )
297
+ class relay_hysteresis_nonlinearity (NonlinearFunction ):
298
+ def __init__ (self , b , c ):
202
299
# Initialize the state to bottom branch
203
300
self .branch = - 1 # lower branch
204
- self .delta = delta
205
- self .D = D
206
- self .m = m
301
+ self .b = b
302
+ self .c = c
207
303
208
304
def __call__ (self , x ):
209
- if x > self .delta + self . D / self . m :
210
- y = self .m * ( x - self . delta )
305
+ if x > self .c :
306
+ y = self .b
211
307
self .branch = 1
212
- elif x < - self .delta - self . D / self . m :
213
- y = self .m * ( x + self . delta )
308
+ elif x < - self .c :
309
+ y = - self .b
214
310
self .branch = - 1
215
- elif self .branch == - 1 and \
216
- x > - self .delta - self .D / self .m and \
217
- x < self .delta - self .D / self .m :
218
- y = - self .D
219
- elif self .branch == - 1 and x >= self .delta - self .D / self .m :
220
- y = self .m * (x - self .delta )
221
- elif self .branch == 1 and \
222
- x > - self .delta + self .D / self .m and \
223
- x < self .delta + self .D / self .m :
224
- y = self .D
225
- elif self .branch == 1 and x <= - self .delta + self .D / self .m :
226
- y = self .m * (x + self .delta )
311
+ elif self .branch == - 1 :
312
+ y = - self .b
313
+ elif self .branch == 1 :
314
+ y = self .b
227
315
return y
228
316
229
- def describing_function (self , A ):
317
+ def describing_function (self , a ):
230
318
def f (x ):
231
319
return math .copysign (1 , x ) if abs (x ) > 1 else \
232
320
(math .asin (x ) + x * math .sqrt (1 - x ** 2 )) * 2 / math .pi
233
321
234
- if A < self .delta + self . D / self . m :
322
+ if a < self .c :
235
323
return np .nan
236
-
237
- df_real = self .m / 2 * \
238
- (2 - self ._f ((self .D / self .m + self .delta )/ A ) +
239
- self ._f ((self .D / self .m - self .delta )/ A ))
240
- df_imag = - 4 * self .D * self .delta / (math .pi * A ** 2 )
324
+
325
+ df_real = 4 * self .b * math .sqrt (1 - (self .c / a )** 2 ) / (a * math .pi )
326
+ df_imag = - 4 * self .b * self .c / (math .pi * a ** 2 )
241
327
return df_real + 1j * df_imag
242
328
243
329
@@ -248,17 +334,23 @@ def __init__(self, b):
248
334
self .center = 0 # current center position
249
335
250
336
def __call__ (self , x ):
251
- # If we are outside the backlash, move and shift the center
252
- if x - self .center > self .b / 2 :
253
- self .center = x - self .b / 2
254
- elif x - self .center < - self .b / 2 :
255
- self .center = x + self .b / 2
256
- return self .center
337
+ # Convert input to an array
338
+ x_array = np .array (x )
339
+
340
+ y = []
341
+ for x in np .atleast_1d (x_array ):
342
+ # If we are outside the backlash, move and shift the center
343
+ if x - self .center > self .b / 2 :
344
+ self .center = x - self .b / 2
345
+ elif x - self .center < - self .b / 2 :
346
+ self .center = x + self .b / 2
347
+ y .append (self .center )
348
+ return (np .array (y ).reshape (x_array .shape ))
257
349
258
350
def describing_function (self , A ):
259
- if A < self .b / 2 :
351
+ if A <= self .b / 2 :
260
352
return 0
261
-
353
+
262
354
df_real = (1 + self ._f (1 - self .b / A )) / 2
263
355
df_imag = - (2 * self .b / A - (self .b / A )** 2 ) / math .pi
264
356
return df_real + 1j * df_imag
0 commit comments