Skip to content

Commit 96cd45a

Browse files
committed
Added IPython LaTeX representation method for StateSpace objects
Added StateSpace method `_repr_latex_`, which returns a MathJax-centric LaTeX representation of the instance. Added two `statesp` configuration options for this representation. One affects number formatting, and the other chooses the output type.
1 parent d3142ff commit 96cd45a

File tree

2 files changed

+241
-0
lines changed

2 files changed

+241
-0
lines changed

control/statesp.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@
7373
'statesp.use_numpy_matrix': True,
7474
'statesp.default_dt': None,
7575
'statesp.remove_useless_states': True,
76+
'statesp.latex_num_format': '.3g',
77+
'statesp.latex_repr_type': 'partitioned',
7678
}
7779

7880

@@ -122,6 +124,34 @@ def _ssmatrix(data, axis=1):
122124
return arr.reshape(shape)
123125

124126

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

157205
# Allow ndarray * StateSpace to give StateSpace._rmul_() priority
@@ -292,6 +340,136 @@ def __repr__(self):
292340
C=asarray(self.C).__repr__(), D=asarray(self.D).__repr__(),
293341
dt=(isdtime(self, strict=True) and ", {}".format(self.dt)) or '')
294342

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

control/tests/statesp_test.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,69 @@ def test_str(self):
561561
assert str(sysdt1) == tref + "\ndt = 1.0\n"
562562

563563

564+
def test_latex_repr(self):
565+
# Test '_latex_repr_' with different config values
566+
# This is a 'gold image' test, so if you change behaviour,
567+
# you'll need to regenerate the reference results.
568+
# Try something like:
569+
# control.reset_defaults()
570+
# print(f'g1_p3_p = {g1._repr_latex_()!r}')
571+
# control.set_defaults('statesp', latex_num_format='.5g')
572+
# print(f'g1_p5_p = {g1._repr_latex_()!r}')
573+
# control.reset_defaults()
574+
# control.set_defaults('statesp', latex_repr_type='separate')
575+
# print(f'g1_p3_s = {g1._repr_latex_()!r}')
576+
# control.set_defaults('statesp', latex_num_format='.5g')
577+
# print(f'g1_p5_s = {g1._repr_latex_()!r}')
578+
from control import set_defaults, reset_defaults
579+
g1 = StateSpace([[np.pi, 1e100],[-1.23456789, 5e-23]],
580+
[[0], [1]],
581+
[[987654321, 0.001234]],
582+
[[5]])
583+
g2 = StateSpace([],
584+
[],
585+
[],
586+
[[1.2345,-2e-200],[-1,0]])
587+
588+
g1_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\\]'
589+
g1_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\\]'
590+
g1_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\\]'
591+
g1_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\\]'
592+
593+
g2_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\\]'
594+
g2_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\\]'
595+
g2_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\\]'
596+
g2_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\\]'
597+
598+
599+
try:
600+
reset_defaults()
601+
self.assertEqual(g1_p3_p, g1._repr_latex_())
602+
set_defaults('statesp', latex_num_format='.5g')
603+
self.assertEqual(g1_p5_p, g1._repr_latex_())
604+
605+
reset_defaults()
606+
set_defaults('statesp', latex_repr_type='separate')
607+
self.assertEqual(g1_p3_s, g1._repr_latex_())
608+
set_defaults('statesp', latex_num_format='.5g')
609+
self.assertEqual(g1_p5_s, g1._repr_latex_())
610+
611+
reset_defaults()
612+
self.assertEqual(g2_p3_p, g2._repr_latex_())
613+
set_defaults('statesp', latex_num_format='.5g')
614+
self.assertEqual(g2_p5_p, g2._repr_latex_())
615+
616+
reset_defaults()
617+
set_defaults('statesp', latex_repr_type='separate')
618+
self.assertEqual(g2_p3_s, g2._repr_latex_())
619+
set_defaults('statesp', latex_num_format='.5g')
620+
self.assertEqual(g2_p5_s, g2._repr_latex_())
621+
622+
finally:
623+
reset_defaults()
624+
625+
626+
564627
class TestRss(unittest.TestCase):
565628
"""These are tests for the proper functionality of statesp.rss."""
566629

0 commit comments

Comments
 (0)