Skip to content

Commit 17ed781

Browse files
committed
address @roryyorke review comments
1 parent 2ac5d9d commit 17ed781

6 files changed

Lines changed: 96 additions & 63 deletions

File tree

control/descfcn.py

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
# RMM, 23 Jan 2021
44
#
55
# This module adds functions for carrying out analysis of systems with
6-
# static nonlinear feedback functions using describing functions.
6+
# memoryless nonlinear feedback functions using describing functions.
77
#
88

99
"""The :mod:~control.descfcn` module contains function for performing
10-
closed loop analysis of systems with static nonlinearities using
10+
closed loop analysis of systems with memoryless nonlinearities using
1111
describing function analysis.
1212
1313
"""
@@ -26,21 +26,21 @@
2626

2727
# Class for nonlinearities with a built-in describing function
2828
class DescribingFunctionNonlinearity():
29-
"""Base class for nonlinear functions with a describing function
29+
"""Base class for nonlinear systems with a describing function
3030
3131
This class is intended to be used as a base class for nonlinear functions
32-
that have a analytically defined describing function (accessed via the
33-
:meth:`describing_function` method). Objects using this class should also
34-
implement a `call` method that evaluates the nonlinearity at a given point
35-
and an `_isstatic` method that is `True` if the nonlinearity has no
36-
internal state.
32+
that have an analytically defined describing function. Subclasses should
33+
override the `__call__` and `describing_function` methods and (optionally)
34+
the `_isstatic` method (should be `False` if `__call__` updates the
35+
instance state).
3736
3837
"""
3938
def __init__(self):
40-
"""Initailize a describing function nonlinearity"""
39+
"""Initailize a describing function nonlinearity (optional)"""
4140
pass
4241

4342
def __call__(self, A):
43+
"""Evaluate the nonlinearity at a (scalar) input value"""
4444
raise NotImplementedError(
4545
"__call__() not implemented for this function (internal error)")
4646

@@ -56,9 +56,15 @@ def describing_function(self, A):
5656
"describing function not implemented for this function")
5757

5858
def _isstatic(self):
59-
"""Return True if the function has not internal state"""
60-
raise NotImplementedError(
61-
"_isstatic() not implemented for this function (internal error)")
59+
"""Return True if the function has no internal state (memoryless)
60+
61+
This internal function is used to optimize numerical computation of
62+
the describing function. It can be set to `True` if the instance
63+
maintains no internal memory of the instance state. Assumed False by
64+
default.
65+
66+
"""
67+
return False
6268

6369
# Utility function used to compute common describing functions
6470
def _f(self, x):
@@ -70,10 +76,10 @@ def describing_function(
7076
F, A, num_points=100, zero_check=True, try_method=True):
7177
"""Numerical compute the describing function of a nonlinear function
7278
73-
The describing function of a static nonlinear function is given by
74-
magnitude and phase of the first harmonic of the function when evaluated
75-
along a sinusoidal input :math:`a \\sin \\omega t`. This function returns
76-
the magnitude and phase of the describing function at amplitude :math:`A`.
79+
The describing function of a nonlinearity is given by magnitude and phase
80+
of the first harmonic of the function when evaluated along a sinusoidal
81+
input :math:`A \\sin \\omega t`. This function returns the magnitude and
82+
phase of the describing function at amplitude :math:`A`.
7783
7884
Parameters
7985
----------
@@ -119,11 +125,15 @@ def describing_function(
119125
"""
120126
# If there is an analytical solution, trying using that first
121127
if try_method and hasattr(F, 'describing_function'):
122-
# Go through all of the amplitudes we were given
123-
df = []
124-
for a in np.atleast_1d(A):
125-
df.append(F.describing_function(a))
126-
return np.array(df).reshape(np.shape(A))
128+
try:
129+
# Go through all of the amplitudes we were given
130+
df = []
131+
for a in np.atleast_1d(A):
132+
df.append(F.describing_function(a))
133+
return np.array(df).reshape(np.shape(A))
134+
except NotImplementedError:
135+
# Drop through and do the numerical computation
136+
pass
127137

128138
#
129139
# The describing function of a nonlinear function F() can be computed by
@@ -136,24 +146,24 @@ def describing_function(
136146
#
137147
# N(A) = M_1(A) e^{j \phi_1(A)} / A
138148
#
139-
# To compute this, we compute F(\theta) for \theta between 0 and 2 \pi,
140-
# use the identities
149+
# To compute this, we compute F(A \sin\theta) for \theta between 0 and 2
150+
# \pi, use the identities
141151
#
142152
# \sin(\theta + \phi) = \sin\theta \cos\phi + \cos\theta \sin\phi
143153
# \int_0^{2\pi} \sin^2 \theta d\theta = \pi
144154
# \int_0^{2\pi} \cos^2 \theta d\theta = \pi
145155
#
146156
# and then integrate the product against \sin\theta and \cos\theta to obtain
147157
#
148-
# \int_0^{2\pi} F(a\sin\theta) \sin\theta d\theta = M_1 \pi \cos\phi
149-
# \int_0^{2\pi} F(a\sin\theta) \cos\theta d\theta = M_1 \pi \sin\phi
158+
# \int_0^{2\pi} F(A\sin\theta) \sin\theta d\theta = M_1 \pi \cos\phi
159+
# \int_0^{2\pi} F(A\sin\theta) \cos\theta d\theta = M_1 \pi \sin\phi
150160
#
151161
# From these we can compute M1 and \phi.
152162
#
153163

154-
# Evaluate over a full range of angles
155-
theta = np.linspace(0, 2*np.pi, num_points)
156-
dtheta = theta[1] - theta[0]
164+
# Evaluate over a full range of angles (leave off endpoint a la DFT)
165+
theta, dtheta = np.linspace(
166+
0, 2*np.pi, num_points, endpoint=False, retstep=True)
157167
sin_theta = np.sin(theta)
158168
cos_theta = np.cos(theta)
159169

@@ -175,10 +185,10 @@ def describing_function(
175185
elif a < 0:
176186
raise ValueError("cannot evaluate describing function for A < 0")
177187

178-
# Save the scaling factor for to make the formulas simpler
188+
# Save the scaling factor to make the formulas simpler
179189
scale = dtheta / np.pi / a
180190

181-
# Evaluate the function (twice) along a sinusoid (for internal state)
191+
# Evaluate the function along a sinusoid
182192
F_eval = np.array([F(x) for x in a*sin_theta]).squeeze()
183193

184194
# Compute the prjections onto sine and cosine
@@ -353,7 +363,7 @@ def __init__(self, ub=1, lb=None):
353363
self.ub = ub
354364

355365
def __call__(self, x):
356-
return np.maximum(self.lb, np.minimum(x, self.ub))
366+
return np.clip(x, self.lb, self.ub)
357367

358368
def _isstatic(self):
359369
return True
@@ -453,18 +463,12 @@ def __init__(self, b):
453463
self.center = 0 # current center position
454464

455465
def __call__(self, x):
456-
# Convert input to an array
457-
x_array = np.array(x)
458-
459-
y = []
460-
for x in np.atleast_1d(x_array):
461-
# If we are outside the backlash, move and shift the center
462-
if x - self.center > self.b/2:
463-
self.center = x - self.b/2
464-
elif x - self.center < -self.b/2:
465-
self.center = x + self.b/2
466-
y.append(self.center)
467-
return(np.array(y).reshape(x_array.shape))
466+
# If we are outside the backlash, move and shift the center
467+
if x - self.center > self.b/2:
468+
self.center = x - self.b/2
469+
elif x - self.center < -self.b/2:
470+
self.center = x + self.b/2
471+
return self.center
468472

469473
def _isstatic(self):
470474
return False

control/freqplot.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -522,10 +522,8 @@ def gen_zero_centered_series(val_min, val_max, period):
522522

523523
def nyquist_plot(
524524
syslist, omega=None, plot=True, omega_limits=None, omega_num=None,
525-
label_freq=0, arrowhead_length=0.1, arrowhead_width=0.1,
526-
mirror='--', color=None, *args, **kwargs):
527-
"""
528-
Nyquist plot for a system
525+
label_freq=0, color=None, mirror='--', arrowhead_length=0.1,
526+
arrowhead_width=0.1, *args, **kwargs): """Nyquist plot for a system
529527
530528
Plots a Nyquist plot for the system over a (optional) frequency range.
531529

control/iosys.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@ def __neg__(sys):
325325
# Return the newly created system
326326
return newsys
327327

328+
def _isstatic(self):
329+
"""Check to see if a system is a static system (no states)"""
330+
return self.nstates == 0
331+
328332
# Utility function to parse a list of signals
329333
def _process_signal_list(self, signals, prefix='s'):
330334
if signals is None:
@@ -450,10 +454,6 @@ def issiso(self):
450454
"""Check to see if a system is single input, single output"""
451455
return self.ninputs == 1 and self.noutputs == 1
452456

453-
def isstatic(self):
454-
"""Check to see if a system is a static system (no states)"""
455-
return self.nstates == 0
456-
457457
def feedback(self, other=1, sign=-1, params={}):
458458
"""Feedback interconnection between two input/output systems
459459
@@ -812,9 +812,28 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None,
812812
self._current_params = params.copy()
813813

