8000 Merge pull request #450 from roryyorke/rory/ss-repr-latex · python-control/python-control@6ed3f74 · GitHub
[go: up one dir, main page]

Skip to content
8000

Commit 6ed3f74

Browse files
authored
Merge pull request #450 from roryyorke/rory/ss-repr-latex
Added IPython LaTeX representation method for StateSpace objects
2 parents a382502 + 91b04de commit 6ed3f74

File tree

2 files changed

+239
-0
lines changed

2 files changed

+239
-0
lines changed

control/statesp.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@
7474
'statesp.use_numpy_matrix': False, # False is default in 0.9.0 and above
7575
'statesp.default_dt': None,
7676
'statesp.remove_useless_states': True,
77+
'statesp.latex_num_format': '.3g',
78+
'statesp.latex_repr_type': 'partitioned',
7779
}
7880

7981

@@ -128,6 +130,33 @@ def _ssmatrix(data, axis=1):
128130
return arr.reshape(shape)
129131

130132

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

163210
# Allow ndarray * StateSpace to give StateSpace._rmul_() priority
@@ -306,6 +353,136 @@ def __repr__(self):
306353
C=asarray(self.C).__repr__(), D=asarray(self.D).__repr__(),
307354
dt=(isdtime(self, strict=True) and ", {}".format(self.dt)) or '')
308355

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

control/tests/statesp_test.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from control.tests.conftest import ismatarrayout, slycotonly
2121
from control.xferfcn import TransferFunction, ss2tf
2222

23+
from .conftest import editsdefaults
2324

2425
class TestStateSpace:
2526
"""Tests for the StateSpace class."""
@@ -840,3 +841,64 @@ def test_statespace_defaults(self, matarrayout):
840841
for k, v in _statesp_defaults.items():
841842
assert defaults[k] == v, \
842843
"{} is {} but expected {}".format(k, defaults[k], v)
844+
845+
846+
# test data for test_latex_repr below
847+
LTX_G1 = StateSpace([[np.pi, 1e100], [-1.23456789, 5e-23]],
848+
[[0], [1]],
849+
[[987654321, 0.001234]],
850+
[[5]])
851+
852+
LTX_G2 = StateSpace([],
853+
[],
854+
[],
855+
[[1.2345, -2e-200], [-1, 0]])
856+
857+
LTX_G1_REF = {
858+
'p3_p' : '\\[\n\\left(\n\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]',
859+
860+
'p5_p' : '\\[\n\\left(\n\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]',
861+
862+
'p3_s' : '\\[\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]',
863+
864+
'p5_s' : '\\[\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]',
865+
}
866+
867+
LTX_G2_REF = {
868+
'p3_p' : '\\[\n\\left(\n\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]',
869+
870+
'p5_p' : '\\[\n\\left(\n\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]',
871+
872+
'p3_s' : '\\[\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]',
873+
874+
'p5_s' : '\\[\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]',
875+
}
876+
877+
refkey_n = {None: 'p3', '.3g': 'p3', '.5g': 'p5'}
878+
refkey_r = {None: 'p', 'partitioned': 'p', 'separate': 's'}
879+
880+
881+
@pytest.mark.parametrize(" g, ref",
882+
[(LTX_G1, LTX_G1_REF),
883+
(LTX_G2, LTX_G2_REF)])
884+
@pytest.mark.parametrize("repr_type", [None, "partitioned", "separate"])
885+
@pytest.mark.parametrize("num_format", [None, ".3g", ".5g"])
886+
def test_latex_repr(g, ref, repr_type, num_format, editsdefaults):
887+
"""Test `._latex_repr_` with different config values
888+
889+
This is a 'gold image' test, so if you change behaviour,
890+
you'll need to regenerate the reference results.
891+
Try something like:
892+
control.reset_defaults()
893+
print(f'p3_p : {g1._repr_latex_()!r}')
894+
"""
895+
from control import set_defaults
896+
if num_format is not None:
897+
set_defaults('statesp', latex_num_format=num_format)
898+
899+
if repr_type is not None:
900+
set_defaults('statesp', latex_repr_type=repr_type)
901+
902+
refkey = "{}_{}".format(refkey_n[num_format], refkey_r[repr_type])
903+
assert g._repr_latex_() == ref[refkey]
904+

0 commit comments

Comments
 (0)
0