Skip to content

Commit b9dc8df

Browse files
authored
Merge branch 'master' into rebase-pr431
2 parents 8b82850 + 6ed3f74 commit b9dc8df

File tree

5 files changed

+322
-31
lines changed

5 files changed

+322
-31
lines changed

control/statesp.py

+178-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@
7373
_statesp_defaults = {
7474
'statesp.use_numpy_matrix': False, # False is default in 0.9.0 and above
7575
'statesp.remove_useless_states': True,
76-
}
76+
'statesp.latex_num_format': '.3g',
77+
'statesp.latex_repr_type': 'partitioned',
78+
}
7779

7880

7981
def _ssmatrix(data, axis=1):
@@ -127,6 +129,33 @@ def _ssmatrix(data, axis=1):
127129
return arr.reshape(shape)
128130

129131

132+
def _f2s(f):
133+
"""Format floating point number f for StateSpace._repr_latex_.
134+
135+
Numbers are converted to strings with statesp.latex_num_format.
136+
137+
Inserts column separators, etc., as needed.
138+
"""
139+
fmt = "{:" + config.defaults['statesp.latex_num_format'] + "}"
140+
sraw = fmt.format(f)
141+
# significand-exponent
142+
se = sraw.lower().split('e')
143+
# whole-fraction
144+
wf = se[0].split('.')
145+
s = wf[0]
146+
if wf[1:]:
147+
s += r'.&\hspace{{-1em}}{frac}'.format(frac=wf[1])
148+
else:
149+
s += r'\phantom{.}&\hspace{-1em}'
150+
151+
if se[1:]:
152+
s += r'&\hspace{{-1em}}\cdot10^{{{:d}}}'.format(int(se[1]))
153+
else:
154+
s += r'&\hspace{-1em}\phantom{\cdot}'
155+
156+
return s
157+
158+
130159
class StateSpace(LTI):
131160
"""StateSpace(A, B, C, D[, dt])
132161
@@ -164,6 +193,24 @@ class StateSpace(LTI):
164193
timebase; the result will have the timebase of the latter system.
165194
The default value of dt can be changed by changing the value of
166195
``control.config.defaults['control.default_dt']``.
196+
197+
StateSpace instances have support for IPython LaTeX output,
198+
intended for pretty-printing in Jupyter notebooks. The LaTeX
199+
output can be configured using
200+
`control.config.defaults['statesp.latex_num_format']` and
201+
`control.config.defaults['statesp.latex_repr_type']`. The LaTeX output is
202+
tailored for MathJax, as used in Jupyter, and may look odd when
203+
typeset by non-MathJax LaTeX systems.
204+
205+
`control.config.defaults['statesp.latex_num_format']` is a format string
206+
fragment, specifically the part of the format string after `'{:'`
207+
used to convert floating-point numbers to strings. By default it
208+
is `'.3g'`.
209+
210+
`control.config.defaults['statesp.latex_repr_type']` must either be
211+
`'partitioned'` or `'separate'`. If `'partitioned'`, the A, B, C, D
212+
matrices are shown as a single, partitioned matrix; if
213+
`'separate'`, the matrices are shown separately.
167214
"""
168215

169216
# Allow ndarray * StateSpace to give StateSpace._rmul_() priority
@@ -329,6 +376,136 @@ def __repr__(self):
329376
C=asarray(self.C).__repr__(), D=asarray(self.D).__repr__(),
330377
dt=(isdtime(self, strict=True) and ", {}".format(self.dt)) or '')
331378

