Skip to content

Commit 9143d75

Browse files
authored
Merge branch 'master' into singular-values-plot
2 parents a486c18 + e81f648 commit 9143d75

12 files changed

Lines changed: 551 additions & 429 deletions

File tree

README.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,7 @@ The package requires numpy, scipy, and matplotlib. In addition, some routines
5151
use a module called slycot, that is a Python wrapper around some FORTRAN
5252
routines. Many parts of python-control will work without slycot, but some
5353
functionality is limited or absent, and installation of slycot is recommended
54-
(see below). Note that in order to install slycot, you will need a FORTRAN
55-
compiler on your machine. The Slycot wrapper can be found at:
54+
(see below). The Slycot wrapper can be found at:
5655

5756
https://github.com/python-control/Slycot
5857

control/lti.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ def _process_frequency_response(sys, omega, out, squeeze=None):
665665
if squeeze is None:
666666
squeeze = config.defaults['control.squeeze_frequency_response']
667667

668-
if not hasattr(omega, '__len__'):
668+
if np.asarray(omega).ndim < 1:
669669
# received a scalar x, squeeze down the array along last dim
670670
out = np.squeeze(out, axis=2)
671671

control/rlocus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None,
180180
fig.axes[1].plot(
181181
[root.real for root in start_mat],
182182
[root.imag for root in start_mat],
183-
'm.', marker='s', markersize=8, zorder=20, label='gain_point')
183+
marker='s', markersize=8, zorder=20, label='gain_point')
184184
s = start_mat[0][0]
185185
if isdtime(sys, strict=True):
186186
zeta = -np.cos(np.angle(np.log(s)))
@@ -628,7 +628,7 @@ def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False):
628628
ax_rlocus.plot(
629629
[root.real for root in mymat],
630630
[root.imag for root in mymat],
631-
'm.', marker='s', markersize=8, zorder=20, label='gain_point')
631+
marker='s', markersize=8, zorder=20, label='gain_point')
632632
else:
633633
ax_rlocus.plot(s.real, s.imag, 'k.', marker='s', markersize=8,
634634
zorder=20, label='gain_point')

control/sisotool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None,
8181

8282
# Setup sisotool figure or superimpose if one is already present
8383
fig = plt.gcf()
84-
if fig.canvas.get_window_title() != 'Sisotool':
84+
if fig.canvas.manager.get_window_title() != 'Sisotool':
8585
plt.close(fig)
8686
fig,axes = plt.subplots(2, 2)
87-
fig.canvas.set_window_title('Sisotool')
87+
fig.canvas.manager.set_window_title('Sisotool')
8888

8989
# Extract bode plot parameters
9090
bode_plot_params = {

control/statesp.py

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
'statesp.remove_useless_states': False,
7676
'statesp.latex_num_format': '.3g',
7777
'statesp.latex_repr_type': 'partitioned',
78+
'statesp.latex_maxsize': 10,
7879
}
7980

8081

@@ -391,11 +392,8 @@ def __str__(self):
391392
"\n ".join(str(M).splitlines()))
392393
for Mvar, M in zip(["A", "B", "C", "D"],
393394
[self.A, self.B, self.C, self.D])])
394-
# TODO: replace with standard calls to lti functions
395-
if (type(self.dt) == bool and self.dt is True):
396-
string += "\ndt unspecified\n"
397-
elif (not (self.dt is None) and type(self.dt) != bool and self.dt > 0):
398-
string += "\ndt = " + self.dt.__str__() + "\n"
395+
if self.isdtime(strict=True):
396+
string += f"\ndt = {self.dt}\n"
399397
return string
400398

