|
| 1 | +# nltools.py - nonlinear feedback analysis |
| 2 | +# |
| 3 | +# RMM, 23 Jan 2021 |
| 4 | +# |
| 5 | +# This module adds functions for carrying out analysis of systems with |
| 6 | +# static nonlinear feedback functions using the circle criterion and |
| 7 | +# describing functions. |
| 8 | +# |
| 9 | + |
| 10 | +"""The :mod:~control.nltools` module contains function for performing closed |
| 11 | +loop analysis of systems with static nonlinearities. It is built around the |
| 12 | +basic structure required to apply the circle criterion and describing function |
| 13 | +analysis. |
| 14 | +
|
| 15 | +""" |
| 16 | + |
| 17 | +import math |
| 18 | +import numpy as np |
| 19 | +import matplotlib.pyplot as plt |
| 20 | +from numpy import where, dstack, diff, meshgrid |
| 21 | +from warnings import warn |
| 22 | + |
| 23 | +from .freqplot import nyquist_plot |
| 24 | + |
| 25 | +__all__ = ['describing_function', 'describing_function_plot', 'sector_bounds'] |
| 26 | + |
| 27 | +def sector_bounds(fcn): |
| 28 | + raise NotImplementedError("function not currently implemented") |
| 29 | + |
| 30 | + |
| 31 | +def describing_function(fcn, amp, num_points=100, zero_check=True): |
| 32 | + """Numerical compute the describing function of a nonlinear function |
| 33 | +
|
| 34 | + The describing function of a static nonlinear function is given by |
| 35 | + magnitude and phase of the first harmonic of the function when evaluated |
| 36 | + along a sinusoidal input :math:`a \\sin \\omega t`. This function returns |
| 37 | + the magnitude and phase of the describing function at amplitude :math:`a`. |
| 38 | +
|
| 39 | + Parameters |
| 40 | + ---------- |
| 41 | + fcn : callable |
| 42 | + The function fcn() should accept a scalar number as an argument and |
| 43 | + return a scalar number. For compatibility with (static) nonlinear |
| 44 | + input/output systems, the output can also return a 1D array with a |
| 45 | + single element. |
| 46 | +
|
| 47 | + amp : float or array |
| 48 | + The amplitude(s) at which the describing function should be calculated. |
| 49 | +
|
| 50 | + Returns |
| 51 | + ------- |
| 52 | + df : complex or array of complex |
| 53 | + The (complex) value of the describing fuction at the given amplitude. |
| 54 | +
|
| 55 | + Raises |
| 56 | + ------ |
| 57 | + TypeError |
| 58 | + If amp < 0 or if amp = 0 and the function fcn(0) is non-zero. |
| 59 | +
|
| 60 | + """ |
| 61 | + # |
| 62 | + # The describing function of a nonlinear function F() can be computed by |
| 63 | + # evaluating the nonlinearity over a sinusoid. The Fourier series for a |
| 64 | + # static noninear function evaluated on a sinusoid can be written as |
| 65 | + # |
| 66 | + # F(a\sin\omega t) = \sum_{k=1}^\infty M_k(a) \sin(k\omega t + \phi_k(a)) |
| 67 | + # |
| 68 | + # The describing function is given by the complex number |
| 69 | + # |
| 70 | + # N(a) = M_1(a) e^{j \phi_1(a)} / a |
| 71 | + # |
| 72 | + # To compute this, we compute F(\theta) for \theta between 0 and 2 \pi, |
| 73 | + # use the identities |
| 74 | + # |
| 75 | + # \sin(\theta + \phi) = \sin\theta \cos\phi + \cos\theta \sin\phi |
| 76 | + # \int_0^{2\pi} \sin^2 \theta d\theta = \pi |
| 77 | + # \int_0^{2\pi} \cos^2 \theta d\theta = \pi |
| 78 | + # |
| 79 | + # and then integate the product against \sin\theta and \cos\theta to obtain |
| 80 | + # |
| 81 | + # \int_0^{2\pi} F(a\sin\theta) \sin\theta d\theta = M_1 \pi \cos\phi |
| 82 | + # \int_0^{2\pi} F(a\sin\theta) \cos\theta d\theta = M_1 \pi \sin\phi |
| 83 | + # |
| 84 | + # From these we can compute M1 and \phi. |
| 85 | + # |
| 86 | + |
| 87 | + # Evaluate over a full range of angles |
| 88 | + theta = np.linspace(0, 2*np.pi, num_points) |
| 89 | + dtheta = theta[1] - theta[0] |
| 90 | + sin_theta = np.sin(theta) |
| 91 | + cos_theta = np.cos(theta) |
| 92 | + |
| 93 | + # Initialize any internal state by going through an initial cycle |
| 94 | + [fcn(x) for x in np.atleast_1d(amp).min() * sin_theta] |
| 95 | + |
| 96 | + # Go through all of the amplitudes we were given |
| 97 | + df = [] |
| 98 | + for a in np.atleast_1d(amp): |
| 99 | + # Make sure we got a valid argument |
| 100 | + if a == 0: |
| 101 | + # Check to make sure the function has zero output with zero input |
| 102 | + if zero_check and np.squeeze(fcn(0.)) != 0: |
| 103 | + raise ValueError("function must evaluate to zero at zero") |
| 104 | + df.append(1.) |
| 105 | + continue |
| 106 | + elif a < 0: |
| 107 | + raise ValueError("cannot evaluate describing function for amp < 0") |
| 108 | + |
| 109 | + # Save the scaling factor for to make the formulas simpler |
| 110 | + scale = dtheta / np.pi / a |
| 111 | + |
| 112 | + # Evaluate the function (twice) along a sinusoid (for internal state) |
| 113 | + fcn_eval = np.array([fcn(x) for x in a*sin_theta]).squeeze() |
| 114 | + |
| 115 | + # Compute the prjections onto sine and cosine |
| 116 | + df_real = (fcn_eval @ sin_theta) * scale # = M_1 \cos\phi / a |
| 117 | + df_imag = (fcn_eval @ cos_theta) * scale # = M_1 \sin\phi / a |
| 118 | + |
| 119 | + df.append(df_real + 1j * df_imag) |
| 120 | + |
| 121 | + # Return the values in the same shape as they were requested |
| 122 | + return np.array(df).reshape(np.shape(amp)) |
| 123 | + |
| 124 | + |
| 125 | +def describing_function_plot(H, F, a, omega=None): |
| 126 | + """Plot a Nyquist plot with a describing function for a nonlinear system. |
| 127 | +
|
| 128 | + This function generates a Nyquist plot for a closed loop system consisting |
| 129 | + of a linear system with a static nonlinear function in the feedback path. |
| 130 | +
|
| 131 | + Parameters |
| 132 | + ---------- |
| 133 | + H : LTI system |
| 134 | + Linear time-invariant (LTI) system (state space, transfer function, or |
| 135 | + FRD) |
| 136 | + F : static nonlinear function |
| 137 | + A static nonlinearity, either a scalar function or a single-input, |
| 138 | + single-output, static input/output system. |
| 139 | + a : list |
| 140 | + List of amplitudes to be used for the describing function plot. |
| 141 | + omega : list, optional |
| 142 | + List of frequences to be used for the linear system Nyquist curve. |
| 143 | +
|
| 144 | + """ |
| 145 | + # Start by drawing a Nyquist curve |
| 146 | + H_real, H_imag, H_omega = nyquist_plot(H, omega, plot=True) |
| 147 | + |
| 148 | + # Compute the describing function |
| 149 | + df = describing_function(F, a) |
| 150 | + dfinv = -1/df |
| 151 | + |
| 152 | + # Now add on the describing function |
| 153 | + plt.plot(dfinv.real, dfinv.imag) |
| 154 | + |
| 155 | + |
| 156 | +# Class for nonlinear functions |
| 157 | +class NonlinearFunction(): |
| 158 | + def sector_bounds(self, lb, ub): |
| 159 | + raise NotImplementedError( |
| 160 | + "sector bounds not implemented for this function") |
| 161 | + |
| 162 | + def describing_function(self, amp): |
| 163 | + raise NotImplementedError( |
| 164 | + "describing function not implemented for this function") |
| 165 | + |
| 166 | + # Function to compute the describing function |
| 167 | + def _f(self, x): |
| 168 | + return math.copysign(1, x) if abs(x) > 1 else \ |
| 169 | + (math.asin(x) + x * math.sqrt(1 - x**2)) * 2 / math.pi |
| 170 | + |
| 171 | + |
| 172 | +# Saturation nonlinearity |
| 173 | +class saturation_nonlinearity(NonlinearFunction): |
| 174 | + def __init__(self, ub=1, lb=None): |
| 175 | + # Process arguments |
| 176 | + if<
9920
/span> lb == None: |
| 177 | + # Only received one argument; assume symmetric around zero |
| 178 | + lb, ub = -abs(ub), abs(ub) |
| 179 | + |
| 180 | + # Make sure the bounds are sensity |
| 181 | + if lb > 0 or ub < 0 or lb + ub != 0: |
| 182 | + warn("asymmetric saturation; ignoring non-zero bias term") |
| 183 | + |
| 184 | + self.lb = lb |
| 185 | + self.ub = ub |
| 186 | + |
| 187 | + def __call__(self, x): |
| 188 | + return np.maximum(self.lb, np.minimum(x, self.ub)) |
| 189 | + |
| 190 | + def describing_function(self, A): |
| 191 | + if self.lb <= A and A <= self.ub: |
| 192 | + return 1. |
| 193 | + else: |
| 194 | + alpha, beta = math.asin(self.ub/A), math.asin(-self.lb/A) |
| 195 | + return (math.sin(alpha + beta) * math.cos(alpha - beta) + |
| 196 | + (alpha + beta)) / math.pi |
| 197 | + |
| 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): |
| 202 | + # Initialize the state to bottom branch |
| 203 | + self.branch = -1 # lower branch |
| 204 | + self.delta = delta |
| 205 | + self.D = D |
| 206 | + self.m = m |
| 207 | + |
| 208 | + def __call__(self, x): |
| 209 | + if x > self.delta + self.D / self.m: |
| 210 | + y = self.m * (x - self.delta) |
| 211 | + self.branch = 1 |
| 212 | + elif x < -self.delta - self.D/self.m: |
| 213 | + y = self.m * (x + self.delta) |
| 214 | + 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) |
| 227 | + return y |
| 228 | + |
| 229 | + def describing_function(self, A): |
| 230 | + def f(x): |
| 231 | + return math.copysign(1, x) if abs(x) > 1 else \ |
| 232 | + (math.asin(x) + x * math.sqrt(1 - x**2)) * 2 / math.pi |
| 233 | + |
| 234 | + if A < self.delta + self.D/self.m: |
| 235 | + 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
C378
span>) |
| 241 | + return df_real + 1j * df_imag |
| 242 | + |
| 243 | + |
| 244 | +# Backlash nonlinearity (#48 in Gelb and Vander Velde, 1968) |
| 245 | +class backlash_nonlinearity(NonlinearFunction): |
| 246 | + def __init__(self, b): |
| 247 | + self.b = b # backlash distance |
| 248 | + self.center = 0 # current center position |
| 249 | + |
| 250 | + 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 |
| 257 | + |
| 258 | + def describing_function(self, A): |
| 259 | + if A < self.b/2: |
| 260 | + return 0 |
| 261 | + |
| 262 | + df_real = (1 + self._f(1 - self.b/A)) / 2 |
| 263 | + df_imag = -(2 * self.b/A - (self.b/A)**2) / math.pi |
| 264 | + return df_real + 1j * df_imag |
0 commit comments