814814
# Return the value of a static nonlinear system
815-
def __call__(sys, u, squeeze=None, params=None):
815+
def __call__(sys, u, params=None, squeeze=None):
816+
"""Evaluate a (static) nonlinearity at a given input value
817+
818+
If a nonlinear I/O system has not internal state, then evaluating the
819+
system at an input `u` gives the output `y = F(u)`, determined by the
820+
output function.
821+
822+
Parameters
823+
----------
824+
params : dict, optional
825+
Parameter values for the system. Passed to the evaluation function
826+
for the system as default values, overriding internal defaults.
827+
squeeze : bool, optional
828+
If True and if the system has a single output, return the system
829+
output as a 1D array rather than a 2D array. If False, return the
830+
system output as a 2D array even if the system is SISO. Default
831+
value set by config.defaults['control.squeeze_time_response'].
832+
833+
"""
834+
816835
# Make sure the call makes sense
817-
if not sys.isstatic():
836+
if not sys._isstatic():
818837
raise TypeError(
819838
"function evaluation is only supported for static "
820839
"input/output systems")

control/tests/descfcn_test.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717

1818

1919
# Static function via a class
20-
class saturation_class():
20+
class saturation_class:
2121
# Static nonlinear saturation function
2222
def __call__(self, x, lb=-1, ub=1):
23-
return np.maximum(lb, np.minimum(x, ub))
23+
return np.clip(x, lb, ub)
2424