379+
def _latex_partitioned_stateless(self):
380+
"""`Partitioned` matrix LaTeX representation for stateless systems
381+
382+
Model is presented as a matrix, D. No partition lines are shown.
383+
384+
Returns
385+
-------
386+
s : string with LaTeX representation of model
387+
"""
388+
lines = [
389+
r'\[',
390+
r'\left(',
391+
(r'\begin{array}'
392+
+ r'{' + 'rll' * self.inputs + '}')
393+
]
394+
395+
for Di in asarray(self.D):
396+
lines.append('&'.join(_f2s(Dij) for Dij in Di)
397+
+ '\\\\')
398+
399+
lines.extend([
400+
r'\end{array}'
401+
r'\right)',
402+
r'\]'])
403+
404+
return '\n'.join(lines)
405+
406+
def _latex_partitioned(self):
407+
"""Partitioned matrix LaTeX representation of state-space model
408+
409+
Model is presented as a matrix partitioned into A, B, C, and D
410+
parts.
411+
412+
Returns
413+
-------
414+
s : string with LaTeX representation of model
415+
"""
416+
if self.states == 0:
417+
return self._latex_partitioned_stateless()
418+
419+
lines = [
420+
r'\[',
421+
r'\left(',
422+
(r'\begin{array}'
423+
+ r'{' + 'rll' * self.states + '|' + 'rll' * self.inputs + '}')
424+
]
425+
426+
for Ai, Bi in zip(asarray(self.A), asarray(self.B)):
427+
lines.append('&'.join([_f2s(Aij) for Aij in Ai]
428+
+ [_f2s(Bij) for Bij in Bi])
429+
+ '\\\\')
430+
lines.append(r'\hline')
431+
for Ci, Di in zip(asarray(self.C), asarray(self.D)):
432+
lines.append('&'.join([_f2s(Cij) for Cij in Ci]
433+
+ [_f2s(Dij) for Dij in Di])
434+
+ '\\\\')
435+
436+
lines.extend([
437+
r'\end{array}'
438+
r'\right)',
439+
r'\]'])
440+
441+
return '\n'.join(lines)
442+
443+
def _latex_separate(self):
444+
"""Separate matrices LaTeX representation of state-space model
445+
446+
Model is presented as separate, named, A, B, C, and D matrices.
447+
448+
Returns
449+
-------
450+
s : string with LaTeX representation of model
451+
"""
452+
lines = [
453+
r'\[',
454+
r'\begin{array}{ll}',
455+
]
456+
457+
def fmt_matrix(matrix, name):
458+
matlines = [name
459+
+ r' = \left(\begin{array}{'
460+
+ 'rll' * matrix.shape[1]
461+
+ '}']
462+
for row in asarray(matrix):
463+
matlines.append('&'.join(_f2s(entry) for entry in row)
464+
+ '\\\\')
465+
matlines.extend([
466+
r'\end{array}'
467+
r'\right)'])
468+
return matlines
469+
470+
if self.states > 0:
471+
lines.extend(fmt_matrix(self.A, 'A'))
472+
lines.append('&')
473+
lines.extend(fmt_matrix(self.B, 'B'))
474+
lines.append('\\\\')
475+
476+
lines.extend(fmt_matrix(self.C, 'C'))
477+
lines.append('&')
478+
lines.extend(fmt_matrix(self.D, 'D'))
479+
480+
lines.extend([
481+
r'\end{array}',
482+
r'\]'])
483+
484+
return '\n'.join(lines)
485+
486+
def _repr_latex_(self):
487+
"""LaTeX representation of state-space model
488+
489+
Output is controlled by config options statesp.latex_repr_type
490+
and statesp.latex_num_format.
491+
492+
The output is primarily intended for Jupyter notebooks, which
493+
use MathJax to render the LaTeX, and the results may look odd
494+
when processed by a 'conventional' LaTeX system.
495+
496+
Returns
497+
-------
498+
s : string with LaTeX representation of model
499+
500+
"""
501+
if config.defaults['statesp.latex_repr_type'] == 'partitioned':
502+
return self._latex_partitioned()
503+
elif config.defaults['statesp.latex_repr_type'] == 'separate':
504+
return self._latex_separate()
505+
else:
506+
cfg = config.defaults['statesp.latex_repr_type']
507+
raise ValueError("Unknown statesp.latex_repr_type '{cfg}'".format(cfg=cfg))
508+
332509
# Negation of a system
333510
def __neg__(self):
334511
"""Negate a state space system."""

control/tests/config_test.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_get_param(self):
3737
assert ct.config._get_param('config', 'test1', None) == 1
3838
assert ct.config._get_param('config', 'test1', None, 1) == 1
3939

40-
ct.config.defaults['config.test3'] is None
40+
ct.config.defaults['config.test3'] = None
4141
assert ct.config._get_param('config', 'test3') is None
4242
assert ct.config._get_param('config', 'test3', 1) == 1
4343
assert ct.config._get_param('config', 'test3', None, 1) is None

control/tests/conftest.py

+18-3
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,22 @@
2828
"PendingDeprecationWarning")
2929

3030

