8000 initial implementation of describing function computation · python-control/python-control@a027d3c · GitHub
[go: up one dir, main page]

Skip to content 8000

Commit a027d3c

Browse files
committed
initial implementation of describing function computation
1 parent eb146a6 commit a027d3c

File tree

4 files changed

+392
-0
lines changed

4 files changed

+392
-0
lines changed

control/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from .mateqn import *
5656
from .modelsimp import *
5757
from .nichols import *
58+
from .nltools import *
5859
from .phaseplot import *
5960
from .pzmap import *
6061
from .rlocus import *

control/iosys.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,10 @@ def issiso(self):
454454
"""Check to see if a system is single input, single output"""
455455
return self.ninputs == 1 and self.noutputs == 1
456456

457+
def isstatic(self):
458+
"""Check to see if a system is a static system (no states)"""
459+
return self.nstates == 0
460+
457461
def feedback(self, other=1, sign=-1, params={}):
458462
"""Feedback interconnection between two input/output systems
459463
@@ -806,6 +810,23 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None,
806810
# Initialize current parameters to default parameters
807811
self._current_params = params.copy()
808812

813+
# Return the value of a static nonlinear system
814+
def __call__(sys, u, squeeze=None, params=None):
815+
# Make sure the call makes sense
816+
if not sys.isstatic():
817+
raise TypeError(
818+
"function evaluation is only supported for static "
819+
"input/output systems")
820+
821+
# If we received any parameters, update them before calling _out()
822+
if params is not None:
823+
sys._update_params(params)
824+
825+
# Evaluate the function on the argument
826+
out = sys._out(0, np.array((0,)), np.asarray(u))
827+
_, out = _process_time_response(sys, [], out, [], squeeze=squeeze)
828+
return out
829+
809830
def _update_params(self, params, warning=False):
810831
# Update the current parameter values
811832
self._current_params = self.params.copy()

control/nltools.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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)
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

Comments
 (0)
0