Skip to content

Commit 581d920

Browse files
committed
fix missing plot title in bode_plot() with display_margins
1 parent 7270f63 commit 581d920

File tree

2 files changed

+86
-19
lines changed

2 files changed

+86
-19
lines changed

control/freqplot.py

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,8 @@ def bode_plot(
135135
If True, draw gain and phase margin lines on the magnitude and phase
136136
graphs and display the margins at the top of the graph. If set to
137137
'overlay', the values for the gain and phase margin are placed on
138-
the graph. Setting display_margins turns off the axes grid.
138+
the graph. Setting `display_margins` turns off the axes grid, unless
139+
`grid` is explicitly set to True.
139140
**kwargs : `matplotlib.pyplot.plot` keyword properties, optional
140141
Additional keywords passed to `matplotlib` to specify line properties.
141142
@@ -277,6 +278,24 @@ def bode_plot(
277278
# Make a copy of the kwargs dictionary since we will modify it
278279
kwargs = dict(kwargs)
279280

281+
# Legacy keywords for margins
282+
display_margins = config._process_legacy_keyword(
283+
kwargs, 'margins', 'display_margins', display_margins)
284+
if kwargs.pop('margin_info', False):
285+
warnings.warn(
286+
"keyword 'margin_info' is deprecated; "
287+
"use 'display_margins='overlay'")
288+
if display_margins is False:
289+
raise ValueError(
290+
"conflicting_keywords: `display_margins` and `margin_info`")
291+
292+
# Turn off grid if display margins, unless explicitly overridden
293+
if display_margins and 'grid' not in kwargs:
294+
kwargs['grid'] = False
295+
296+
margins_method = config._process_legacy_keyword(
297+
kwargs, 'method', 'margins_method', margins_method)
298+
280299
# Get values for params (and pop from list to allow keyword use in plot)
281300
dB = config._get_param(
282301
'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True)
@@ -317,19 +336,6 @@ def bode_plot(
317336
"sharex cannot be present with share_frequency")
318337
kwargs['share_frequency'] = sharex
319338

320-
# Legacy keywords for margins
321-
display_margins = config._process_legacy_keyword(
322-
kwargs, 'margins', 'display_margins', display_margins)
323-
if kwargs.pop('margin_info', False):
324-
warnings.warn(
325-
"keyword 'margin_info' is deprecated; "
326-
"use 'display_margins='overlay'")
327-
if display_margins is False:
328-
raise ValueError(
329-
"conflicting_keywords: `display_margins` and `margin_info`")
330-
margins_method = config._process_legacy_keyword(
331-
kwargs, 'method', 'margins_method', margins_method)
332-
333339
if not isinstance(data, (list, tuple)):
334340
data = [data]
335341

@@ -728,7 +734,7 @@ def _make_line_label(response, output_index, input_index):
728734
label='_nyq_mag_' + sysname)
729735

730736
# Add a grid to the plot
731-
ax_mag.grid(grid and not display_margins, which='both')
737+
ax_mag.grid(grid, which='both')
732738

733739
# Phase
734740
if plot_phase:
@@ -743,7 +749,7 @@ def _make_line_label(response, output_index, input_index):
743749
label='_nyq_phase_' + sysname)
744750

745751
# Add a grid to the plot
746-
ax_phase.grid(grid and not display_margins, which='both')
752+
ax_phase.grid(grid, which='both')
747753

748754
#
749755
# Display gain and phase margins (SISO only)
@@ -754,6 +760,10 @@ def _make_line_label(response, output_index, input_index):
754760
raise NotImplementedError(
755761
"margins are not available for MIMO systems")
756762

763+
if display_margins == 'overlay' and len(data) > 1:
764+
raise NotImplementedError(
765+
f"{display_margins=} not supported for multi-trace plots")
766+
757767
# Compute stability margins for the system
758768
margins = stability_margins(response, method=margins_method)
759769
gm, pm, Wcg, Wcp = (margins[i] for i in [0, 1, 3, 4])
@@ -847,12 +857,12 @@ def _make_line_label(response, output_index, input_index):
847857

848858
else:
849859
# Put the title underneath the suptitle (one line per system)
850-
ax = ax_mag if ax_mag else ax_phase
851-
axes_title = ax.get_title()
860+
ax_ = ax_mag if ax_mag else ax_phase
861+
axes_title = ax_.get_title()
852862
if axes_title is not None and axes_title != "":
853863
axes_title += "\n"
854864
with plt.rc_context(rcParams):
855-
ax.set_title(
865+
ax_.set_title(
856866
axes_title + f"{sysname}: "
857867
"Gm = %.2f %s(at %.2f %s), "
858868
"Pm = %.2f %s (at %.2f %s)" %

control/tests/freqplot_test.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# freqplot_test.py - test out frequency response plots
22
# RMM, 23 Jun 2023
33

4+
import re
45
import matplotlib as mpl
56
import matplotlib.pyplot as plt
67
import numpy as np
@@ -597,6 +598,12 @@ def test_suptitle():
597598
TypeError, match="unexpected keyword|no property"):
598599
cplt.set_plot_title("New title", unknown=None)
599600

601+
# Make sure title is still there if we display margins underneath
602+
sys = ct.rss(2, 1, 1, name='sys')
603+
cplt = ct.bode_plot(sys, display_margins=True)
604+
assert re.match(r"^Bode plot for sys$", cplt.figure._suptitle._text)
605+
assert re.match(r"^sys: Gm = .*, Pm = .*$", cplt.axes[0, 0].get_title())
606+
600607

601608
@pytest.mark.parametrize("plt_fcn", [ct.bode_plot, ct.singular_values_plot])
602609
def test_freqplot_errors(plt_fcn):
@@ -617,6 +624,7 @@ def test_freqplot_errors(plt_fcn):
617624
with pytest.raises(ValueError, match="invalid limits"):
618625
plt_fcn(response, omega_limits=[1e2, 1e-2])
619626

627+
620628
def test_freqresplist_unknown_kw():
621629
sys1 = ct.rss(2, 1, 1)
622630
sys2 = ct.rss(2, 1, 1)
@@ -626,6 +634,52 @@ def test_freqresplist_unknown_kw():
626634
with pytest.raises(AttributeError, match="unexpected keyword"):
627635
resp.plot(unknown=True)
628636

637+
@pytest.mark.parametrize("nsys, display_margins, gridkw, match", [
638+
(1, True, {}, None),
639+
(1, False, {}, None),
640+
(1, False, {}, None),
641+
(1, True, {'grid': True}, None),
642+
(1, 'overlay', {}, None),
643+
(1, 'overlay', {'grid': True}, None),
644+
(1, 'overlay', {'grid': False}, None),
645+
(2, True, {}, None),
646+
(2, 'overlay', {}, "not supported for multi-trace plots"),
647+
(2, True, {'grid': 'overlay'}, None),
648+
(3, True, {'grid': True}, None),
649+
])
650+
def test_display_margins(nsys, display_margins, gridkw, match):
651+
sys1 = ct.tf([10], [1, 1, 1, 1], name='sys1')
652+
sys2 = ct.tf([20], [2, 2, 2, 1], name='sys2')
653+
sys3 = ct.tf([30], [2, 3, 3, 1], name='sys3')
654+
655+
sysdata = [sys1, sys2, sys3][0:nsys]
656+
657+
plt.figure()
658+
if match is None:
659+
cplt = ct.bode_plot(sysdata, display_margins=display_margins, **gridkw)
660+
else:
661+
with pytest.raises(NotImplementedError, match=match):
662+
ct.bode_plot(sysdata, display_margins=display_margins, **gridkw)
663+
return
664+
665+
cplt.set_plot_title(
666+
cplt.figure._suptitle._text + f" [d_m={display_margins}, {gridkw=}")
667+
668+
# Make sure the grid is there if it should be
669+
if gridkw.get('grid') or not display_margins:
670+
assert all(
671+
[line.get_visible() for line in cplt.axes[0, 0].get_xgridlines()])
672+
else:
673+
assert not any(
674+
[line.get_visible() for line in cplt.axes[0, 0].get_xgridlines()])
675+
676+
# Make sure margins are displayed
677+
if display_margins == True:
678+
ax_title = cplt.axes[0, 0].get_title()
679+
assert len(ax_title.split('\n')) == nsys
680+
elif display_margins == 'overlay':
681+
assert cplt.axes[0, 0].get_title() == ''
682+
629683

630684
if __name__ == "__main__":
631685
#
@@ -680,3 +734,6 @@ def test_freqresplist_unknown_kw():
680734
# of them for use in the documentation).
681735
#
682736
test_mixed_systypes()
737+
test_display_margins(2, True, {})
738+
test_display_margins(2, 'overlay', {})
739+
test_display_margins(2, True, {'grid': True})

0 commit comments

Comments
 (0)