Skip to content

Commit c596a12

Browse files
authored
More unit tests (#305)
* TST: unit tests for issiso() * add unit test for StateSpace copy constructor * TRV: remove extra space * update issiso() tests for lack of slycot * add unit tests for bdalg coverage + fix uncovered bug * add unit tests for canonical coverage * add unit tests for config coverage + update docs * add test to complete canonical coverage for modal form, mixed real/complex * fix problem with figures persisting beyond config_test * add unit tests for frdata converage + fix small, uncovered errors * derive control exceptions from standard exceptions * flag coverage issue in dtime * fix py2.7 issue with repeated warnings not showing up * add unit tests for xferfcn converage * update xferfcn mimo tests to require slycot
1 parent 7188a9c commit c596a12

17 files changed

+725
-67
lines changed

control/bdalg.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def feedback(sys1, sys2=1, sign=-1):
243243
elif isinstance(sys2, ss.StateSpace):
244244
sys1 = ss._convertToStateSpace(sys1)
245245
elif isinstance(sys2, frd.FRD):
246-
sys1 = ss._convertToFRD(sys1)
246+
sys1 = frd._convertToFRD(sys1, sys2.omega)
247247
else: # sys2 is a scalar.
248248
sys1 = tf._convert_to_transfer_function(sys1)
249249
sys2 = tf._convert_to_transfer_function(sys2)

control/canonical.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ def reachable_form(xsys):
8888
# Transformation from one form to another
8989
Tzx = solve(Wrx.T, Wrz.T).T # matrix right division, Tzx = Wrz * inv(Wrx)
9090

91-
if matrix_rank(Tzx) != xsys.states:
91+
# Check to make sure inversion was OK. Note that since we are inverting
92+
# Wrx and we already checked its rank, this exception should never occur
93+
if matrix_rank(Tzx) != xsys.states: # pragma: no cover
9294
raise ValueError("Transformation matrix singular to working precision.")
9395

9496
# Finally, compute the output matrix

control/config.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@
1414
bode_number_of_samples = None # Bode plot number of samples
1515
bode_feature_periphery_decade = 1.0 # Bode plot feature periphery in decades
1616

17+
18+
def reset_defaults():
19+
"""Reset configuration values to their default values."""
20+
global bode_dB; bode_dB = False
21+
global bode_deg; bode_deg = True
22+
global bode_Hz; bode_Hz = False
23+
global bode_number_of_samples; bode_number_of_samples = None
24+
global bode_feature_periphery_decade; bode_feature_periphery_decade = 1.0
25+
26+
1727
# Set defaults to match MATLAB
1828
def use_matlab_defaults():
1929
"""
@@ -27,6 +37,7 @@ def use_matlab_defaults():
2737
global bode_deg; bode_deg = True
2838
global bode_Hz; bode_Hz = True
2939

40+
3041
# Set defaults to match FBS (Astrom and Murray)
3142
def use_fbs_defaults():
3243
"""
@@ -39,4 +50,5 @@ def use_fbs_defaults():
3950
# Bode plot defaults
4051
global bode_dB; bode_dB = False
4152
global bode_deg; bode_deg = True
42-
global bode_Hz; bode_Hz = True
53+
global bode_Hz; bode_Hz = False
54+

control/dtime.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ def c2d(sysc, Ts, method='zoh'):
112112
'''
113113
# Call the sample_system() function to do the work
114114
sysd = sample_system(sysc, Ts, method)
115+
116+
# TODO: is this check needed? If sysc is StateSpace, sysd is too?
115117
if isinstance(sysc, StateSpace) and not isinstance(sysd, StateSpace):
116-
return _convertToStateSpace(sysd)
118+
return _convertToStateSpace(sysd) # pragma: no cover
119+
117120
return sysd

control/exception.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,24 +39,24 @@
3939
#
4040
# $Id$
4141

42-
class ControlSlycot(Exception):
42+
class ControlSlycot(ImportError):
4343
"""Exception for Slycot import. Used when we can't import a function
4444
from the slycot package"""
4545
pass
4646

47-
class ControlDimension(Exception):
47+
class ControlDimension(ValueError):
4848
"""Raised when dimensions of system objects are not correct"""
4949
pass
5050

51-
class ControlArgument(Exception):
51+
class ControlArgument(TypeError):
5252
"""Raised when arguments to a function are not correct"""
5353
pass
5454

55-
class ControlMIMONotImplemented(Exception):
55+
class ControlMIMONotImplemented(NotImplementedError):
5656
"""Function is not currently implemented for MIMO systems"""
5757
pass
5858

59-
class ControlNotImplemented(Exception):
59+
class ControlNotImplemented(NotImplementedError):
6060
"""Functionality is not yet implemented"""
6161
pass
6262

control/frdata.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"""
5050

5151
# External function declarations
52+
from warnings import warn
5253
import numpy as np
5354
from numpy import angle, array, empty, ones, \
5455
real, imag, matrix, absolute, eye, linalg, where, dot
@@ -187,9 +188,10 @@ def __add__(self, other):
187188

188189
if isinstance(other, FRD):
189190
# verify that the frequencies match
190-
if (other.omega != self.omega).any():
191-
print("Warning: frequency points do not match; expect"
192-
" truncation and interpolation")
191+
if len(other.omega) != len(self.omega) or \
192+
(other.omega != self.omega).any():
193+
warn("Frequency points do not match; expect"
194+
" truncation and interpolation.")
193195

194196
# Convert the second argument to a frequency response function.
195197
# or re-base the frd to the current omega (if needed)
@@ -340,8 +342,9 @@ def evalfr(self, omega):
340342
intermediate values.
341343
342344
"""
343-
warn("FRD.evalfr(omega) will be deprecated in a future release of python-control; use sys.eval(omega) instead",
344-
PendingDeprecationWarning)
345+
warn("FRD.evalfr(omega) will be deprecated in a future release "
346+
"of python-control; use sys.eval(omega) instead",
347+
PendingDeprecationWarning) # pragma: no coverage
345348
return self._evalfr(omega)
346349

347350
# Define the `eval` function to evaluate an FRD at a given (real)
@@ -352,7 +355,7 @@ def evalfr(self, omega):
352355
def eval(self, omega):
353356
"""Evaluate a transfer function at a single angular frequency.
354357
355-
self._evalfr(omega) returns the value of the frequency response
358+
self.evalfr(omega) returns the value of the frequency response
356359
at frequency omega.
357360
358361
Note that a "normal" FRD only returns values for which there is an
@@ -462,7 +465,8 @@ def _convertToFRD(sys, omega, inputs=1, outputs=1):
462465

463466
if isinstance(sys, FRD):
464467
omega.sort()
465-
if (abs(omega - sys.omega) < FRD.epsw).all():
468+
if len(omega) == len(sys.omega) and \
469+
(abs(omega - sys.omega) < FRD.epsw).all():
466470
# frequencies match, and system was already frd; simply use
467471
return sys
468472

control/freqplot.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -603,18 +603,24 @@ def default_frequency_range(syslist, Hz=None, number_of_samples=None,
603603
----------
604604
syslist : list of LTI
605605
List of linear input/output systems (single system is OK)
606-
Hz: boolean
606+
607+
Hz : bool
607608
If True, the limits (first and last value) of the frequencies
608609
are set to full decades in Hz so it fits plotting with logarithmic
609610
scale in Hz otherwise in rad/s. Omega is always returned in rad/sec.
610-
number_of_samples: int
611-
Number of samples to generate
612-
feature_periphery_decade: float
611+
612+
number_of_samples : int, optional
613+
Number of samples to generate. Defaults to ``numpy.logspace`` default
614+
value.
615+
616+
feature_periphery_decade : float, optional
613617
Defines how many decades shall be included in the frequency range on
614618
both sides of features (poles, zeros).
615-
Example: If there is a feature, e.g. a pole, at 1Hz and feature_periphery_decade=1.
616-
then the range of frequencies shall span 0.1 .. 10 Hz.
617-
The default value is read from config.bode_feature_periphery_decade.
619+
620+
Example: If there is a feature, e.g. a pole, at 1 Hz and
621+
feature_periphery_decade=1., then the range of frequencies shall span
622+
0.1 .. 10 Hz. The default value is read from
623+
``config.bode_feature_periphery_decade``.
618624
619625
Returns
620626
-------
@@ -626,6 +632,7 @@ def default_frequency_range(syslist, Hz=None, number_of_samples=None,
626632
>>> from matlab import ss
627633
>>> sys = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.")
628634
>>> omega = default_frequency_range(sys)
635+
629636
"""
630637
# This code looks at the poles and zeros of all of the systems that
631638
# we are plotting and sets the frequency range to be one decade above

control/tests/bdalg_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,27 @@ def assert_equal(x,y):
253253
assert_equal(ref.C, tst.C)
254254
assert_equal(ref.D, tst.D)
255255

256+
def test_feedback_args(self):
257+
# Added 25 May 2019 to cover missing exception handling in feedback()
258+
# If first argument is not LTI or convertable, generate an exception
259+
args = ([1], self.sys2)
260+
self.assertRaises(TypeError, ctrl.feedback, *args)
261+
262+
# If second argument is not LTI or convertable, generate an exception
263+
args = (self.sys1, np.array([1]))
264+
self.assertRaises(TypeError, ctrl.feedback, *args)
265+
266+
# Convert first argument to FRD, if needed
267+
h = TransferFunction([1], [1, 2, 2])
268+
omega = np.logspace(-1, 2, 10)
269+
frd = ctrl.FRD(h, omega)
270+
sys = ctrl.feedback(1, frd)
271+
self.assertTrue(isinstance(sys, ctrl.FRD))
272+
256273

257274
def suite():
258275
return unittest.TestLoader().loadTestsFromTestCase(TestFeedback)
259276

277+
260278
if __name__ == "__main__":
261279
unittest.main()

control/tests/canonical_test.py

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import unittest
44
import numpy as np
5-
from control import ss
6-
from control.canonical import canonical_form
7-
5+
from control import ss, tf
6+
from control.canonical import canonical_form, reachable_form, \
7+
observable_form, modal_form
8+
from control.exception import ControlNotImplemented
89

910
class TestCanonical(unittest.TestCase):
1011
"""Tests for the canonical forms class"""
@@ -40,6 +41,11 @@ def test_reachable_form(self):
4041
np.testing.assert_array_almost_equal(sys_check.D, D_true)
4142
np.testing.assert_array_almost_equal(T_check, T_true)
4243

44+
# Reachable form only supports SISO
45+
sys = tf([[ [1], [1] ]], [[ [1, 2, 1], [1, 2, 1] ]])
46+
np.testing.assert_raises(ControlNotImplemented, reachable_form, sys)
47+
48+
4349
def test_unreachable_system(self):
4450
"""Test reachable canonical form with an unreachable system"""
4551

@@ -76,12 +82,84 @@ def test_modal_form(self):
7682
sys_check, T_check = canonical_form(ss(A, B, C, D), "modal")
7783

7884
# Check against the true values
79-
#TODO: Test in respect to ambiguous transformation (system characteristics?)
85+
# TODO: Test in respect to ambiguous transformation (system characteristics?)
8086
np.testing.assert_array_almost_equal(sys_check.A, A_true)
8187
#np.testing.assert_array_almost_equal(sys_check.B, B_true)
8288
#np.testing.assert_array_almost_equal(sys_check.C, C_true)
8389
np.testing.assert_array_almost_equal(sys_check.D, D_true)
8490
#np.testing.assert_array_almost_equal(T_check, T_true)
91+
92+
# Check conversion when there are complex eigenvalues
93+
A_true = np.array([[-1, 1, 0, 0],
94+
[-1, -1, 0, 0],
95+
[ 0, 0, -2, 0],
96+
[ 0, 0, 0, -3]])
97+
B_true = np.array([[0], [1], [0], [1]])
98+
C_true = np.array([[1, 0, 0, 1]])
99+
D_true = np.array([[0]])
100+
101+
A = np.linalg.solve(T_true, A_true) * T_true
102+
B = np.linalg.solve(T_true, B_true)
103+
C = C_true * T_true
104+
D = D_true
105+
106+
# Create state space system and convert to modal canonical form
107+
sys_check, T_check = canonical_form(ss(A, B, C, D), 'modal')
108+
109+
# Check A and D matrix, which are uniquely defined
110+
np.testing.assert_array_almost_equal(sys_check.A, A_true)
111+
np.testing.assert_array_almost_equal(sys_check.D, D_true)
112+
113+
# B matrix should be all ones (or zero if not controllable)
114+
# TODO: need to update modal_form() to implement this
115+
if np.allclose(T_check, T_true):
116+
np.testing.assert_array_almost_equal(sys_check.B, B_true)
117+
np.testing.assert_array_almost_equal(sys_check.C, C_true)
118+
119+
# Make sure Hankel coefficients are OK
120+
from numpy.linalg import matrix_power
121+
for i in range(A.shape[0]):
122+
np.testing.assert_almost_equal(
123+
np.dot(np.dot(C_true, matrix_power(A_true, i)), B_true),
124+
np.dot(np.dot(C, matrix_power(A, i)), B))
125+
126+
# Reorder rows to get complete coverage (real eigenvalue cxrtvfirst)
127+
A_true = np.array([[-1, 0, 0, 0],
128+
[ 0, -2, 1, 0],
129+
[ 0, -1, -2, 0],
130+
[ 0, 0, 0, -3]])
131+
B_true = np.array([[0], [0], [1], [1]])
132+
C_true = np.array([[0, 1, 0, 1]])
133+
D_true = np.array([[0]])
134+
135+
A = np.linalg.solve(T_true, A_true) * T_true
136+
B = np.linalg.solve(T_true, B_true)
137+
C = C_true * T_true
138+
D = D_true
139+
140+
# Create state space system and convert to modal canonical form
141+
sys_check, T_check = canonical_form(ss(A, B, C, D), 'modal')
142+
143+
# Check A and D matrix, which are uniquely defined
144+
np.testing.assert_array_almost_equal(sys_check.A, A_true)
145+
np.testing.assert_array_almost_equal(sys_check.D, D_true)
146+
147+
# B matrix should be all ones (or zero if not controllable)
148+
# TODO: need to update modal_form() to implement this
149+
if np.allclose(T_check, T_true):
150+
np.testing.assert_array_almost_equal(sys_check.B, B_true)
151+
np.testing.assert_array_almost_equal(sys_check.C, C_true)
152+
153+
# Make sure Hankel coefficients are OK
154+
from numpy.linalg import matrix_power
155+
for i in range(A.shape[0]):
156+
np.testing.assert_almost_equal(
157+
np.dot(np.dot(C_true, matrix_power(A_true, i)), B_true),
158+
np.dot(np.dot(C, matrix_power(A, i)), B))
159+
160+
# Modal form only supports SISO
161+
sys = tf([[ [1], [1] ]], [[ [1, 2, 1], [1, 2, 1] ]])
162+
np.testing.assert_raises(ControlNotImplemented, modal_form, sys)
85163

86164
def test_observable_form(self):
87165
"""Test the observable canonical form"""
@@ -114,6 +192,11 @@ def test_observable_form(self):
114192
np.testing.assert_array_almost_equal(sys_check.D, D_true)
115193
np.testing.assert_array_almost_equal(T_check, T_true)
116194

195+
# Observable form only supports SISO
196+
sys = tf([[ [1], [1] ]], [[ [1, 2, 1], [1, 2, 1] ]])
197+
np.testing.assert_raises(ControlNotImplemented, observable_form, sys)
198+
199+
117200
def test_unobservable_system(self):
118201
"""Test observable canonical form with an unobservable system"""
119202

@@ -126,3 +209,18 @@ def test_unobservable_system(self):
126209

127210
# Check if an exception is raised
128211
np.testing.assert_raises(ValueError, canonical_form, sys, "observable")
212+
213+
def test_arguments(self):
214+
# Additional unit tests added on 25 May 2019 to increase coverage
215+
216+
# Unknown canonical forms should generate exception
217+
sys = tf([1], [1, 2, 1])
218+
np.testing.assert_raises(
219+
ControlNotImplemented, canonical_form, sys, 'unknown')
220+
221+
def suite():
222+
return unittest.TestLoader().loadTestsFromTestCase(TestFeedback)
223+
224+
225+
if __name__ == "__main__":
226+
unittest.main()

0 commit comments

Comments
 (0)