401399
# represent to implement a re-loadable version
@@ -418,8 +416,8 @@ def _latex_partitioned_stateless(self):
418416
"""
419417
lines = [
420418
r'\[',
421-
r'\left(',
422-
(r'\begin{array}'
419+
(r'\left('
420+
+ r'\begin{array}'
423421
+ r'{' + 'rll' * self.ninputs + '}')
424422
]
425423

@@ -429,7 +427,8 @@ def _latex_partitioned_stateless(self):
429427

430428
lines.extend([
431429
r'\end{array}'
432-
r'\right)',
430+
r'\right)'
431+
+ self._latex_dt(),
433432
r'\]'])
434433

435434
return '\n'.join(lines)
@@ -449,8 +448,8 @@ def _latex_partitioned(self):
449448

450449
lines = [
451450
r'\[',
452-
r'\left(',
453-
(r'\begin{array}'
451+
(r'\left('
452+
+ r'\begin{array}'
454453
+ r'{' + 'rll' * self.nstates + '|' + 'rll' * self.ninputs + '}')
455454
]
456455

@@ -466,7 +465,8 @@ def _latex_partitioned(self):
466465

467466
lines.extend([
468467
r'\end{array}'
469-
r'\right)',
468+
+ r'\right)'
469+
+ self._latex_dt(),
470470
r'\]'])
471471

472472
return '\n'.join(lines)
@@ -509,34 +509,51 @@ def fmt_matrix(matrix, name):
509509
lines.extend(fmt_matrix(self.D, 'D'))
510510

511511
lines.extend([
512-
r'\end{array}',
512+
r'\end{array}'
513+
+ self._latex_dt(),
513514
r'\]'])
514515

515516
return '\n'.join(lines)
516517

518+
def _latex_dt(self):
519+
if self.isdtime(strict=True):
520+
if self.dt is True:
521+
return r"~,~dt=~\mathrm{True}"
522+
else:
523+
fmt = config.defaults['statesp.latex_num_format']
524+
return f"~,~dt={self.dt:{fmt}}"
525+
return ""
526+
517527
def _repr_latex_(self):
518528
"""LaTeX representation of state-space model
519529
520-
Output is controlled by config options statesp.latex_repr_type
521-
and statesp.latex_num_format.
530+
Output is controlled by config options statesp.latex_repr_type,
531+
statesp.latex_num_format, and statesp.latex_maxsize.
522532
523533
The output is primarily intended for Jupyter notebooks, which
524534
use MathJax to render the LaTeX, and the results may look odd
525535
when processed by a 'conventional' LaTeX system.
526536
537+
527538
Returns
528539
-------
529-
s : string with LaTeX representation of model
540+
541+
s : string with LaTeX representation of model, or None if
542+
either matrix dimension is greater than
543+
statesp.latex_maxsize
530544
531545
"""
532-
if config.defaults['statesp.latex_repr_type'] == 'partitioned':
546+
syssize = self.nstates + max(self.noutputs, self.ninputs)
547+
if syssize > config.defaults['statesp.latex_maxsize']:
548+
return None
549+
elif config.defaults['statesp.latex_repr_type'] == 'partitioned':
533550
return self._latex_partitioned()
534551
elif config.defaults['statesp.latex_repr_type'] == 'separate':
535552
return self._latex_separate()
536553
else:
537-
cfg = config.defaults['statesp.latex_repr_type']
538554
raise ValueError(
539-
"Unknown statesp.latex_repr_type '{cfg}'".format(cfg=cfg))
555+
"Unknown statesp.latex_repr_type '{cfg}'".format(
556+
cfg=config.defaults['statesp.latex_repr_type']))
540557

541558
# Negation of a system
542559
def __neg__(self):

control/tests/lti_test.py

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
import control as ct
88
from control import c2d, tf, tf2ss, NonlinearIOSystem
9-
from control.lti import (LTI, common_timebase, damp, dcgain, isctime, isdtime,
10-
issiso, pole, timebaseEqual, zero)
9+
from control.lti import (LTI, common_timebase, evalfr, damp, dcgain, isctime,
10+
isdtime, issiso, pole, timebaseEqual, zero)
1111
from control.tests.conftest import slycotonly
1212
from control.exception import slycot_check
1313

@@ -179,11 +179,20 @@ def test_isdtime(self, objfun, arg, dt, ref, strictref):
179179
[1, 1, 2, [0.1, 1, 10], None, (1, 2, 3)], # MISO
180180
[2, 1, 2, [0.1, 1, 10], True, (2, 3)],
181181
[3, 1, 2, [0.1, 1, 10], False, (1, 2, 3)],
182+
[1, 1, 2, 0.1, None, (1, 2)],
183+
[1, 1, 2, 0.1, True, (2,)],
184+
[1, 1, 2, 0.1, False, (1, 2)],
182185
[1, 2, 2, [0.1, 1, 10], None, (2, 2, 3)], # MIMO
183186
[2, 2, 2, [0.1, 1, 10], True, (2, 2, 3)],
184-
[3, 2, 2, [0.1, 1, 10], False, (2, 2, 3)]
187+
[3, 2, 2, [0.1, 1, 10], False, (2, 2, 3)],
188+
[1, 2, 2, 0.1, None, (2, 2)],
189+
[2, 2, 2, 0.1, True, (2, 2)],
190+
[3, 2, 2, 0.1, False, (2, 2)],
185191
])
186-
def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape):
192+
@pytest.mark.parametrize("omega_type", ["numpy", "native"])
193+
def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape,
194+
omega_type):
195+
"""Test correct behavior of frequencey response squeeze parameter."""
187196
# Create the system to be tested
188197
if fcn == ct.frd:
189198
sys = fcn(ct.rss(nstate, nout, ninp), [1e-2, 1e-1, 1, 1e1, 1e2])
@@ -193,15 +202,23 @@ def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape):
193202
else:
194203
sys = fcn(ct.rss(nstate, nout, ninp))
195204

196-
# Convert the frequency list to an array for easy of use
197-
isscalar = not hasattr(omega, '__len__')
198-
omega = np.array(omega)
205+
if omega_type == "numpy":
206+
omega = np.asarray(omega)
207+
isscalar = omega.ndim == 0
208+
# keep the ndarray type even for scalars
209+
s = np.asarray(omega * 1j)
210+
else:
211+
isscalar = not hasattr(omega, '__len__')
212+
if isscalar:
213+
s = omega*1J
214+
else:
215+
s = [w*1J for w in omega]
199216

200217
# Call the transfer function directly and make sure shape is correct
201-
assert sys(omega * 1j, squeeze=squeeze).shape == shape
218+
assert sys(s, squeeze=squeeze).shape == shape
202219

203220
# Make sure that evalfr also works as expected
204-
assert ct.evalfr(sys, omega * 1j, squeeze=squeeze).shape == shape
221+
assert ct.evalfr(sys, s, squeeze=squeeze).shape == shape
205222

206223
# Check frequency response
207224
mag, phase, _ = sys.frequency_response(omega, squeeze=squeeze)
@@ -216,22 +233,22 @@ def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape):
216233

217234
# Make sure the default shape lines up with squeeze=None case
218235
if squeeze is None:
219-
assert sys(omega * 1j).shape == shape
236+
assert sys(s).shape == shape
220237

221238
# Changing config.default to False should return 3D frequency response
222239
ct.config.set_defaults('control', squeeze_frequency_response=False)
223240
mag, phase, _ = sys.frequency_response(omega)
224241
if isscalar:
225242
assert mag.shape == (sys.noutputs, sys.ninputs, 1)
226243
assert phase.shape == (sys.noutputs, sys.ninputs, 1)
227-
assert sys(omega * 1j).shape == (sys.noutputs, sys.ninputs)
228-
assert ct.evalfr(sys, omega * 1j).shape == (sys.noutputs, sys.ninputs)
244+
assert sys(s).shape == (sys.noutputs, sys.ninputs)
245+
assert ct.evalfr(sys, s).shape == (sys.noutputs, sys.ninputs)
229246
else:
230247
assert mag.shape == (sys.noutputs, sys.ninputs, len(omega))
231248
assert phase.shape == (sys.noutputs, sys.ninputs, len(omega))
232-
assert sys(omega * 1j).shape == \
249+
assert sys(s).shape == \
233250
(sys.noutputs, sys.ninputs, len(omega))
234-
assert ct.evalfr(sys, omega * 1j).shape == \
251+
assert ct.evalfr(sys, s).shape == \
235252
(sys.noutputs, sys.ninputs, len(omega))
236253

237254
@pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.ss2io])
@@ -243,13 +260,17 @@ def test_squeeze_exceptions(self, fcn):
243260

244261
with pytest.raises(ValueError, match="unknown squeeze value"):
245262
sys.frequency_response([1], squeeze=1)
246-
sys([1], squeeze='siso')
247-
evalfr(sys, [1], squeeze='siso')
263+
with pytest.raises(ValueError, match="unknown squeeze value"):
264+
sys([1j], squeeze='siso')
265+
with pytest.raises(ValueError, match="unknown squeeze value"):
266+
evalfr(sys, [1j], squeeze='siso')
248267

249268
with pytest.raises(ValueError, match="must be 1D"):
250269
sys.frequency_response([[0.1, 1], [1, 10]])
251-
sys([[0.1, 1], [1, 10]])
252-
evalfr(sys, [[0.1, 1], [1, 10]])
270+
with pytest.raises(ValueError, match="must be 1D"):
271+
sys([[0.1j, 1j], [1j, 10j]])
272+
with pytest.raises(ValueError, match="must be 1D"):
273+
evalfr(sys, [[0.1j, 1j], [1j, 10j]])
253274

254275
with pytest.warns(DeprecationWarning, match="LTI `inputs`"):
255276
ninputs = sys.inputs

control/tests/modelsimp_test.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def testMarkovResults(self, k, m, n):
7979
# m = number of Markov parameters
8080
# n = size of the data vector
8181
#
82-
# Values should match exactly for n = m, otherewise you get a
82+
# Values *should* match exactly for n = m, otherewise you get a
8383
# close match but errors due to the assumption that C A^k B =
8484
# 0 for k > m-2 (see modelsimp.py).
8585
#
@@ -106,7 +106,10 @@ def testMarkovResults(self, k, m, n):
106106
Mcomp = markov(Y, U, m)
107107

108108
# Compare to results from markov()
109-
np.testing.assert_array_almost_equal(Mtrue, Mcomp)
109+
# experimentally determined probability to get non matching results
110+
# with rtot=1e-6 and atol=1e-8 due to numerical errors
111+
# for k=5, m=n=10: 0.015 %
112+
np.testing.assert_allclose(Mtrue, Mcomp, rtol=1e-6, atol=1e-8)
110113

111114
def testModredMatchDC(self, matarrayin):
112115
#balanced realization computed in matlab for the transfer function:
@@ -217,4 +220,3 @@ def testBalredMatchDC(self, matarrayin):
217220
np.testing.assert_array_almost_equal(rsys.B, Brtrue, decimal=4)
218221
np.testing.assert_array_almost_equal(rsys.C, Crtrue, decimal=4)
219222
np.testing.assert_array_almost_equal(rsys.D, Drtrue, decimal=4)
220-

0 commit comments

Comments
 (0)