2525
# Describing function for a saturation function
2626
def describing_function(self, a):
@@ -33,7 +33,7 @@ def describing_function(self, a):
3333

3434
# Static function without a class
3535
def saturation(x):
36-
return np.maximum(-1, np.minimum(x, 1))
36+
return np.clip(x, -1, 1)
3737

3838

3939
# Static nonlinear system implementing saturation
@@ -47,7 +47,7 @@ def _satfcn(t, x, u, params):
4747

4848
def test_static_nonlinear_call(satsys):
4949
# Make sure that the saturation system is a static nonlinearity
50-
assert satsys.isstatic()
50+
assert satsys._isstatic()
5151

5252
# Make sure the saturation function is doing the right computation
5353
input = [-2, -1, -0.5, 0, 0.5, 1, 2]
@@ -61,7 +61,7 @@ def test_static_nonlinear_call(satsys):
6161
np.testing.assert_array_equal(satsys([0.]), [0.])
6262

6363
# Test SIMO nonlinearity
64-
def _simofcn(t, x, u, params={}):
64+
def _simofcn(t, x, u, params):
6565
return np.array([np.cos(u), np.sin(u)])
6666
simo_sys = ct.NonlinearIOSystem(None, outfcn=_simofcn, input=1, output=2)
6767
np.testing.assert_array_equal(simo_sys([0.]), [1, 0])
@@ -72,7 +72,6 @@ def _misofcn(t, x, u, params={}):
7272
return np.array([np.sin(u[0]) * np.cos(u[1])])
7373
miso_sys = ct.NonlinearIOSystem(None, outfcn=_misofcn, input=2, output=1)
7474
np.testing.assert_array_equal(miso_sys([0, 0]), [0])
75-
np.testing.assert_array_equal(miso_sys([0, 0]), [0])
7675
np.testing.assert_array_equal(miso_sys([0, 0], squeeze=True), [0])
7776