31-
@pytest.fixture(scope="session", autouse=TEST_MATRIX_AND_ARRAY,
31+
@pytest.fixture(scope="session", autouse=True)
32+
def control_defaults():
33+
"""Make sure the testing session always starts with the defaults.
34+
35+
This should be the first fixture initialized,
36+
so that all other fixtures see the general defaults (unless they set them
37+
themselves) even before importing control/__init__. Enforce this by adding
38+
it as an argument to all other session scoped fixtures.
39+
"""
40+
control.reset_defaults()
41+
the_defaults = control.config.defaults.copy()
42+
yield
43+
# assert that nothing changed it without reverting
44+
assert control.config.defaults == the_defaults
45+
46+
@pytest.fixture(scope="function", autouse=TEST_MATRIX_AND_ARRAY,
3247
params=[pytest.param("arrayout", marks=matrixerrorfilter),
3348
pytest.param("matrixout", marks=matrixfilter)])
3449
def matarrayout(request):
@@ -70,7 +85,7 @@ def check_deprecated_matrix():
7085
yield
7186

7287

73-
@pytest.fixture(scope="session",
88+
@pytest.fixture(scope="function",
7489
params=[p for p, usebydefault in
7590
[(pytest.param(np.array,
7691
id="arrayin"),
@@ -90,7 +105,7 @@ def editsdefaults():
90105
"""Make sure any changes to the defaults only last during a test"""
91106
restore = control.config.defaults.copy()
92107
yield
93-
control.config.defaults.update(restore)
108+
control.config.defaults = restore.copy()
94109

95110

96111
@pytest.fixture(scope="function")

control/tests/freqresp_test.py

+48-25
Original file line numberDiff line numberDiff line change
@@ -120,39 +120,62 @@ def test_mimo():
120120
tf(sysMIMO)
121121

122122

123-
def test_bode_margin():
123+
@pytest.mark.parametrize(
124+
"Hz, Wcp, Wcg",
125+
[pytest.param(False, 6.0782869, 10., id="omega"),
126+
pytest.param(True, 0.9673894, 1.591549, id="Hz")])
127+
@pytest.mark.parametrize(
128+
"deg, p0, pm",
129+
[pytest.param(False, -np.pi, -2.748266, id="rad"),
130+
pytest.param(True, -180, -157.46405841, id="deg")])
131+
@pytest.mark.parametrize(
132+
"dB, maginfty1, maginfty2, gminv",
133+
[pytest.param(False, 1, 1e-8, 0.4, id="mag"),
134+
pytest.param(True, 0, -1e+5, -7.9588, id="dB")])
135+
def test_bode_margin(dB, maginfty1, maginfty2, gminv,
136+
deg, p0, pm,
137+
Hz, Wcp, Wcg):
124138
"""Test bode margins"""
125139
num = [1000]
126140
den = [1, 25, 100, 0]
127141
sys = ctrl.tf(num, den)
128142
plt.figure()
129-
ctrl.bode_plot(sys, margins=True, dB=False, deg=True, Hz=False)
143+
ctrl.bode_plot(sys, margins=True, dB=dB, deg=deg, Hz=Hz)
130144
fig = plt.gcf()
131145
allaxes = fig.get_axes()
132146

133-
mag_to_infinity = (np.array([6.07828691, 6.07828691]),
134-
np.array([1., 1e-8]))
135-
assert_allclose(mag_to_infinity, allaxes[0].lines[2].get_data())
136-
137-
gm_to_infinty = (np.array([10., 10.]),
138-
np.array([4e-1, 1e-8]))
139-
assert_allclose(gm_to_infinty, allaxes[0].lines[3].get_data())
140-
141-
one_to_gm = (np.array([10., 10.]),
142-
np.array([1., 0.4]))
143-
assert_allclose(one_to_gm, allaxes[0].lines[4].get_data())
144-
145-
pm_to_infinity = (np.array([6.07828691, 6.07828691]),
146-
np.array([100000., -157.46405841]))
147-
assert_allclose(pm_to_infinity, allaxes[1].lines[2].get_data())
148-
149-
pm_to_phase = (np.array([6.07828691, 6.07828691]),
150-
np.array([-157.46405841, -180.]))
151-
assert_allclose(pm_to_phase, allaxes[1].lines[3].get_data())
152-
153-
phase_to_infinity = (np.array([10., 10.]),
154-
np.array([1e-8, -1.8e2]))
155-
assert_allclose(phase_to_infinity, allaxes[1].lines[4].get_data())
147+
mag_to_infinity = (np.array([Wcp, Wcp]),
148+
np.array([maginfty1, maginfty2]))
149+
assert_allclose(mag_to_infinity,
150+
allaxes[0].lines[2].get_data(),
151+
rtol=1e-5)
152+
153+
gm_to_infinty = (np.array([Wcg, Wcg]),
154+
np.array([gminv, maginfty2]))
155+
assert_allclose(gm_to_infinty,
156+
allaxes[0].lines[3].get_data(),
157+
rtol=1e-5)
158+
159+
one_to_gm = (np.array([Wcg, Wcg]),
160+
np.array([maginfty1, gminv]))
161+
assert_allclose(one_to_gm, allaxes[0].lines[4].get_data(),
162+
rtol=1e-5)
163+
164+
pm_to_infinity = (np.array([Wcp, Wcp]),
165+
np.array([1e5, pm]))
166+
assert_allclose(pm_to_infinity,
167+
allaxes[1].lines[2].get_data(),
168+
rtol=1e-5)
169+
170+
pm_to_phase = (np.array([Wcp, Wcp]),
171+
np.array([pm, p0]))
172+
assert_allclose(pm_to_phase, allaxes[1].lines[3].get_data(),
173+
rtol=1e-5)
174+
175+
phase_to_infinity = (np.array([Wcg, Wcg]),
176+
np.array([1e-8, p0]))
177+
assert_allclose(phase_to_infinity, allaxes[1].lines[4].get_data(),
178+
rtol=1e-5)
156179

157180

158181
@pytest.fixture

0 commit comments

Comments
 (0)