8000 update title processing to be uniform across _plot functions · toaster-code/python-control@89cda3d · GitHub
[go: up one dir, main page]

Skip to content

Commit 89cda3d

Browse files
committed
update title processing to be uniform across _plot functions
1 parent cec9e70 commit 89cda3d

File tree

8 files changed

+169
-15
lines changed

8 files changed

+169
-15
lines changed

control/ctrlplot.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@
6464
# sysnames = [response.sysname for response in data]
6565
# if title is None:
6666
# title = "Name plot for " + ", ".join(sysnames)
67-
# _update_suptitle(fig, title, rcParams=rcParams)
67+
# _update_suptitle(title, fig, rcParams=rcParams)
68+
# else
69+
# suptitle(title, fig, rcParams=rcParams)
6870
#
6971
# # Legacy processing of plot keyword
7072
# if plot is True:
@@ -159,6 +161,9 @@ def __setitem__(self, item, val):
159161
def reshape(self, *args):
160162
return self.lines.reshape(*args)
161163

164+
def set_plot_title(self, title, frame='axes'):
165+
suptitle(title, fig=self.figure, frame=frame)
166+
162167

163168
#
164169
# User functions
@@ -467,7 +472,7 @@ def _make_legend_labels(labels, ignore_common=False):
467472
return labels
468473

469474

470-
def _update_suptitle(fig, title, rcParams=None, frame='axes'):
475+
def _update_suptitle(title, fig=None, frame='axes', rcParams=None):
471476
if fig is not None and isinstance(title, str):
472477
# Get the current title, if it exists
473478
old_title = None if fig._suptitle is None else fig._suptitle._text