7877

@@ -85,6 +84,10 @@ def test_saturation_describing_function(satsys):
8584
df_anal = [satfcn.describing_function(a) for a in amprange]
8685

8786
# Compute describing function for a static function
87+
df_fcn = [ct.describing_function(saturation, a) for a in amprange]
88+
np.testing.assert_almost_equal(df_fcn, df_anal, decimal=3)
89+
90+
# Compute describing function for a describing function nonlinearity
8891
df_fcn = [ct.describing_function(satfcn, a) for a in amprange]
8992
np.testing.assert_almost_equal(df_fcn, df_anal, decimal=3)
9093

@@ -100,6 +103,15 @@ def test_saturation_describing_function(satsys):
100103
with pytest.raises(ValueError, match="cannot evaluate"):
101104
ct.describing_function(saturation, -1)
102105

106+
# Create describing function nonlinearity w/out describing_function method
107+
# and make sure it drops through to the underlying computation
108+
class my_saturation(ct.DescribingFunctionNonlinearity):
109+
def __call__(self, x):
110+
return saturation(x)
111+
satfcn_nometh = my_saturation()
112+
df_nometh = [ct.describing_function(satfcn_nometh, a) for a in amprange]
113+
np.testing.assert_almost_equal(df_nometh, df_anal, decimal=3)
114+
103115

104116
@pytest.mark.parametrize("fcn, amin, amax", [
105117
[saturation_nonlinearity(1), 0, 10],
@@ -118,7 +130,7 @@ def test_describing_function(fcn, amin, amax):
118130

119131
# Make sure the describing function method also works
120132
df_meth = ct.describing_function(fcn, amprange, zero_check=False)
121-
np.testing.assert_almost_equal(df_meth, df_anal, decimal=1)
133+
np.testing.assert_almost_equal(df_meth, df_anal)
122134

123135
# Make sure that evaluation at negative amplitude generates an exception
124136
with pytest.raises(ValueError, match="cannot evaluate"):

doc/descfcn.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ For nonlinear systems consisting of a feedback connection between a
88
linear system and a static nonlinearity, it is possible to obtain a
99
generalization of Nyquist's stability criterion based on the idea of
1010
describing functions. The basic concept involves approximating the
11-
response of a static nonlinearity to an input :math:`u = A e^{\omega
12-
t}` as an output :math:`y = N(A) (A e^{\omega t})`, where :math:`N(A)
11+
response of a static nonlinearity to an input :math:`u = A e^{j \omega
12+
t}` as an output :math:`y = N(A) (A e^{j \omega t})`, where :math:`N(A)
1313
\in \mathbb{C}` represents the (amplitude-dependent) gain and phase
1414
associated with the nonlinearity.
1515

examples/describing_functions.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
"backlash = ct.backlash_nonlinearity(0.5)\n",
123123
"theta = np.linspace(0, 2*np.pi, 50)\n",
124124
"x = np.sin(theta)\n",
125-
"plt.plot(x, backlash(x))\n",
125+
"plt.plot(x, [backlash(z) for z in x])\n",
126126
"plt.xlabel(\"Input, x\")\n",
127127
"plt.ylabel(\"Output, y = backlash(x)\")\n",
128128
"plt.title(\"Input/output map for a backlash nonlinearity\");"

0 commit comments

Comments
 (0)