1717import math
1818import numpy as np
1919import matplotlib .pyplot as plt
20+ import scipy
2021from numpy import where , dstack , diff , meshgrid
2122from warnings import warn
2223
@@ -28,7 +29,8 @@ def sector_bounds(fcn):
2829 raise NotImplementedError ("function not currently implemented" )
2930
3031
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 ):
3234 """Numerical compute the describing function of a nonlinear function
3335
3436 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):
4749 amp : float or array
4850 The amplitude(s) at which the describing function should be calculated.
4951
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+
5064 Returns
5165 -------
5266 df : complex or array of complex
@@ -58,6 +72,14 @@ def describing_function(fcn, amp, num_points=100, zero_check=True):
5872 If amp < 0 or if amp = 0 and the function fcn(0) is non-zero.
5973
6074 """
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+
6183 #
6284 # The describing function of a nonlinear function F() can be computed by
6385 # 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):
83105 #
84106 # From these we can compute M1 and \phi.
85107 #
86-
108+
87109 # Evaluate over a full range of angles
88110 theta = np .linspace (0 , 2 * np .pi , num_points )
89111 dtheta = theta [1 ] - theta [0 ]
@@ -122,7 +144,8 @@ def describing_function(fcn, amp, num_points=100, zero_check=True):
122144 return np .array (df ).reshape (np .shape (amp ))
123145
124146
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 ):
126149 """Plot a Nyquist plot with a describing function for a nonlinear system.
127150
128151 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):
133156 H : LTI system
134157 Linear time-invariant (LTI) system (state space, transfer function, or
135158 FRD)
136- F : static nonlinear function
159+ F : static nonlinear function
137160 A static nonlinearity, either a scalar function or a single-input,
138161 single-output, static input/output system.
139162 a : list
140163 List of amplitudes to be used for the describing function plot.
141164 omega : list, optional
142165 List of frequences to be used for the linear system Nyquist curve.
143166
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+
144175 """
145176 # 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
147179
148180 # Compute the describing function
149181 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 )
154251
155252
156253# Class for nonlinear functions
@@ -195,49 +292,38 @@ def describing_function(self, A):
195292 return (math .sin (alpha + beta ) * math .cos (alpha - beta ) +
196293 (alpha + beta )) / math .pi
197294
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 ):
202299 # Initialize the state to bottom branch
203300 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
207303
208304 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
211307 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
214310 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
227315 return y
228316
229- def describing_function (self , A ):
317+ def describing_function (self , a ):
230318 def f (x ):
231319 return math .copysign (1 , x ) if abs (x ) > 1 else \
232320 (math .asin (x ) + x * math .sqrt (1 - x ** 2 )) * 2 / math .pi
233321
234- if A < self .delta + self . D / self . m :
322+ if a < self .c :
235323 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 )
241327 return df_real + 1j * df_imag
242328
243329
@@ -248,17 +334,23 @@ def __init__(self, b):
248334 self .center = 0 # current center position
249335
250336 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 ))
257349
258350 def describing_function (self , A ):
259- if A < self .b / 2 :
351+ if A <= self .b / 2 :
260352 return 0
261-
353+
262354 df_real = (1 + self ._f (1 - self .b / A )) / 2
263355 df_imag = - (2 * self .b / A - (self .b / A )** 2 ) / math .pi
264356 return df_real + 1j * df_imag
0 commit comments