control/freqplot.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ def bode_plot(
142142
143143
Other Parameters
144144
----------------
145+
ax : array of Axes
146+
The matplotlib Axes to draw the figure on. If not specified, the
147+
Axes for the current figure are used or, if there is no current
148+
figure with the correct number and shape of Axes, a new figure is
149+
created. The shape of the array must match the shape of the
150+
plotted data.
145151
grid : bool
146152
If True, plot grid lines on gain and phase plots. Default is set by
147153
`config.defaults['freqplot.grid']`.
@@ -173,6 +179,8 @@ def bode_plot(
173179
rcParams : dict
174180
Override the default parameters used for generating plots.
175181
Default is set by config.default['freqplot.rcParams'].
182+
title : str, optional
183+
Set the title of the plot. Defaults to plot type and system name(s).
176184
wrap_phase : bool or float
177185
If wrap_phase is `False` (default), then the phase will be unwrapped
178186
so that it is continuously increasing or decreasing. If wrap_phase is
@@ -948,13 +956,16 @@ def gen_zero_centered_series(val_min, val_max, period):
948956
seen = set()
949957
sysnames = [response.sysname for response in data \
950958
if not (response.sysname in seen or seen.add(response.sysname))]
959+
951960
if title is None:
952961
if data[0].title is None:
953962
title = "Bode plot for " + ", ".join(sysnames)
954963
else:
964+
# Allow data to set the title (used by gangof4)
955965
title = data[0].title
956-
957-
_update_suptitle(fig, title, rcParams=rcParams, frame=suptitle_frame)
966+
_update_suptitle(title, fig, rcParams=rcParams, frame=suptitle_frame)
967+
else:
968+
suptitle(title, fig=fig, rcParams=rcParams, frame=suptitle_frame)
958969

959970
#
960971
# Create legends
@@ -1603,6 +1614,8 @@ def nyquist_plot(
16031614
start_marker_size : float, optional
16041615
Start marker size (in display coordinates). Default value is
16051616
4 and can be set using config.defaults['nyquist.start_marker_size'].
1617+
title : str, optional
1618+
Set the title of the plot. Defaults to plot type and system name(s).
16061619
warn_nyquist : bool, optional
16071620
If set to 'False', turn off warnings about frequencies above Nyquist.
16081621
warn_encirclements : bool, optional
@@ -1870,7 +1883,7 @@ def _parse_linestyle(style_name, allow_false=False):
18701883
# Display the unit circle, to read gain crossover frequency
18711884
if unit_circle:
18721885
plt.plot(cos, sin, **config.defaults['nyquist.circle_style'])
1873-
1886+
18741887
# Draw circles for given magnitudes of sensitivity
18751888
if ms_circles is not None:
18761889
for ms in ms_circles:
@@ -2243,6 +2256,8 @@ def singular_values_plot(
22432256
rcParams : dict
22442257
Override the default parameters used for generating plots.
22452258
Default is set up config.default['freqplot.rcParams'].
2259+
title : str, optional
2260+
Set the title of the plot. Defaults to plot type and system name(s).
22462261
22472262
See Also
22482263
--------

control/nichols.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ def nichols_plot(
6060
legend_loc : str, optional
6161
For plots with multiple lines, a legend will be included in the
6262
given location. Default is 'upper left'. Use False to supress.
63+
title : str, optional
64+
Set the title of the plot. Defaults to plot type and system name(s).
6365
**kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional
6466
Additional keywords passed to `matplotlib` to specify line properties.
6567

control/phaseplot.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
def phase_plane_plot(
5555
sys, pointdata=None, timedata=None, gridtype=None, gridspec=None,
5656
plot_streamlines=True, plot_vectorfield=False, plot_equilpoints=True,
57-
plot_separatrices=True, ax=None, suppress_warnings=False, **kwargs
57+
plot_separatrices=True, ax=None, suppress_warnings=False, title=None,
58+
**kwargs
5859
):
5960
"""Plot phase plane diagram.
6061
@@ -135,6 +136,8 @@ def phase_plane_plot(
135136
in the dict as keywords to :func:`~control.phaseplot.separatrices`.
136137
suppress_warnings : bool, optional
137138
If set to `True`, suppress warning messages in generating trajectories.
139+
title : str, optional
140+
Set the title of the plot. Defaults to plot type and system name(s).
138141
139142
"""
140143
# Process arguments
@@ -217,7 +220,9 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs):
217220
# TODO: update to common code pattern
218221
if user_ax is None:
219222
with plt.rc_context(rcParams):
220-
suptitle(f"Phase portrait for {sys.name}")
223+
if title is None:
224+
title = f"Phase portrait for {sys.name}"
225+
suptitle(title)
221226
ax.set_xlabel(sys.state_labels[0])
222227
ax.set_ylabel(sys.state_labels[1])
223228

control/pzmap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ def pole_zero_plot(
249249
marker_width : int, optional
250250
Set the line width of the markers used for poles and zeros.
251251
title : str, optional
252-
Set the title of the plot. Defaults plot type and system name(s).
252+
Set the title of the plot. Defaults to plot type and system name(s).
253253
xlim : list, optional
254254
Set the limits for the x axis.
255255
ylim : list, optional

control/tests/ctrlplot_test.py

Lines changed: 119 additions & 1 deletion
F438
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def test_plot_ax_processing(resp_fcn, plot_fcn):
128128
else:
129129
cplt3 = plot_fcn(*args, **kwargs, **meth_kwargs)
130130
assert cplt3.figure == cplt1.figure
131-
131+
132132
# Plot should have landed on top of previous plot, in different colors
133133
assert np.all(cplt3.axes == cplt1.axes)
134134
assert len(cplt3.lines[0]) == len(cplt1.lines[0])
@@ -255,6 +255,124 @@ def assert_legend(cplt, expected_texts):
255255
assert_legend(cplt, expected_labels)
256256

257257

258+
@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns)
259+
@pytest.mark.usefixtures('mplcleanup')
260+
def test_plot_title_processing(resp_fcn, plot_fcn):
261+
# Create some systems to use
262+
sys1 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[1]")
263+
sys1c = ct.rss(4, 1, 1, strictly_proper=True, name="sys[1]_C")
264+
sys2 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[2]")
265+
266+
# Set up arguments
267+
kwargs = meth_kwargs = plot_fcn_kwargs = {}
268+
default_title = "sys[1], sys[2]"
269+
expected_title = "sys1_, sys2_"
270+
match resp_fcn, plot_fcn:
271+
case ct.describing_function_response, _:
272+
F = ct.descfcn.saturation_nonlinearity(1)
273+
amp = np.linspace(1, 4, 10)
274+
args1 = (sys1, F, amp)
275+
args2 = (sys2, F, amp)
276+
277+
case ct.gangof4_response, _:
278+
args1 = (sys1, sys1c)
279+
args2 = (sys2, sys1c)
280+
default_title = "P=sys[1], C=sys[1]_C, P=sys[2], C=sys[1]_C"
281+
282+
case ct.frequency_response, ct.nichols_plot:
283+
args1 = (sys1, )
284+
args2 = (sys2, )
285+
meth_kwargs = {'plot_type': 'nichols'}
286+
287+
case ct.root_locus_map, ct.root_locus_plot:
288+
args1 = (sys1, )
289+
args2 = (sys2, )
290+
meth_kwargs = plot_fcn_kwargs = {'interactive': False}
291+
292+
case (ct.forced_response | ct.input_output_response, _):
293+
timepts = np.linspace(1, 10)
294+
U = np.sin(timepts)
295+
args1 = (resp_fcn(sys1, timepts, U), )
296+
args2 = (resp_fcn(sys2, timepts, U), )
297+
argsc = (resp_fcn([sys1, sys2], timepts, U), )
298+
299+
case (ct.impulse_response | ct.initial_response | ct.step_response, _):
300+
args1 = (resp_fcn(sys1), )
301+
args2 = (resp_fcn(sys2), )
302+
argsc = (resp_fcn([sys1, sys2]), )
303+
304+
case _, _:
305+
args1 = (sys1, )
306+
args2 = (sys2, )
307+
308+
# Store the expected title prefix
309+
match resp_fcn, plot_fcn:
310+
case _, ct.bode_plot:
311+
title_prefix = "Bode plot for "
312+
case _, ct.nichols_plot:
313+
title_prefix = "Nichols plot for "
314+
case _, ct.singular_values_plot:
315+
title_prefix = "Singular values for "
316+
case _, ct.gangof4_plot:
317+
title_prefix = "Gang of Four for "
318+
case _, ct.describing_function_plot:
319+
title_prefix = "Nyquist plot for "
320+
case _, ct.phase_plane_plot:
321+
title_prefix = "Phase portrait for "
322+
case _, ct.pole_zero_plot:
323+
title_prefix = "Pole/zero plot for "
324+
case _, ct.nyquist_plot:
325+
title_prefix = "Nyquist plot for "
326+
case _, ct.root_locus_plot:
327+
title_prefix = "Root locus plot for "
328+
case ct.initial_response, _:
329+
title_prefix = "Initial response for "
330+
case ct.step_response, _:
331+
title_prefix = "Step response for "
332+
case ct.impulse_response, _:
333+
title_prefix = "Impulse response for "
334+
case ct.forced_response, _:
335+
title_prefix = "Forced response for "
336+
case ct.input_output_response, _:
337+
title_prefix = "Input/output response for "
338+
case _:
339+
raise RuntimeError(f"didn't recognize {resp_fnc}, {plot_fnc}")
340+
341+
# Generate the first plot, with default labels
342+
cplt1 = plot_fcn(*args1, **kwargs, **plot_fcn_kwargs)
343+
assert cplt1.figure._suptitle._text.startswith(title_prefix)
344+
345+
# Skip functions not intended for sequential calling
346+
if plot_fcn not in nolabel_plot_fcns:
347+
# Generate second plot with default labels
348+
cplt2 = plot_fcn(*args2, **kwargs, **plot_fcn_kwargs)
349+
assert cplt1.figure._suptitle._text == title_prefix + default_title
350+
plt.close()
351+
352+
# Generate both plots at the same time
353+
if len(args1) == 1 and plot_fcn != ct.time_response_plot:
354+
cplt = plot_fcn([*args1, *args2], **kwargs, **plot_fcn_kwargs)
355+
assert cplt.figure._suptitle._text == title_prefix + default_title
356+
elif len(args1) == 1 and plot_fcn == ct.time_response_plot:
357+
# Use TimeResponseList.plot() to generate combined response
358+
cplt = argsc[0].plot(**kwargs, **plot_fcn_kwargs)
359+
assert cplt.figure._suptitle._text == title_prefix + default_title
360+
plt.close()
361+
362+
# Generate plots sequentially, with updated titles
363+
cplt1 = plot_fcn(
364+
*args1, **kwargs, **plot_fcn_kwargs, title="My first title")
365+
cplt2 = plot_fcn(
366+
*args2, **kwargs, **plot_fcn_kwargs, title="My new title")
367+
assert cplt2.figure._suptitle._text == "My new title"
368+
plt.close()
369+
370+
# Update using set_plot_title
371+
cplt2.set_plot_title("Another title")
372+
assert cplt2.figure._suptitle._text == "Another title"
373+
plt.close()
374+
375+
258376
@pytest.mark.usefixtures('mplcleanup')
259377
def test_rcParams():
260378
sys = ct.rss(2, 2, 2)

control/timeplot.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
import numpy as np
1616

1717
from . import config
18-
from .ctrlplot import ControlPlot, _ctrlplot_rcParams, _make_legend_labels, \
19-
_update_suptitle
18+
from .ctrlplot import ControlPlot, suptitle, _ctrlplot_rcParams, \
19+
_make_legend_labels, _update_suptitle
2020

2121
__all__ = ['time_response_plot', 'combine_time_responses']
2222

@@ -139,6 +139,8 @@ def time_response_plot(
139139
axis or ``legend_loc`` or ``legend_map`` have been specified.
140140
time_label : str, optional
141141
Label to use for the time axis.
142+
title : str, optional
143+
Set the title of the plot. Defaults to plot type and system name(s).
142144
trace_labels : list of str, optional
143145
Replace the default trace labels with the given labels.
144146
trace_props : array of dicts
@@ -196,9 +198,6 @@ def time_response_plot(
196198
'timeplot', 'trace_props', kwargs, _timeplot_defaults, pop=True)
197199
tprop_len = len(trace_props)
198200

199-
# Set the title for the data
200-
title = data.title if title == None else title
201-
202201
# Determine whether or not to plot the input data (and how)
203202
if plot_inputs is None:
204203
plot_inputs = data.plot_inputs
@@ -658,7 +657,11 @@ def _make_line_label(signal_index, signal_labels, trace_index):
658657
# list of systems (e.g., "Step response for sys[1], sys[2]").
659658
#
660659

661-
_update_suptitle(fig, title, rcParams=rcParams)
660+
if title is None:
661+
title = data.title if title == None else title
662+
_update_suptitle(title, fig, rcParams=rcParams)
663+
else:
664+
suptitle(title, fig, rcParams=rcParams)
662665

663666
return ControlPlot(out, ax_array, fig, legend=legend_map)
664667

@@ -676,6 +679,9 @@ def combine_time_responses(response_list, trace_labels=None, title=None):
676679
trace_labels : list of str, optional
677680
List of labels for each trace. If not specified, trace names are
678681
taken from the input data or set to None.
682+
title : str, optional
683+
Set the title to use when plotting. Defaults to plot type and
684+
system name(s).
679685
680686
Returns
681687
-------

control/timeresp.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ class TimeResponseData:
192192
response. If ntraces is 0 (default) then the data represents a
193193
single trace with the trace index surpressed in the data.
194194
195+
title : str, optional
196+
Set the title to use when plotting.
197+
195198
trace_labels : array of string, optional
196199
Labels to use for traces (set to sysname it ntraces is 0)
197200

0 commit comments

Comments
 (0)
0