Skip to content

Commit e180716

Browse files
committed
add unit tests for exceptions/warnings + cleanup
1 parent 990e63f commit e180716

File tree

3 files changed

+83
-19
lines changed

3 files changed

+83
-19
lines changed

control/descfcn.py

+31-8
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,15 @@ def describing_function_plot(
254254
if refine:
255255
# Refine the answer to get more accuracy
256256
def _cost(x):
257+
# If arguments are invalid, return a "large" value
258+
# Note: imposing bounds messed up the optimization (?)
259+
if x[0] < 0 or x[1] < 0:
260+
return 1
257261
return abs(1 + H(1j * x[1]) *
258262
describing_function(F, x[0]))**2
259-
res = scipy.optimize.minimize(_cost, [a_guess, omega_guess])
263+
res = scipy.optimize.minimize(
264+
_cost, [a_guess, omega_guess])
265+
# bounds=[(A[i], A[i+1]), (H_omega[j], H_omega[j+1])])
260266

261267
if not res.success:
262268
warn("not able to refine result; returning estimate")
@@ -322,6 +328,9 @@ class saturation_nonlinearity(DescribingFunctionNonlinearity):
322328
323329
"""
324330
def __init__(self, ub=1, lb=None):
331+
# Create the describing function nonlinearity object
332+
super(saturation_nonlinearity, self).__init__()
333+
325334
# Process arguments
326335
if lb == None:
327336
# Only received one argument; assume symmetric around zero
@@ -341,6 +350,10 @@ def isstatic(self):
341350
return True
342351

343352
def describing_function(self, A):
353+
# Check to make sure the amplitude is positive
354+
if A < 0:
355+
raise ValueError("cannot evaluate describing function for A < 0")
356+
344357
if self.lb <= A and A <= self.ub:
345358
return 1.
346359
else:
@@ -368,6 +381,9 @@ class relay_hysteresis_nonlinearity(DescribingFunctionNonlinearity):
368381
369382
"""
370383
def __init__(self, b, c):
384+
# Create the describing function nonlinearity object
385+
super(relay_hysteresis_nonlinearity, self).__init__()
386+
371387
# Initialize the state to bottom branch
372388
self.branch = -1 # lower branch
373389
self.b = b # relay output value
@@ -389,16 +405,16 @@ def __call__(self, x):
389405
def isstatic(self):
390406
return False
391407

392-
def describing_function(self, a):
393-
def f(x):
394-
return math.copysign(1, x) if abs(x) > 1 else \
395-
(math.asin(x) + x * math.sqrt(1 - x**2)) * 2 / math.pi
408+
def describing_function(self, A):
409+
# Check to make sure the amplitude is positive
410+
if A < 0:
411+
raise ValueError("cannot evaluate describing function for A < 0")
396412

397-
if a < self.c:
413+
if A < self.c:
398414
return np.nan
399415

400-
df_real = 4 * self.b * math.sqrt(1 - (self.c/a)**2) / (a * math.pi)
401-
df_imag = -4 * self.b * self.c / (math.pi * a**2)
416+
df_real = 4 * self.b * math.sqrt(1 - (self.c/A)**2) / (A * math.pi)
417+
df_imag = -4 * self.b * self.c / (math.pi * A**2)
402418
return df_real + 1j * df_imag
403419

404420

@@ -421,6 +437,9 @@ class backlash_nonlinearity(DescribingFunctionNonlinearity):
421437
"""
422438

423439
def __init__(self, b):
440+
# Create the describing function nonlinearity object
441+
super(backlash_nonlinearity, self).__init__()
442+
424443
self.b = b # backlash distance
425444
self.center = 0 # current center position
426445

@@ -442,6 +461,10 @@ def isstatic(self):
442461
return False
443462

444463
def describing_function(self, A):
464+
# Check to make sure the amplitude is positive
465+
if A < 0:
466+
raise ValueError("cannot evaluate describing function for A < 0")
467+
445468
if A <= self.b/2:
446469
return 0
447470

control/iosys.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -821,7 +821,7 @@ def __call__(sys, u, squeeze=None, params=None):
821821
# If we received any parameters, update them before calling _out()
822822
if params is not None:
823823
sys._update_params(params)
824-
824+
825825
# Evaluate the function on the argument
826826
out = sys._out(0, np.array((0,)), np.asarray(u))
827827
_, out = _process_time_response(sys, [], out, [], squeeze=squeeze)
@@ -2004,7 +2004,7 @@ def interconnect(syslist, connections=[], inplist=[], outlist=[],
20042004
a warning is generated a copy of the system is created with the
20052005
name of the new system determined by adding the prefix and suffix
20062006
strings in config.defaults['iosys.linearized_system_name_prefix']
2007-
and config.defaults['iosys.linearized_system_name_suffix'], with the
2007+
and config.defaults['iosys.linearized_system_name_suffix'], with the
20082008
default being to add the suffix '$copy'$ to the system name.
20092009
20102010
It is possible to replace lists in most of arguments with tuples instead,

control/tests/descfcn_test.py

+50-9
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212
import numpy as np
1313
import control as ct
1414
import math
15+
from control.descfcn import saturation_nonlinearity, backlash_nonlinearity, \
16+
relay_hysteresis_nonlinearity
17+
1518

16-
class saturation():
19+
# Static function via a class
20+
class saturation_class():
1721
# Static nonlinear saturation function
1822
def __call__(self, x, lb=-1, ub=1):
1923
return np.maximum(lb, np.minimum(x, ub))
@@ -27,10 +31,15 @@ def describing_function(self, a):
2731
return 2/math.pi * (math.asin(b) + b * math.sqrt(1 - b**2))
2832

2933

34+
# Static function without a class
35+
def saturation(x):
36+
return np.maximum(-1, np.minimum(x, 1))
37+
38+
3039
# Static nonlinear system implementing saturation
3140
@pytest.fixture
3241
def satsys():
33-
satfcn = saturation()
42+
satfcn = saturation_class()
3443
def _satfcn(t, x, u, params):
3544
return satfcn(u)
3645
return ct.NonlinearIOSystem(None, outfcn=_satfcn, input=1, output=1)
@@ -65,16 +74,16 @@ def _misofcn(t, x, u, params={}):
6574
np.testing.assert_array_equal(miso_sys([0, 0]), [0])
6675
np.testing.assert_array_equal(miso_sys([0, 0]), [0])
6776
np.testing.assert_array_equal(miso_sys([0, 0], squeeze=True), [0])
68-
77+
6978

7079
# Test saturation describing function in multiple ways
7180
def test_saturation_describing_function(satsys):
72-
satfcn = saturation()
73-
81+
satfcn = saturation_class()
82+
7483
# Store the analytic describing function for comparison
7584
amprange = np.linspace(0, 10, 100)
7685
df_anal = [satfcn.describing_function(a) for a in amprange]
77-
86+
7887
# Compute describing function for a static function
7988
df_fcn = [ct.describing_function(satfcn, a) for a in amprange]
8089
np.testing.assert_almost_equal(df_fcn, df_anal, decimal=3)
@@ -87,8 +96,9 @@ def test_saturation_describing_function(satsys):
8796
df_arr = ct.describing_function(satsys, amprange)
8897
np.testing.assert_almost_equal(df_arr, df_anal, decimal=3)
8998

90-
from control.descfcn import saturation_nonlinearity, backlash_nonlinearity, \
91-
relay_hysteresis_nonlinearity
99+
# Evaluate static function at a negative amplitude
100+
with pytest.raises(ValueError, match="cannot evaluate"):
101+
ct.describing_function(saturation, -1)
92102

93103

94104
@pytest.mark.parametrize("fcn, amin, amax", [
@@ -100,7 +110,7 @@ def test_describing_function(fcn, amin, amax):
100110
# Store the analytic describing function for comparison
101111
amprange = np.linspace(amin, amax, 100)
102112
df_anal = [fcn.describing_function(a) for a in amprange]
103-
113+
104114
# Compute describing function on an array of values
105115
df_arr = ct.describing_function(
106116
fcn, amprange, zero_check=False, try_method=False)
@@ -110,6 +120,11 @@ def test_describing_function(fcn, amin, amax):
110120
df_meth = ct.describing_function(fcn, amprange, zero_check=False)
111121
np.testing.assert_almost_equal(df_meth, df_anal, decimal=1)
112122

123+
# Make sure that evaluation at negative amplitude generates an exception
124+
with pytest.raises(ValueError, match="cannot evaluate"):
125+
ct.describing_function(fcn, -1)
126+
127+
113128
def test_describing_function_plot():
114129
# Simple linear system with at most 1 intersection
115130
H_simple = ct.tf([1], [1, 2, 2, 1])
@@ -141,3 +156,29 @@ def test_describing_function_plot():
141156
np.testing.assert_almost_equal(
142157
-1/ct.describing_function(F_backlash, a),
143158
H_multiple(1j*w), decimal=5)
159+
160+
def test_describing_function_exceptions():
161+
# Describing function with non-zero bias
162+
with pytest.warns(UserWarning, match="asymmetric"):
163+
saturation = ct.descfcn.saturation_nonlinearity(lb=-1, ub=2)
164+
assert saturation(-3) == -1
165+
assert saturation(3) == 2
166+
167+
# Turn off the bias check
168+
bias = ct.describing_function(saturation, 0, zero_check=False)
169+
170+
# Function should evaluate to zero at zero amplitude
171+
f = lambda x: x + 0.5
172+
with pytest.raises(ValueError, match="must evaluate to zero"):
173+
bias = ct.describing_function(f, 0, zero_check=True)
174+
175+
# Evaluate at a negative amplitude
176+
with pytest.raises(ValueError, match="cannot evaluate"):
177+
ct.describing_function(saturation, -1)
178+
179+
# Describing function with bad label
180+
H_simple = ct.tf([8], [1, 2, 2, 1])
181+
F_saturation = ct.descfcn.saturation_nonlinearity(1)
182+
amp = np.linspace(1, 4, 10)
183+
with pytest.raises(ValueError, match="formatting string"):
184+
ct.describing_function_plot(H_simple, F_saturation, amp, label=1)

0 commit comments

Comments
 (0)