Skip to content

Commit cec9e70

Browse files
committed
update label processing to provide common functionality + unit tests
1 parent 0dc4790 commit cec9e70

9 files changed

Lines changed: 196 additions & 38 deletions

File tree

control/ctrlplot.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ def _process_ax_keyword(
384384

385385
# Turn label keyword into array indexed by trace, output, input
386386
# TODO: move to ctrlutil.py and update parameter names to reflect general use
387-
def _process_line_labels(label, ntraces, ninputs=0, noutputs=0):
387+
def _process_line_labels(label, ntraces=1, ninputs=0, noutputs=0):
388388
if label is None:
389389
return None
390390

@@ -447,7 +447,7 @@ def _make_legend_labels(labels, ignore_common=False):
447447
if last_space < 0 or ignore_common:
448448
common_prefix = ''
449449
elif last_space > 0:
450-
common_prefix = common_prefix[:last_space]
450+
common_prefix = common_prefix[:last_space + 2]
451451
prefix_len = len(common_prefix)
452452

453453
# Look for a common suffix (up to a space)

control/descfcn.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ def _cost(x):
380380

381381

382382
def describing_function_plot(
383-
*sysdata, label="%5.2g @ %-5.2g", **kwargs):
383+
*sysdata, point_label="%5.2g @ %-5.2g", label=None, **kwargs):
384384
"""describing_function_plot(data, *args, **kwargs)
385385
386386
Plot a Nyquist plot with a describing function for a nonlinear system.
@@ -420,7 +420,7 @@ def describing_function_plot(
420420
If True (default), refine the location of the intersection of the
421421
Nyquist curve for the linear system and the describing function to
422422
determine the intersection point
423-
label : str, optional
423+
point_label : str, optional
424424
Formatting string used to label intersection points on the Nyquist
425425
plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels.
426426
@@ -453,6 +453,8 @@ def describing_function_plot(
453453
# Process keywords
454454
warn_nyquist = config._process_legacy_keyword(
455455
kwargs, 'warn', 'warn_nyquist', kwargs.pop('warn_nyquist', None))
456+
point_label = config._process_legacy_keyword(
457+
kwargs, 'label', 'point_label', point_label)
456458

457459
# TODO: update to be consistent with ctrlplot use of `label`
458460
if label not in (False, None) and not isinstance(label, str):
@@ -484,10 +486,10 @@ def describing_function_plot(
484486
lines[1] = plt.plot(dfresp.N_vals.real, dfresp.N_vals.imag)
485487

486488
# Label the intersection points
487-
if label:
489+
if point_label:
488490
for pos, (a, omega) in zip(dfresp.positions, dfresp.intersections):
489491
# Add labels to the intersection points
490-
plt.text(pos.real, pos.imag, label % (a, omega))
492+
plt.text(pos.real, pos.imag, point_label % (a, omega))
491493

492494
return ControlPlot(lines, cfig.axes, cfig.figure)
493495

control/freqplot.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2078,7 +2078,8 @@ def gangof4_response(
20782078

20792079
return FrequencyResponseData(
20802080
data, omega, outputs=['y', 'u'], inputs=['r', 'd'],
2081-
title=f"Gang of Four for P={P.name}, C={C.name}", plot_phase=False)
2081+
title=f"Gang of Four for P={P.name}, C={C.name}",
2082+
sysname=f"P={P.name}, C={C.name}", plot_phase=False)
20822083

20832084

20842085
def gangof4_plot(

control/grid.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,7 @@ def sgrid(subplot=(1, 1, 1), scaling=None):
104104
ax = SubplotHost(fig, *subplot, grid_helper=grid_helper)
105105

106106
# make ticklabels of right invisible, and top axis visible.
107-
visible = True
108-
ax.axis[:].major_ticklabels.set_visible(visible)
107+
ax.axis[:].major_ticklabels.set_visible(True)
109108
ax.axis[:].major_ticks.set_visible(False)
110109
ax.axis[:].invert_ticklabel_direction()
111110
ax.axis[:].major_ticklabels.set_color('gray')

control/nichols.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from . import config
2121
from .ctrlplot import ControlPlot, _get_line_labels, _process_ax_keyword, \
22-
suptitle
22+
_process_line_labels, suptitle
2323
from .ctrlutil import unwrap
2424
from .freqplot import _default_frequency_range, _freqplot_defaults
2525
from .lti import frequency_response
@@ -36,7 +36,7 @@
3636

3737
def nichols_plot(
3838
data, omega=None, *fmt, grid=None, title=None, ax=None,
39-
legend_loc='upper left', **kwargs):
39+
legend_loc='upper left', label=None, **kwargs):
4040
"""Nichols plot for a system.
4141
4242
Plots a Nichols plot for the system over a (optional) frequency range.
@@ -53,6 +53,10 @@ def nichols_plot(
5353
The `omega` parameter must be present (use omega=None if needed).
5454
grid : boolean, optional
5555
True if the plot should include a Nichols-chart grid. Default is True.
56+
label : str or array-like of str
57+
If present, replace automatically generated label(s) with given
58+
label(s). If sysdata is a list, strings should be specified for each
59+
system.
5660
legend_loc : str, optional
5761
For plots with multiple lines, a legend will be included in the
5862
given location. Default is 'upper left'. Use False to supress.
@@ -61,7 +65,7 @@ def nichols_plot(
6165
6266
Returns
6367
-------
64-
cplt : :class:`ControlPlot` object
68+
cplt : :class:`ControlPlot` object
6569
Object containing the data that were plotted:
6670
6771
* cplt.lines: 1D array of :class:`matplotlib.lines.Line2D` objects.
@@ -81,6 +85,7 @@ def nichols_plot(
8185
"""
8286
# Get parameter values
8387
grid = config._get_param('nichols', 'grid', grid, True)
88+
label = _process_line_labels(label)
8489
rcParams = config._get_param(
8590
'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True)
8691

@@ -113,12 +118,13 @@ def nichols_plot(
113118
x = unwrap(np.degrees(phase), 360)
114119
y = 20*np.log10(mag)
115120

116-
# Decide on the system name
121+
# Decide on the system name and label
117122
sysname = response.sysname if response.sysname is not None \
118123
else f"Unknown-{idx_sys}"
124+
label_ = sysname if label is None else label[idx]
119125

120126
# Generate the plot
121-
out[idx] = ax_nichols.plot(x, y, *fmt, label=sysname, **kwargs)
127+
out[idx] = ax_nichols.plot(x, y, *fmt, label=label_, **kwargs)
122128

123129
# Label the plot axes
124130
plt.xlabel('Phase [deg]')

control/pzmap.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from . import config
2121
from .ctrlplot import ControlPlot, suptitle, _get_line_labels, \
22-
_process_ax_keyword
22+
_process_ax_keyword, _process_line_labels
2323
from .freqplot import _freqplot_defaults
2424
from .grid import nogrid, sgrid, zgrid
2525
from .iosys import isctime, isdtime
@@ -173,7 +173,7 @@ def pole_zero_plot(
173173
data, plot=None, grid=None, title=None, marker_color=None,
174174
marker_size=None, marker_width=None, legend_loc='upper right',
175175
xlim=None, ylim=None, interactive=None, ax=None, scaling=None,
176-
initial_gain=None, **kwargs):
176+
initial_gain=None, label=None, **kwargs):
177177
"""Plot a pole/zero map for a linear system.
178178
179179
If the system data include root loci, a root locus diagram for the
@@ -230,25 +230,30 @@ def pole_zero_plot(
230230
scaling : str or list, optional
231231
Set the type of axis scaling. Can be 'equal' (default), 'auto', or
232232
a list of the form [xmin, xmax, ymin, ymax].
233-
title : str, optional
234-
Set the title of the plot. Defaults plot type and system name(s).
233+
initial_gain : float, optional
234+
If given, the specified system gain will be marked on the plot.
235+
236+
interactive : bool, optional
237+
Turn off interactive mode for root locus plots.
238+
label : str or array-like of str
239+
If present, replace automatically generated label(s) with given
240+
label(s). If data is a list, strings should be specified for each
241+
system.
242+
legend_loc : str, optional
243+
For plots with multiple lines, a legend will be included in the
244+
given location. Default is 'center right'. Use False to supress.
235245
marker_color : str, optional
236246
Set the color of the markers used for poles and zeros.
237247
marker_size : int, optional
238248
Set the size of the markers used for poles and zeros.
239249
marker_width : int, optional
240250
Set the line width of the markers used for poles and zeros.
241-
legend_loc : str, optional
242-
For plots with multiple lines, a legend will be included in the
243-
given location. Default is 'center right'. Use False to supress.
251+
title : str, optional
252+
Set the title of the plot. Defaults plot type and system name(s).
244253
xlim : list, optional
245254
Set the limits for the x axis.
246255
ylim : list, optional
247256
Set the limits for the y axis.
248-
interactive : bool, optional
249-
Turn off interactive mode for root locus plots.
250-
initial_gain : float, optional
251-
If given, the specified system gain will be marked on the plot.
252257
253258
Notes
254259
-----
@@ -260,13 +265,14 @@ def pole_zero_plot(
260265
261266
"""
262267
# Get parameter values
268+
label = _process_line_labels(label)
263269
marker_size = config._get_param('pzmap', 'marker_size', marker_size, 6)
264270
marker_width = config._get_param('pzmap', 'marker_width', marker_width, 1.5)
265-
xlim_user, ylim_user = xlim, ylim
266271
rcParams = config._get_param(
267272
'freqplot', 'rcParams', kwargs, _freqplot_defaults,
268273
pop=True, last=True)
269274
user_ax = ax
275+
xlim_user, ylim_user = xlim, ylim
270276

271277
# If argument was a singleton, turn it into a tuple
272278
if not isinstance(data, (list, tuple)):
@@ -371,12 +377,15 @@ def pole_zero_plot(
371377

372378
# Plot the locations of the poles and zeros
373379
if len(poles) > 0:
374-
label = response.sysname if response.loci is None else None
380+
if label is None:
381+
label_ = response.sysname if response.loci is None else None
382+
else:
383+
label_ = label[idx]
375384
out[idx, 0] = ax.plot(
376385
real(poles), imag(poles), marker='x', linestyle='',
377386
markeredgecolor=color, markerfacecolor=color,
378387
markersize=marker_size, markeredgewidth=marker_width,
379-
color=color, label=label)
388+
color=color, label=label_)
380389
if len(zeros) > 0:
381390
out[idx, 1] = ax.plot(
382391
real(zeros), imag(zeros), marker='o', linestyle='',
@@ -386,10 +395,10 @@ def pole_zero_plot(
386395

387396
# Plot the loci, if present
388397
if response.loci is not None:
398+
label_ = response.sysname if label is None else label[idx]
389399
for locus in response.loci.transpose():
390400
out[idx, 2] += ax.plot(
391-
real(locus), imag(locus), color=color,
392-
label=response.sysname)
401+
real(locus), imag(locus), color=color, label=label_)
393402

394403
# Compute the axis limits to use based on the response
395404
resp_xlim, resp_ylim = _compute_root_locus_limits(response)

control/tests/ctrlplot_test.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
(ct.input_output_response, ct.time_response_plot),
3030
]
3131

32+
nolabel_plot_fcns = [ct.describing_function_plot, ct.phase_plane_plot]
3233
deprecated_fcns = [ct.phase_plot]
3334

3435
# Make sure we didn't miss any plotting functions
@@ -135,6 +136,125 @@ def test_plot_ax_processing(resp_fcn, plot_fcn):
135136
assert get_line_color(cplt3) != get_line_color(cplt1)
136137
assert get_line_color(cplt3) != get_line_color(cplt2)
137138

139+
140+
@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns)
141+
@pytest.mark.usefixtures('mplcleanup')
142+
def test_plot_label_processing(resp_fcn, plot_fcn):
143+
# Utility function to make sure legends are OK
144+
def assert_legend(cplt, expected_texts):
145+
# Check to make sure the labels are OK in legend
146+
legend = None
147+
for ax in cplt.axes.flatten():
148+
legend = ax.get_legend()
149+
if legend is not None:
150+
break
151+
if expected_texts is None:
152+
assert legend is None
153+
else:
154+
assert legend is not None
155+
legend_texts = [entry.get_text() for entry in legend.get_texts()]
156+
assert legend_texts == expected_texts
157+
158+
# Create some systems to use
159+
sys1 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[1]")
160+
sys1c = ct.rss(4, 1, 1, strictly_proper=True, name="sys[1]_C")
161+
sys2 = ct.rss(4, 1, 1, strictly_proper=True, name="sys[2]")
162+
163+
# Set up arguments
164+
kwargs = meth_kwargs = plot_fcn_kwargs = {}
165+
default_labels = ["sys[1]", "sys[2]"]
166+
expected_labels = ["sys1_", "sys2_"]
167+
match resp_fcn, plot_fcn:
168+
case ct.describing_function_response, _:
169+
F = ct.descfcn.saturation_nonlinearity(1)
170+
amp = np.linspace(1, 4, 10)
171+
args1 = (sys1, F, amp)
172+
args2 = (sys2, F, amp)
173+
174+
case ct.gangof4_response, _:
175+
args1 = (sys1, sys1c)
176+
args2 = (sys2, sys1c)
177+
default_labels = ["P=sys[1]", "P=sys[2]"]
178+
179+
case ct.frequency_response, ct.nichols_plot:
180+
args1 = (sys1, )
181+
args2 = (sys2, )
182+
meth_kwargs = {'plot_type': 'nichols'}
183+
184+
case ct.root_locus_map, ct.root_locus_plot:
185+
args1 = (sys1, )
186+
args2 = (sys2, )
187+
meth_kwargs = plot_fcn_kwargs = {'interactive': False}
188+
189+
case (ct.forced_response | ct.input_output_response, _):
190+
timepts = np.linspace(1, 10)
191+
U = np.sin(timepts)
192+
args1 = (resp_fcn(sys1, timepts, U), )
193+
args2 = (resp_fcn(sys2, timepts, U), )
194+
argsc = (resp_fcn([sys1, sys2], timepts, U), )
195+
196+
case (ct.impulse_response | ct.initial_response | ct.step_response, _):
197+
args1 = (resp_fcn(sys1), )
198+
args2 = (resp_fcn(sys2), )
199+
argsc = (resp_fcn([sys1, sys2]), )
200+
201+
case _, _:
202+
args1 = (sys1, )
203+
args2 = (sys2, )
204+
205+
if plot_fcn in nolabel_plot_fcns:
206+
pytest.skip(f"labels not implemented for {plot_fcn}")
207+
208+
# Generate the first plot, with default labels
209+
cplt1 = plot_fcn(*args1, **kwargs, **plot_fcn_kwargs)
210+
assert isinstance(cplt1, ct.ControlPlot)
211+
assert_legend(cplt1, None)
212+
213+
# Generate second plot with default labels
214+
cplt2 = plot_fcn(*args2, **kwargs, **plot_fcn_kwargs)
215+
assert isinstance(cplt2, ct.ControlPlot)
216+
assert_legend(cplt2, default_labels)
217+
plt.close()
218+
219+
# Generate both plots at the same time
220+
if len(args1) == 1 and plot_fcn != ct.time_response_plot:
221+
cplt = plot_fcn([*args1, *args2], **kwargs, **plot_fcn_kwargs)
222+
assert isinstance(cplt, ct.ControlPlot)
223+
assert_legend(cplt, default_labels)
224+
elif len(args1) == 1 and plot_fcn == ct.time_response_plot:
225+
# Use TimeResponseList.plot() to generate combined response
226+
cplt = argsc[0].plot(**kwargs, **plot_fcn_kwargs)
227+
assert isinstance(cplt, ct.ControlPlot)
228+
assert_legend(cplt, default_labels)
229+
plt.close()
230+
231+
# Generate plots sequentially, with updated labels
232+
cplt1 = plot_fcn(
233+
*args1, **kwargs, **plot_fcn_kwargs, label=expected_labels[0])
234+
assert isinstance(cplt1, ct.ControlPlot)
235+
assert_legend(cplt1, None)
236+
237+
cplt2 = plot_fcn(
238+
*args2, **kwargs, **plot_fcn_kwargs, label=expected_labels[1])
239+
assert isinstance(cplt2, ct.ControlPlot)
240+
assert_legend(cplt2, expected_labels)
241+
plt.close()
242+
243+
# Generate both plots at the same time, with updated labels
244+
if len(args1) == 1 and plot_fcn != ct.time_response_plot:
245+
cplt = plot_fcn(
246+
[*args1, *args2], **kwargs, **plot_fcn_kwargs,
247+
label=expected_labels)
248+
assert isinstance(cplt, ct.ControlPlot)
249+
assert_legend(cplt, expected_labels)
250+
elif len(args1) == 1 and plot_fcn == ct.time_response_plot:
251+
# Use TimeResponseList.plot() to generate combined response
252+
cplt = argsc[0].plot(
253+
**kwargs, **plot_fcn_kwargs, label=expected_labels)
254+
assert isinstance(cplt, ct.ControlPlot)
255+
assert_legend(cplt, expected_labels)
256+
257+
138258
@pytest.mark.usefixtures('mplcleanup')
139259
def test_rcParams():
140260
sys = ct.rss(2, 2, 2)

0 commit comments

Comments
 (0)