diff --git a/control/config.py b/control/config.py index b6d5385d4..260c7dac6 100644 --- a/control/config.py +++ b/control/config.py @@ -10,6 +10,7 @@ import collections import warnings + from .exception import ControlArgument __all__ = ['defaults', 'set_defaults', 'reset_defaults', @@ -121,6 +122,10 @@ def reset_defaults(): # System level defaults defaults.update(_control_defaults) + from .ctrlplot import _ctrlplot_defaults, reset_rcParams + reset_rcParams() + defaults.update(_ctrlplot_defaults) + from .freqplot import _freqplot_defaults, _nyquist_defaults defaults.update(_freqplot_defaults) defaults.update(_nyquist_defaults) diff --git a/control/ctrlplot.py b/control/ctrlplot.py index 6d31664a0..bc5b2cb04 100644 --- a/control/ctrlplot.py +++ b/control/ctrlplot.py @@ -3,6 +3,84 @@ # # Collection of functions that are used by various plotting functions. +# Code pattern for control system plotting functions: +# +# def name_plot(sysdata, *fmt, plot=None, **kwargs): +# # Process keywords and set defaults +# ax = kwargs.pop('ax', None) +# color = kwargs.pop('color', None) +# label = kwargs.pop('label', None) +# rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) +# +# # Make sure all keyword arguments were processed (if not checked later) +# if kwargs: +# raise TypeError("unrecognized keywords: ", str(kwargs)) +# +# # Process the data (including generating responses for systems) +# sysdata = list(sysdata) +# if any([isinstance(sys, InputOutputSystem) for sys in sysdata]): +# data = name_response(sysdata) +# nrows = max([data.noutputs for data in sysdata]) +# ncols = max([data.ninputs for data in sysdata]) +# +# # Legacy processing of plot keyword +# if plot is False: +# return data.x, data.y +# +# # Figure out the shape of the plot and find/create axes +# fig, ax_array = _process_ax_keyword(ax, (nrows, ncols), rcParams) +# legend_loc, legend_map, show_legend = _process_legend_keywords( +# kwargs, (nrows, ncols), 'center right') +# +# # Customize axes (curvilinear grids, shared axes, etc) +# +# # Plot the data +# lines = np.full(ax_array.shape, []) +# line_labels = _process_line_labels(label, ntraces, nrows, ncols) +# color_offset, color_cycle = _get_color_offset(ax) +# for i, j in itertools.product(range(nrows), range(ncols)): +# ax = ax_array[i, j] +# for k in range(ntraces): +# if color is None: +# color = _get_color( +# color, fmt=fmt, offset=k, color_cycle=color_cycle) +# label = line_labels[k, i, j] +# lines[i, j] += ax.plot(data.x, data.y, color=color, label=label) +# +# # Customize and label the axes +# for i, j in itertools.product(range(nrows), range(ncols)): +# ax_array[i, j].set_xlabel("x label") +# ax_array[i, j].set_ylabel("y label") +# +# # Create legends +# if show_legend != False: +# legend_array = np.full(ax_array.shape, None, dtype=object) +# for i, j in itertools.product(range(nrows), range(ncols)): +# if legend_map[i, j] is not None: +# lines = ax_array[i, j].get_lines() +# labels = _make_legend_labels(lines) +# if len(labels) > 1: +# legend_array[i, j] = ax.legend( +# lines, labels, loc=legend_map[i, j]) +# else: +# legend_array = None +# +# # Update the plot title (only if ax was not given) +# sysnames = [response.sysname for response in data] +# if ax is None and title is None: +# title = "Name plot for " + ", ".join(sysnames) +# _update_plot_title(title, fig, rcParams=rcParams) +# elif ax == None: +# _update_plot_title(title, fig, rcParams=rcParams, use_existing=False) +# +# # Legacy processing of plot keyword +# if plot is True: +# return data +# +# return ControlPlot(lines, ax_array, fig, legend=legend_map) + +import itertools +import warnings from os.path import commonprefix import matplotlib as mpl @@ -11,73 +89,131 @@ from . import config -__all__ = ['suptitle', 'get_plot_axes'] +__all__ = [ + 'ControlPlot', 'suptitle', 'get_plot_axes', 'pole_zero_subplots', + 'rcParams', 'reset_rcParams'] # # Style parameters # -_ctrlplot_rcParams = mpl.rcParams.copy() -_ctrlplot_rcParams.update({ +rcParams_default = { 'axes.labelsize': 'small', 'axes.titlesize': 'small', 'figure.titlesize': 'medium', 'legend.fontsize': 'x-small', 'xtick.labelsize': 'small', 'ytick.labelsize': 'small', -}) +} +_ctrlplot_rcParams = rcParams_default.copy() # provide access inside module +rcParams = _ctrlplot_rcParams # provide access outside module + +_ctrlplot_defaults = {'ctrlplot.rcParams': _ctrlplot_rcParams} # -# User functions -# -# The functions below can be used by users to modify ctrl plots or get -# information about them. +# Control figure # +class ControlPlot(object): + """A class for returning control figures. -def suptitle( - title, fig=None, frame='axes', **kwargs): - """Add a centered title to a figure. + This class is used as the return type for control plotting functions. + It contains the information required to access portions of the plot + that the user might want to adjust, as well as providing methods to + modify some of the properties of the plot. - This is a wrapper for the matplotlib `suptitle` function, but by - setting ``frame`` to 'axes' (default) then the title is centered on the - midpoint of the axes in the figure, rather than the center of the - figure. This usually looks better (particularly with multi-panel - plots), though it takes longer to render. + A control figure consists of a :class:`matplotlib.figure.Figure` with + an array of :class:`matplotlib.axes.Axes`. Each axes in the figure has + a number of lines that represent the data for the plot. There may also + be a legend present in one or more of the axes. - Parameters + Attributes ---------- - title : str - Title text. - fig : Figure, optional - Matplotlib figure. Defaults to current figure. - frame : str, optional - Coordinate frame to use for centering: 'axes' (default) or 'figure'. - **kwargs : :func:`matplotlib.pyplot.suptitle` keywords, optional - Additional keywords (passed to matplotlib). + lines : array of list of :class:`matplotlib:Line2D` + Array of Line2D objects for each line in the plot. Generally, the + shape of the array matches the subplots shape and the value of the + array is a list of Line2D objects in that subplot. Some plotting + functions will return variants of this structure, as described in + the individual documentation for the functions. + axes : 2D array of :class:`matplotlib:Axes` + Array of Axes objects for each subplot in the plot. + figure : :class:`matplotlib:Figure` + Figure on which the Axes are drawn. + legend : :class:`matplotlib:.legend.Legend` (instance or ndarray) + Legend object(s) for the plot. If more than one legend is + included, this will be an array with each entry being either None + (for no legend) or a legend object. """ - rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + def __init__(self, lines, axes=None, figure=None, legend=None): + self.lines = lines + if axes is None: + _get_axes = np.vectorize(lambda lines: lines[0].axes) + axes = _get_axes(lines) + self.axes = np.atleast_2d(axes) + if figure is None: + figure = self.axes[0, 0].figure + self.figure = figure + self.legend = legend + + # Implement methods and properties to allow legacy interface (np.array) + __iter__ = lambda self: self.lines + __len__ = lambda self: len(self.lines) + def __getitem__(self, item): + warnings.warn( + "return of Line2D objects from plot function is deprecated in " + "favor of ControlPlot; use out.lines to access Line2D objects", + category=FutureWarning) + return self.lines[item] + def __setitem__(self, item, val): + self.lines[item] = val + shape = property(lambda self: self.lines.shape, None) + def reshape(self, *args): + return self.lines.reshape(*args) + + def set_plot_title(self, title, frame='axes'): + """Set the title for a control plot. + + This is a wrapper for the matplotlib `suptitle` function, but by + setting ``frame`` to 'axes' (default) then the title is centered on + the midpoint of the axes in the figure, rather than the center of + the figure. This usually looks better (particularly with + multi-panel plots), though it takes longer to render. + + Parameters + ---------- + title : str + Title text. + fig : Figure, optional + Matplotlib figure. Defaults to current figure. + frame : str, optional + Coordinate frame to use for centering: 'axes' (default) or 'figure'. + **kwargs : :func:`matplotlib.pyplot.suptitle` keywords, optional + Additional keywords (passed to matplotlib). + + """ + _update_plot_title( + title, fig=self.figure, frame=frame, use_existing=False) - if fig is None: - fig = plt.gcf() - - if frame == 'figure': - with plt.rc_context(rcParams): - fig.suptitle(title, **kwargs) +# +# User functions +# +# The functions below can be used by users to modify control plots or get +# information about them. +# - elif frame == 'axes': - # TODO: move common plotting params to 'ctrlplot' - with plt.rc_context(rcParams): - plt.tight_layout() # Put the figure into proper layout - xc, _ = _find_axes_center(fig, fig.get_axes()) +def suptitle( + title, fig=None, frame='axes', **kwargs): + """Add a centered title to a figure. - fig.suptitle(title, x=xc, **kwargs) - plt.tight_layout() # Update the layout + This function is deprecated. Use :func:`ControlPlot.set_plot_title`. - else: - raise ValueError(f"unknown frame '{frame}'") + """ + warnings.warn( + "suptitle is deprecated; use cplt.set_plot_title", FutureWarning) + _update_plot_title( + title, fig=fig, frame=frame, use_existing=False, **kwargs) # Create vectorized function to find axes from lines @@ -98,7 +234,7 @@ def get_plot_axes(line_array): Returns ------- axes_array : array of list of Axes - A 2D array with elements corresponding to the Axes assocated with + A 2D array with elements corresponding to the Axes associated with the lines in `line_array`. Notes @@ -106,8 +242,79 @@ def get_plot_axes(line_array): Only the first element of each array entry is used to determine the axes. """ + warnings.warn("get_plot_axes is deprecated; use cplt.axes", FutureWarning) _get_axes = np.vectorize(lambda lines: lines[0].axes) - return _get_axes(line_array) + if isinstance(line_array, ControlPlot): + return _get_axes(line_array.lines) + else: + return _get_axes(line_array) + + +def pole_zero_subplots( + nrows, ncols, grid=None, dt=None, fig=None, scaling=None, + rcParams=None): + """Create axes for pole/zero plot. + + Parameters + ---------- + nrows, ncols : int + Number of rows and columns. + grid : True, False, or 'empty', optional + Grid style to use. Can also be a list, in which case each subplot + will have a different style (columns then rows). + dt : timebase, option + Timebase for each subplot (or a list of timebases). + scaling : 'auto', 'equal', or None + Scaling to apply to the subplots. + fig : :class:`matplotlib.figure.Figure` + Figure to use for creating subplots. + + Returns + ------- + ax_array : array + 2D array of axes + + """ + from .grid import nogrid, sgrid, zgrid + from .iosys import isctime + + if fig is None: + fig = plt.gcf() + rcParams = config._get_param('ctrlplot', 'rcParams', rcParams) + + if not isinstance(grid, list): + grid = [grid] * nrows * ncols + if not isinstance(dt, list): + dt = [dt] * nrows * ncols + + ax_array = np.full((nrows, ncols), None) + index = 0 + with plt.rc_context(rcParams): + for row, col in itertools.product(range(nrows), range(ncols)): + match grid[index], isctime(dt=dt[index]): + case 'empty', _: # empty grid + ax_array[row, col] = fig.add_subplot(nrows, ncols, index+1) + + case True, True: # continuous time grid + ax_array[row, col], _ = sgrid( + (nrows, ncols, index+1), scaling=scaling) + + case True, False: # discrete time grid + ax_array[row, col] = fig.add_subplot(nrows, ncols, index+1) + zgrid(ax=ax_array[row, col], scaling=scaling) + + case False | None, _: # no grid (just stability boundaries) + ax_array[row, col] = fig.add_subplot(nrows, ncols, index+1) + nogrid( + ax=ax_array[row, col], dt=dt[index], scaling=scaling) + index += 1 + return ax_array + + +def reset_rcParams(): + """Reset rcParams to default values for control plots.""" + _ctrlplot_rcParams.update(rcParams_default) + # # Utility functions @@ -116,10 +323,10 @@ def get_plot_axes(line_array): # of processing and displaying information. # - def _process_ax_keyword( - axs, shape=(1, 1), rcParams=None, squeeze=False, clear_text=False): - """Utility function to process ax keyword to plotting commands. + axs, shape=(1, 1), rcParams=None, squeeze=False, clear_text=False, + create_axes=True): + """Process ax keyword to plotting commands. This function processes the `ax` keyword to plotting commands. If no ax keyword is passed, the current figure is checked to see if it has @@ -127,6 +334,11 @@ def _process_ax_keyword( current figure and axes are returned. Otherwise a new figure is created with axes of the desired shape. + If `create_axes` is False and a new/empty figure is returned, then axs + is an array of the proper shape but None for each element. This allows + the calling function to do the actual axis creation (needed for + curvilinear grids that use the AxisArtist module). + Legacy behavior: some of the older plotting commands use a axes label to identify the proper axes for plotting. This behavior is supported through the use of the label keyword, but will only work if shape == @@ -141,14 +353,19 @@ def _process_ax_keyword( # Note: can't actually check the shape, just the total number of axes if len(axs) != np.prod(shape): with plt.rc_context(rcParams): - if len(axs) != 0: + if len(axs) != 0 and create_axes: # Create a new figure fig, axs = plt.subplots(*shape, squeeze=False) - else: + elif create_axes: # Create new axes on (empty) figure axs = fig.subplots(*shape, squeeze=False) - fig.set_layout_engine('tight') - fig.align_labels() + else: + # Create an empty array and let user create axes + axs = np.full(shape, None) + if create_axes: # if not creating axes, leave these to caller + fig.set_layout_engine('tight') + fig.align_labels() + else: # Use the existing axes, properly reshaped axs = np.asarray(axs).reshape(*shape) @@ -159,8 +376,9 @@ def _process_ax_keyword( text.set_visible(False) # turn off the text del text # get rid of it completely else: + axs = np.atleast_1d(axs) try: - axs = np.asarray(axs).reshape(shape) + axs = axs.reshape(shape) except ValueError: raise ValueError( "specified axes are not the right shape; " @@ -178,14 +396,14 @@ def _process_ax_keyword( # Turn label keyword into array indexed by trace, output, input # TODO: move to ctrlutil.py and update parameter names to reflect general use -def _process_line_labels(label, ntraces, ninputs=0, noutputs=0): +def _process_line_labels(label, ntraces=1, ninputs=0, noutputs=0): if label is None: return None if isinstance(label, str): label = [label] * ntraces # single label for all traces - # Convert to an ndarray, if not done aleady + # Convert to an ndarray, if not done already try: line_labels = np.asarray(label) except ValueError: @@ -212,29 +430,66 @@ def _process_line_labels(label, ntraces, ninputs=0, noutputs=0): # Get labels for all lines in an axes def _get_line_labels(ax, use_color=True): - labels, lines = [], [] + labels_colors, lines = [], [] last_color, counter = None, 0 # label unknown systems for i, line in enumerate(ax.get_lines()): label = line.get_label() + color = line.get_color() if use_color and label.startswith("Unknown"): label = f"Unknown-{counter}" - if last_color is None: - last_color = line.get_color() - elif last_color != line.get_color(): + if last_color != color: counter += 1 - last_color = line.get_color() + last_color = color elif label[0] == '_': continue - if label not in labels: + if (label, color) not in labels_colors: lines.append(line) - labels.append(label) + labels_colors.append((label, color)) - return lines, labels + return lines, [label for label, color in labels_colors] + + +def _process_legend_keywords( + kwargs, shape=None, default_loc='center right'): + legend_loc = kwargs.pop('legend_loc', None) + if shape is None and 'legend_map' in kwargs: + raise TypeError("unexpected keyword argument 'legend_map'") + else: + legend_map = kwargs.pop('legend_map', None) + show_legend = kwargs.pop('show_legend', None) + + # If legend_loc or legend_map were given, always show the legend + if legend_loc is False or legend_map is False: + if show_legend is True: + warnings.warn( + "show_legend ignored; legend_loc or legend_map was given") + show_legend = False + legend_loc = legend_map = None + elif legend_loc is not None or legend_map is not None: + if show_legend is False: + warnings.warn( + "show_legend ignored; legend_loc or legend_map was given") + show_legend = True + + if legend_loc is None: + legend_loc = default_loc + elif not isinstance(legend_loc, (int, str)): + raise ValueError("legend_loc must be string or int") + + # Make sure the legend map is the right size + if legend_map is not None: + legend_map = np.atleast_2d(legend_map) + if legend_map.shape != shape: + raise ValueError("legend_map shape just match axes shape") + + return legend_loc, legend_map, show_legend # Utility function to make legend labels def _make_legend_labels(labels, ignore_common=False): + if len(labels) == 1: + return labels # Look for a common prefix (up to a space) common_prefix = commonprefix(labels) @@ -242,7 +497,7 @@ def _make_legend_labels(labels, ignore_common=False): if last_space < 0 or ignore_common: common_prefix = '' elif last_space > 0: - common_prefix = common_prefix[:last_space] + common_prefix = common_prefix[:last_space + 2] prefix_len = len(common_prefix) # Look for a common suffix (up to a space) @@ -262,8 +517,15 @@ def _make_legend_labels(labels, ignore_common=False): return labels -def _update_suptitle(fig, title, rcParams=None, frame='axes'): - if fig is not None and isinstance(title, str): +def _update_plot_title( + title, fig=None, frame='axes', use_existing=True, **kwargs): + if title is False or title is None: + return + if fig is None: + fig = plt.gcf() + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + + if use_existing: # Get the current title, if it exists old_title = None if fig._suptitle is None else fig._suptitle._text @@ -282,8 +544,19 @@ def _update_suptitle(fig, title, rcParams=None, frame='axes'): separator = ',' if len(common_prefix) > 0 else ';' title = old_title + separator + title[common_len:] - # Add the title - suptitle(title, fig=fig, rcParams=rcParams, frame=frame) + if frame == 'figure': + with plt.rc_context(rcParams): + fig.suptitle(title, **kwargs) + + elif frame == 'axes': + with plt.rc_context(rcParams): + fig.suptitle(title, **kwargs) # Place title in center + plt.tight_layout() # Put everything into place + xc, _ = _find_axes_center(fig, fig.get_axes()) + fig.suptitle(title, x=xc, **kwargs) # Redraw title, centered + + else: + raise ValueError(f"unknown frame '{frame}'") def _find_axes_center(fig, axs): @@ -311,16 +584,16 @@ def _add_arrows_to_line2D( """ Add arrows to a matplotlib.lines.Line2D at selected locations. - Parameters: - ----------- + Parameters + ---------- axes: Axes object as returned by axes command (or gca) line: Line2D object as returned by plot command arrow_locs: list of locations where to insert arrows, % of total length arrowstyle: style of the arrow arrowsize: size of the arrow - Returns: - -------- + Returns + ------- arrows: list of arrows Based on https://stackoverflow.com/questions/26911898/ @@ -382,3 +655,101 @@ def _add_arrows_to_line2D( axes.add_patch(p) arrows.append(p) return arrows + + +def _get_color_offset(ax, color_cycle=None): + """Get color offset based on current lines. + + This function determines that the current offset is for the next color + to use based on current colors in a plot. + + Parameters + ---------- + ax : matplotlib.axes.Axes + Axes containing already plotted lines. + color_cycle : list of matplotlib color specs, optional + Colors to use in plotting lines. Defaults to matplotlib rcParams + color cycle. + + Returns + ------- + color_offset : matplotlib color spec + Starting color for next line to be drawn. + color_cycle : list of matplotlib color specs + Color cycle used to determine colors. + + """ + if color_cycle is None: + color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] + + color_offset = 0 + if len(ax.lines) > 0: + last_color = ax.lines[-1].get_color() + if last_color in color_cycle: + color_offset = color_cycle.index(last_color) + 1 + + return color_offset % len(color_cycle), color_cycle + + +def _get_color( + colorspec, offset=None, fmt=None, ax=None, lines=None, + color_cycle=None): + """Get color to use for plotting line. + + This function returns the color to be used for the line to be drawn (or + None if the detault color cycle for the axes should be used). + + Parameters + ---------- + colorspec : matplotlib color specification + User-specified color (or None). + offset : int, optional + Offset into the color cycle (for multi-trace plots). + fmt : str, optional + Format string passed to plotting command. + ax : matplotlib.axes.Axes, optional + Axes containing already plotted lines. + lines : list of matplotlib.lines.Line2D, optional + List of plotted lines. If not given, use ax.get_lines(). + color_cycle : list of matplotlib color specs, optional + Colors to use in plotting lines. Defaults to matplotlib rcParams + color cycle. + + Returns + ------- + color : matplotlib color spec + Color to use for this line (or None for matplotlib default). + + """ + # See if the color was explicitly specified by the user + if isinstance(colorspec, dict): + if 'color' in colorspec: + return colorspec.pop('color') + elif fmt is not None and \ + [isinstance(arg, str) and + any([c in arg for c in "bgrcmykw#"]) for arg in fmt]: + return None # *fmt will set the color + elif colorspec != None: + return colorspec + + # Figure out what color cycle to use, if not given by caller + if color_cycle == None: + color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] + + # Find the lines that we should pay attention to + if lines is None and ax is not None: + lines = ax.lines + + # If we were passed a set of lines, try to increment color from previous + if offset is not None: + return color_cycle[offset] + elif lines is not None: + color_offset = 0 + if len(ax.lines) > 0: + last_color = ax.lines[-1].get_color() + if last_color in color_cycle: + color_offset = color_cycle.index(last_color) + 1 + color_offset = color_offset % len(color_cycle) + return color_cycle[color_offset] + else: + return None diff --git a/control/descfcn.py b/control/descfcn.py index f52b43a2c..4dce09250 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -13,13 +13,15 @@ """ import math -import numpy as np +from warnings import warn + import matplotlib.pyplot as plt +import numpy as np import scipy -from warnings import warn -from .freqplot import nyquist_response from . import config +from .ctrlplot import ControlPlot +from .freqplot import nyquist_response __all__ = ['describing_function', 'describing_function_plot', 'describing_function_response', 'DescribingFunctionResponse', @@ -378,7 +380,7 @@ def _cost(x): def describing_function_plot( - *sysdata, label="%5.2g @ %-5.2g", **kwargs): + *sysdata, point_label="%5.2g @ %-5.2g", label=None, **kwargs): """describing_function_plot(data, *args, **kwargs) Plot a Nyquist plot with a describing function for a nonlinear system. @@ -399,7 +401,7 @@ def describing_function_plot( Parameters ---------- - data : :class:`~control.DescribingFunctionData` + data : :class:`~control.DescribingFunctionResponse` A describing function response data object created by :func:`~control.describing_function_response`. H : LTI system @@ -418,18 +420,36 @@ def describing_function_plot( If True (default), refine the location of the intersection of the Nyquist curve for the linear system and the describing function to determine the intersection point - label : str, optional + point_label : str, optional Formatting string used to label intersection points on the Nyquist plot. Defaults to "%5.2g @ %-5.2g". Set to `None` to omit labels. + ax : matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified and + the current figure has a single axes, that axes is used. + Otherwise, a new figure is created. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). + **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional + Additional keywords passed to `matplotlib` to specify line properties + for Nyquist curve. Returns ------- - lines : 1D array of Line2D - Arrray of Line2D objects for each line in the plot. The first - element of the array is a list of lines (typically only one) for - the Nyquist plot of the linear I/O styem. The second element of - the array is a list of lines (typically only one) for the - describing function curve. + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects + for each line in the plot. The first element of the array is a + list of lines (typically only one) for the Nyquist plot of the + linear I/O system. The second element of the array is a list + of lines (typically only one) for the describing function + curve. + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + See :class:`ControlPlot` for more detailed information. Examples -------- @@ -442,7 +462,10 @@ def describing_function_plot( # Process keywords warn_nyquist = config._process_legacy_keyword( kwargs, 'warn', 'warn_nyquist', kwargs.pop('warn_nyquist', None)) + point_label = config._process_legacy_keyword( + kwargs, 'label', 'point_label', point_label) + # TODO: update to be consistent with ctrlplot use of `label` if label not in (False, None) and not isinstance(label, str): raise ValueError("label must be formatting string, False, or None") @@ -454,27 +477,35 @@ def describing_function_plot( *sysdata, refine=kwargs.pop('refine', True), warn_nyquist=warn_nyquist) elif len(sysdata) == 1: - dfresp = sysdata[0] + if not isinstance(sysdata[0], DescribingFunctionResponse): + raise TypeError("data must be DescribingFunctionResponse") + else: + dfresp = sysdata[0] else: raise TypeError("1, 3, or 4 position arguments required") + # Don't allow legend keyword arguments + for kw in ['legend_loc', 'legend_map', 'show_legend']: + if kw in kwargs: + raise TypeError(f"unexpected keyword argument '{kw}'") + # Create a list of lines for the output - out = np.empty(2, dtype=object) + lines = np.empty(2, dtype=object) # Plot the Nyquist response - out[0] = dfresp.response.plot(**kwargs)[0] + cfig = dfresp.response.plot(**kwargs) + lines[0] = cfig.lines[0] # Return Nyquist lines for first system # Add the describing function curve to the plot - lines = plt.plot(dfresp.N_vals.real, dfresp.N_vals.imag) - out[1] = lines + lines[1] = plt.plot(dfresp.N_vals.real, dfresp.N_vals.imag) # Label the intersection points - if label: + if point_label: for pos, (a, omega) in zip(dfresp.positions, dfresp.intersections): # Add labels to the intersection points - plt.text(pos.real, pos.imag, label % (a, omega)) + plt.text(pos.real, pos.imag, point_label % (a, omega)) - return out + return ControlPlot(lines, cfig.axes, cfig.figure) # Utility function to figure out whether two line segments intersection diff --git a/control/freqplot.py b/control/freqplot.py index 277de8a54..1c7f794ba 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -19,9 +19,10 @@ from . import config from .bdalg import feedback -from .ctrlplot import _add_arrows_to_line2D, _ctrlplot_rcParams, \ - _find_axes_center, _get_line_labels, _make_legend_labels, \ - _process_ax_keyword, _process_line_labels, _update_suptitle, suptitle +from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _find_axes_center, \ + _get_color, _get_color_offset, _get_line_labels, _make_legend_labels, \ + _process_ax_keyword, _process_legend_keywords, _process_line_labels, \ + _update_plot_title from .ctrlutil import unwrap from .exception import ControlMIMONotImplemented from .frdata import FrequencyResponseData @@ -33,11 +34,10 @@ __all__ = ['bode_plot', 'NyquistResponseData', 'nyquist_response', 'nyquist_plot', 'singular_values_response', 'singular_values_plot', 'gangof4_plot', 'gangof4_response', - 'bode', 'nyquist', 'gangof4'] + 'bode', 'nyquist', 'gangof4', 'FrequencyResponseList'] # Default values for module parameter variables _freqplot_defaults = { - 'freqplot.rcParams': _ctrlplot_rcParams, 'freqplot.feature_periphery_decades': 1, 'freqplot.number_of_samples': 1000, 'freqplot.dB': False, # Plot gain in dB @@ -49,7 +49,7 @@ 'freqplot.share_magnitude': 'row', 'freqplot.share_phase': 'row', 'freqplot.share_frequency': 'col', - 'freqplot.suptitle_frame': 'axes', + 'freqplot.title_frame': 'axes', } # @@ -87,8 +87,7 @@ def bode_plot( plot=None, plot_magnitude=True, plot_phase=None, overlay_outputs=None, overlay_inputs=None, phase_label=None, magnitude_label=None, label=None, display_margins=None, - margins_method='best', legend_map=None, legend_loc=None, - sharex=None, sharey=None, title=None, **kwargs): + margins_method='best', title=None, sharex=None, sharey=None, **kwargs): """Bode plot for a system. Plot the magnitude and phase of the frequency response over a @@ -124,26 +123,51 @@ def bode_plot( Returns ------- - lines : array of Line2D - Array of Line2D objects for each line in the plot. The shape of - the array matches the subplots shape and the value of the array is a - list of Line2D objects in that subplot. + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects + for each line in the plot. The shape of the array matches the + subplots shape and the value of the array is a list of Line2D + objects in that subplot. + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + * cplt.legend: legend object(s) contained in the plot + + See :class:`ControlPlot` for more detailed information. Other Parameters ---------------- - grid : bool + ax : array of matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified, the + axes for the current figure are used or, if there is no current + figure with the correct number and shape of axes, a new figure is + created. The shape of the array must match the shape of the + plotted data. + grid : bool, optional If True, plot grid lines on gain and phase plots. Default is set by `config.defaults['freqplot.grid']`. - initial_phase : float + initial_phase : float, optional Set the reference phase to use for the lowest frequency. If set, the initial phase of the Bode plot will be set to the value closest to the value specified. Units are in either degrees or radians, depending on the `deg` parameter. Default is -180 if wrap_phase is False, 0 if wrap_phase is True. - label : str or array-like of str + label : str or array_like of str, optional If present, replace automatically generated label(s) with the given label(s). If sysdata is a list, strings should be specified for each system. If MIMO, strings required for each system, output, and input. + legend_map : array of str, optional + Location of the legend for multi-axes plots. Specifies an array + of legend location strings matching the shape of the subplots, with + each entry being either None (for no legend) or a legend location + string (see :func:`~matplotlib.pyplot.legend`). + legend_loc : int or str, optional + Include a legend in the given location. Default is 'center right', + with no legend for a single response. Use False to suppress legend. margins_method : str, optional Method to use in computing margins (see :func:`stability_margins`). omega_limits : array_like of two values @@ -161,7 +185,13 @@ def bode_plot( values with no plot. rcParams : dict Override the default parameters used for generating plots. - Default is set by config.default['freqplot.rcParams']. + Default is set by config.default['ctrlplot.rcParams']. + show_legend : bool, optional + Force legend to be shown if ``True`` or hidden if ``False``. If + ``None``, then show legend when there is more than one line on an + axis or ``legend_loc`` or ``legend_map`` has been specified. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). wrap_phase : bool or float If wrap_phase is `False` (default), then the phase will be unwrapped so that it is continuously increasing or decreasing. If wrap_phase is @@ -220,10 +250,9 @@ def bode_plot( 'freqplot', 'wrap_phase', kwargs, _freqplot_defaults, pop=True) initial_phase = config._get_param( 'freqplot', 'initial_phase', kwargs, None, pop=True) - rcParams = config._get_param( - 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) - suptitle_frame = config._get_param( - 'freqplot', 'suptitle_frame', kwargs, _freqplot_defaults, pop=True) + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + title_frame = config._get_param( + 'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True) # Set the default labels freq_label = config._get_param( @@ -459,8 +488,10 @@ def bode_plot( if kw not in kwargs or kwargs[kw] is None: kwargs[kw] = config.defaults['freqplot.' + kw] - fig, ax_array = _process_ax_keyword(ax, ( - nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True) + fig, ax_array = _process_ax_keyword( + ax, (nrows, ncols), squeeze=False, rcParams=rcParams, clear_text=True) + legend_loc, legend_map, show_legend = _process_legend_keywords( + kwargs, (nrows,ncols), 'center right') # Get the values for sharing axes limits share_magnitude = kwargs.pop('share_magnitude', None) @@ -937,13 +968,18 @@ def gen_zero_centered_series(val_min, val_max, period): seen = set() sysnames = [response.sysname for response in data \ if not (response.sysname in seen or seen.add(response.sysname))] - if title is None: + + if ax is None and title is None: if data[0].title is None: title = "Bode plot for " + ", ".join(sysnames) else: + # Allow data to set the title (used by gangof4) title = data[0].title - - _update_suptitle(fig, title, rcParams=rcParams, frame=suptitle_frame) + _update_plot_title(title, fig, rcParams=rcParams, frame=title_frame) + elif ax is None: + _update_plot_title( + title, fig=fig, rcParams=rcParams, frame=title_frame, + use_existing=False) # # Create legends @@ -965,21 +1001,19 @@ def gen_zero_centered_series(val_min, val_max, period): # different response (system). # - # Figure out where to put legends - if legend_map is None: - legend_map = np.full(ax_array.shape, None, dtype=object) - if legend_loc == None: - legend_loc = 'center right' - - # TODO: add in additional processing later - - # Put legend in the upper right - legend_map[0, -1] = legend_loc - # Create axis legends - for i in range(nrows): - for j in range(ncols): + if show_legend != False: + # Figure out where to put legends + if legend_map is None: + legend_map = np.full(ax_array.shape, None, dtype=object) + legend_map[0, -1] = legend_loc + + legend_array = np.full(ax_array.shape, None, dtype=object) + for i, j in itertools.product(range(nrows), range(ncols)): + if legend_map[i, j] is None: + continue ax = ax_array[i, j] + # Get the labels to use, removing common strings lines = [line for line in ax.get_lines() if line.get_label()[0] != '_'] @@ -988,9 +1022,12 @@ def gen_zero_centered_series(val_min, val_max, period): ignore_common=line_labels is not None) # Generate the label, if needed - if len(labels) > 1 and legend_map[i, j] != None: + if show_legend == True or len(labels) > 1: with plt.rc_context(rcParams): - ax.legend(lines, labels, loc=legend_map[i, j]) + legend_array[i, j] = ax.legend( + lines, labels, loc=legend_map[i, j]) + else: + legend_array = None # # Legacy return pocessing @@ -1008,7 +1045,7 @@ def gen_zero_centered_series(val_min, val_max, period): else: return mag_data, phase_data, omega_data - return out + return ControlPlot(out, ax_array, fig, legend=legend_array) # @@ -1447,7 +1484,7 @@ def nyquist_response( def nyquist_plot( data, omega=None, plot=None, label_freq=0, color=None, label=None, - return_contour=None, title=None, legend_loc='upper right', ax=None, + return_contour=None, title=None, ax=None, unit_circle=False, mt_circles=None, ms_circles=None, **kwargs): """Nyquist plot for a system. @@ -1469,8 +1506,6 @@ def nyquist_plot( Set of frequencies to be evaluated, in rad/sec. Specifying ``omega`` as a list of two elements is equivalent to providing ``omega_limits``. - color : string, optional - Used to specify the color of the line and arrowhead. unit_circle : bool, optional If ``True``, display the unit circle, to read gain crossover frequency. mt_circles : array_like, optional @@ -1479,20 +1514,31 @@ def nyquist_plot( Draw circles corresponding to the given magnitudes of complementary sensitivity. **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional - Additional keywords (passed to `matplotlib`) + Additional keywords passed to `matplotlib` to specify line properties. Returns ------- - lines : array of Line2D - 2D array of Line2D objects for each line in the plot. The shape of - the array is given by (nsys, 4) where nsys is the number of systems - or Nyquist responses passed to the function. The second index - specifies the segment type: + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: 2D array of :class:`matplotlib.lines.Line2D` + objects for each line in the plot. The shape of the array is + given by (nsys, 4) where nsys is the number of systems or + Nyquist responses passed to the function. The second index + specifies the segment type: + + - lines[idx, 0]: unscaled portion of the primary curve + - lines[idx, 1]: scaled portion of the primary curve + - lines[idx, 2]: unscaled portion of the mirror curve + - lines[idx, 3]: scaled portion of the mirror curve - * lines[idx, 0]: unscaled portion of the primary curve - * lines[idx, 1]: scaled portion of the primary curve - * lines[idx, 2]: unscaled portion of the mirror curve - * lines[idx, 3]: scaled portion of the mirror curve + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + * cplt.legend: legend object(s) contained in the plot + + See :class:`ControlPlot` for more detailed information. Other Parameters ---------------- @@ -1510,6 +1556,10 @@ def nyquist_plot( 8 and can be set using config.defaults['nyquist.arrow_size']. arrow_style : matplotlib.patches.ArrowStyle, optional Define style used for Nyquist curve arrows (overrides `arrow_size`). + ax : matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified and + the current figure has a single axes, that axes is used. + Otherwise, a new figure is created. encirclement_threshold : float, optional Define the threshold for generating a warning if the number of net encirclements is a non-integer value. Default value is 0.05 and can @@ -1524,13 +1574,16 @@ def nyquist_plot( Amount to indent the Nyquist contour around poles on or near the imaginary axis. Portions of the Nyquist plot corresponding to indented portions of the contour are plotted using a different line style. - label : str or array-like of str + label : str or array_like of str, optional If present, replace automatically generated label(s) with the given label(s). If sysdata is a list, strings should be specified for each system. label_freq : int, optiona Label every nth frequency on the plot. If not specified, no labels are generated. + legend_loc : int or str, optional + Include a legend in the given location. Default is 'upper right', + with no legend for a single response. Use False to suppress legend. max_curve_magnitude : float, optional Restrict the maximum magnitude of the Nyquist plot to this value. Portions of the Nyquist plot whose magnitude is restricted are @@ -1569,6 +1622,10 @@ def nyquist_plot( return_contour : bool, optional (legacy) If 'True', return the encirclement count and Nyquist contour used to generate the Nyquist plot. + show_legend : bool, optional + Force legend to be shown if ``True`` or hidden if ``False``. If + ``None``, then show legend when there is more than one line on the + plot or ``legend_loc`` has been specified. start_marker : str, optional Matplotlib marker to use to mark the starting point of the Nyquist plot. Defaults value is 'o' and can be set using @@ -1576,6 +1633,8 @@ def nyquist_plot( start_marker_size : float, optional Start marker size (in display coordinates). Default value is 4 and can be set using config.defaults['nyquist.start_marker_size']. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). warn_nyquist : bool, optional If set to 'False', turn off warnings about frequencies above Nyquist. warn_encirclements : bool, optional @@ -1637,18 +1696,18 @@ def nyquist_plot( arrow_size = config._get_param( 'nyquist', 'arrow_size', kwargs, _nyquist_defaults, pop=True) arrow_style = config._get_param('nyquist', 'arrow_style', kwargs, None) + ax_user = ax max_curve_magnitude = config._get_param( 'nyquist', 'max_curve_magnitude', kwargs, _nyquist_defaults, pop=True) max_curve_offset = config._get_param( 'nyquist', 'max_curve_offset', kwargs, _nyquist_defaults, pop=True) - rcParams = config._get_param( - 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) start_marker = config._get_param( 'nyquist', 'start_marker', kwargs, _nyquist_defaults, pop=True) start_marker_size = config._get_param( 'nyquist', 'start_marker_size', kwargs, _nyquist_defaults, pop=True) - suptitle_frame = config._get_param( - 'freqplot', 'suptitle_frame', kwargs, _freqplot_defaults, pop=True) + title_frame = config._get_param( + 'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True) # Set line styles for the curves def _parse_linestyle(style_name, allow_false=False): @@ -1731,7 +1790,9 @@ def _parse_linestyle(style_name, allow_false=False): return (counts, contours) if return_contour else counts fig, ax = _process_ax_keyword( - ax, shape=(1, 1), squeeze=True, rcParams=rcParams) + ax_user, shape=(1, 1), squeeze=True, rcParams=rcParams) + legend_loc, _, show_legend = _process_legend_keywords( + kwargs, None, 'upper right') # Create a list of lines for the output out = np.empty(len(nyquist_responses), dtype=object) @@ -1843,7 +1904,7 @@ def _parse_linestyle(style_name, allow_false=False): # Display the unit circle, to read gain crossover frequency if unit_circle: plt.plot(cos, sin, **config.defaults['nyquist.circle_style']) - + # Draw circles for given magnitudes of sensitivity if ms_circles is not None: for ms in ms_circles: @@ -1907,13 +1968,22 @@ def _parse_linestyle(style_name, allow_false=False): lines, labels = _get_line_labels(ax) # Add legend if there is more than one system plotted - if len(labels) > 1: - ax.legend(lines, labels, loc=legend_loc) + if show_legend == True or (show_legend != False and len(labels) > 1): + with plt.rc_context(rcParams): + legend = ax.legend(lines, labels, loc=legend_loc) + else: + legend = None # Add the title - if title is None: - title = "Nyquist plot for " + ", ".join(labels) - suptitle(title, fig=fig, rcParams=rcParams, frame=suptitle_frame) + sysnames = [response.sysname for response in nyquist_responses] + if ax_user is None and title is None: + title = "Nyquist plot for " + ", ".join(sysnames) + _update_plot_title( + title, fig=fig, rcParams=rcParams, frame=title_frame) + elif ax_user is None: + _update_plot_title( + title, fig=fig, rcParams=rcParams, frame=title_frame, + use_existing=False) # Legacy return pocessing if plot is True or return_contour is not None: @@ -1923,7 +1993,7 @@ def _parse_linestyle(style_name, allow_false=False): # Return counts and (optionally) the contour we used return (counts, contours) if return_contour else counts - return out + return ControlPlot(out, ax, fig, legend=legend) # @@ -1999,6 +2069,18 @@ def gangof4_response( Linear input/output systems (process and control). omega : array Range of frequencies (list or bounds) in rad/sec. + omega_limits : array_like of two values + Set limits for plotted frequency range. If Hz=True the limits are + in Hz otherwise in rad/s. Specifying ``omega`` as a list of two + elements is equivalent to providing ``omega_limits``. Ignored if + data is not a list of systems. + omega_num : int + Number of samples to use for the frequeny range. Defaults to + config.defaults['freqplot.number_of_samples']. Ignored if data is + not a list of systems. + Hz : bool, optional + If True, when computing frequency limits automatically set + limits to full decades in Hz instead of rad/s. Returns ------- @@ -2048,15 +2130,76 @@ def gangof4_response( return FrequencyResponseData( data, omega, outputs=['y', 'u'], inputs=['r', 'd'], - title=f"Gang of Four for P={P.name}, C={C.name}", plot_phase=False) + title=f"Gang of Four for P={P.name}, C={C.name}", + sysname=f"P={P.name}, C={C.name}", plot_phase=False) def gangof4_plot( - P, C, omega=None, omega_limits=None, omega_num=None, **kwargs): - """Legacy Gang of 4 plot; use gangof4_response().plot() instead.""" - return gangof4_response( - P, C, omega=omega, omega_limits=omega_limits, - omega_num=omega_num).plot(**kwargs) + *args, omega=None, omega_limits=None, omega_num=None, + Hz=False, **kwargs): + """Plot the response of the "Gang of 4" transfer functions for a system. + + Plots a 2x2 frequency response for the "Gang of 4" sensitivity + functions [T, PS; CS, S]. Can be called in one of two ways: + + gangof4_plot(response[, ...]) + gangof4_plot(P, C[, ...]) + + Parameters + ---------- + response : FrequencyPlotData + Gang of 4 frequency response from `gangof4_response`. + P, C : LTI + Linear input/output systems (process and control). + omega : array + Range of frequencies (list or bounds) in rad/sec. + omega_limits : array_like of two values + Set limits for plotted frequency range. If Hz=True the limits are + in Hz otherwise in rad/s. Specifying ``omega`` as a list of two + elements is equivalent to providing ``omega_limits``. Ignored if + data is not a list of systems. + omega_num : int + Number of samples to use for the frequeny range. Defaults to + config.defaults['freqplot.number_of_samples']. Ignored if data is + not a list of systems. + Hz : bool, optional + If True, when computing frequency limits automatically set + limits to full decades in Hz instead of rad/s. + + Returns + ------- + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: 2x2 array of :class:`matplotlib.lines.Line2D` + objects for each line in the plot. The value of each array + entry is a list of Line2D objects in that subplot. + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + * cplt.legend: legend object(s) contained in the plot + + See :class:`ControlPlot` for more detailed information. + + """ + if len(args) == 1 and isinstance(arg, FrequencyResponseData): + if any([kw is not None + for kw in [omega, omega_limits, omega_num, Hz]]): + raise ValueError( + "omega, omega_limits, omega_num, Hz not allowed when " + "given a Gang of 4 response as first argument") + return args[0].plot(kwargs) + else: + if len(args) > 3: + raise TypeError( + f"expecting 2 or 3 positional arguments; received {len(args)}") + omega = omega if len(args) < 3 else args[2] + args = args[0:2] + return gangof4_response( + *args, omega=omega, omega_limits=omega_limits, + omega_num=omega_num, Hz=Hz).plot(**kwargs) # # Singular values plot @@ -2143,7 +2286,7 @@ def singular_values_response( def singular_values_plot( data, omega=None, *fmt, plot=None, omega_limits=None, omega_num=None, - ax=None, label=None, title=None, legend_loc='center right', **kwargs): + ax=None, label=None, title=None, **kwargs): """Plot the singular values for a system. Plot the singular values as a function of frequency for a system or @@ -2170,29 +2313,37 @@ def singular_values_plot( Returns ------- - legend_loc : str, optional - For plots with multiple lines, a legend will be included in the - given location. Default is 'center right'. Use False to suppress. - lines : array of Line2D - 1-D array of Line2D objects. The size of the array matches - the number of systems and the value of the array is a list of - Line2D objects for that system. - mag : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, magnitude of the response (deprecated). - phase : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, phase in radians of the response (deprecated). - omega : ndarray (or list of ndarray if len(data) > 1)) - If plot=False, frequency in rad/sec (deprecated). + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: 1-D array of :class:`matplotlib.lines.Line2D` objects. + The size of the array matches the number of systems and the + value of the array is a list of Line2D objects for that system. + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + * cplt.legend: legend object(s) contained in the plot + + See :class:`ControlPlot` for more detailed information. Other Parameters ---------------- + ax : matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified and + the current figure has a single axes, that axes is used. + Otherwise, a new figure is created. grid : bool If True, plot grid lines on gain and phase plots. Default is set by `config.defaults['freqplot.grid']`. - label : str or array-like of str + label : str or array_like of str, optional If present, replace automatically generated label(s) with the given label(s). If sysdata is a list, strings should be specified for each system. + legend_loc : int or str, optional + Include a legend in the given location. Default is 'center right', + with no legend for a single response. Use False to suppress legend. omega_limits : array_like of two values Set limits for plotted frequency range. If Hz=True the limits are in Hz otherwise in rad/s. Specifying ``omega`` as a list of two @@ -2208,23 +2359,39 @@ def singular_values_plot( rcParams : dict Override the default parameters used for generating plots. Default is set up config.default['freqplot.rcParams']. + show_legend : bool, optional + Force legend to be shown if ``True`` or hidden if ``False``. If + ``None``, then show legend when there is more than one line on an + axis or ``legend_loc`` or ``legend_map`` has been specified. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). See Also -------- singular_values_response + Notes + ----- + 1. If plot==False, the following legacy values are returned: + * mag : ndarray (or list of ndarray if len(data) > 1)) + Magnitude of the response (deprecated). + * phase : ndarray (or list of ndarray if len(data) > 1)) + Phase in radians of the response (deprecated). + * omega : ndarray (or list of ndarray if len(data) > 1)) + Frequency in rad/sec (deprecated). + """ # Keyword processing + color = kwargs.pop('color', None) dB = config._get_param( 'freqplot', 'dB', kwargs, _freqplot_defaults, pop=True) Hz = config._get_param( 'freqplot', 'Hz', kwargs, _freqplot_defaults, pop=True) grid = config._get_param( 'freqplot', 'grid', kwargs, _freqplot_defaults, pop=True) - rcParams = config._get_param( - 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) - suptitle_frame = config._get_param( - 'freqplot', 'suptitle_frame', kwargs, _freqplot_defaults, pop=True) + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + title_frame = config._get_param( + 'freqplot', 'title_frame', kwargs, _freqplot_defaults, pop=True) # If argument was a singleton, turn it into a tuple data = data if isinstance(data, (list, tuple)) else (data,) @@ -2277,15 +2444,11 @@ def singular_values_plot( fig, ax_sigma = _process_ax_keyword( ax, shape=(1, 1), squeeze=True, rcParams=rcParams) ax_sigma.set_label('control-sigma') # TODO: deprecate? + legend_loc, _, show_legend = _process_legend_keywords( + kwargs, None, 'center right') - # Handle color cycle manually as all singular values - # of the same systems are expected to be of the same color - color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] - color_offset = 0 - if len(ax_sigma.lines) > 0: - last_color = ax_sigma.lines[-1].get_color() - if last_color in color_cycle: - color_offset = color_cycle.index(last_color) + 1 + # Get color offset for first (new) line to be drawn + color_offset, color_cycle = _get_color_offset(ax_sigma) # Create a list of lines for the output out = np.empty(len(data), dtype=object) @@ -2300,14 +2463,13 @@ def singular_values_plot( else: nyq_freq = None - # See if the color was specified, otherwise rotate - if kwargs.get('color', None) or any( - [isinstance(arg, str) and - any([c in arg for c in "bgrcmykw#"]) for arg in fmt]): - color_arg = {} # color set by *fmt, **kwargs - else: - color_arg = {'color': color_cycle[ - (idx_sys + color_offset) % len(color_cycle)]} + # Determine the color to use for this response + color = _get_color( + color, fmt=fmt, offset=color_offset + idx_sys, + color_cycle=color_cycle) + + # To avoid conflict with *fmt, only pass color kw if non-None + color_arg = {} if color is None else {'color': color} # Decide on the system name sysname = response.sysname if response.sysname is not None \ @@ -2347,14 +2509,19 @@ def singular_values_plot( lines, labels = _get_line_labels(ax_sigma) # Add legend if there is more than one system plotted - if len(labels) > 1 and legend_loc is not False: + if show_legend == True or (show_legend != False and len(labels) > 1): with plt.rc_context(rcParams): - ax_sigma.legend(lines, labels, loc=legend_loc) + legend = ax_sigma.legend(lines, labels, loc=legend_loc) + else: + legend = None # Add the title - if title is None: - title = "Singular values for " + ", ".join(labels) - suptitle(title, fig=fig, rcParams=rcParams, frame=suptitle_frame) + if ax is None: + if title is None: + title = "Singular values for " + ", ".join(labels) + _update_plot_title( + title, fig=fig, rcParams=rcParams, frame=title_frame, + use_existing=False) # Legacy return processing if plot is not None: @@ -2363,7 +2530,7 @@ def singular_values_plot( else: return sigmas, omegas - return out + return ControlPlot(out, ax_sigma, fig, legend=legend) # # Utility functions diff --git a/control/grid.py b/control/grid.py index dfe8f9a3e..54a1940c9 100644 --- a/control/grid.py +++ b/control/grid.py @@ -74,7 +74,7 @@ def __call__(self, transform_xy, x1, y1, x2, y2): return lon_min, lon_max, lat_min, lat_max -def sgrid(scaling=None): +def sgrid(subplot=(1, 1, 1), scaling=None): # From matplotlib demos: # https://matplotlib.org/gallery/axisartist/demo_curvelinear_grid.html # https://matplotlib.org/gallery/axisartist/demo_floating_axis.html @@ -101,11 +101,10 @@ def sgrid(scaling=None): # Set up an axes with a specialized grid helper fig = plt.gcf() - ax = SubplotHost(fig, 1, 1, 1, grid_helper=grid_helper) + ax = SubplotHost(fig, *subplot, grid_helper=grid_helper) # make ticklabels of right invisible, and top axis visible. - visible = True - ax.axis[:].major_ticklabels.set_visible(visible) + ax.axis[:].major_ticklabels.set_visible(True) ax.axis[:].major_ticks.set_visible(False) ax.axis[:].invert_ticklabel_direction() ax.axis[:].major_ticklabels.set_color('gray') diff --git a/control/nichols.py b/control/nichols.py index 78b03b315..188b5ec0c 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -18,7 +18,8 @@ import numpy as np from . import config -from .ctrlplot import _get_line_labels, _process_ax_keyword, suptitle +from .ctrlplot import ControlPlot, _get_line_labels, _process_ax_keyword, \ + _process_legend_keywords, _process_line_labels, _update_plot_title from .ctrlutil import unwrap from .freqplot import _default_frequency_range, _freqplot_defaults from .lti import frequency_response @@ -35,7 +36,7 @@ def nichols_plot( data, omega=None, *fmt, grid=None, title=None, ax=None, - legend_loc='upper left', **kwargs): + label=None, **kwargs): """Nichols plot for a system. Plots a Nichols plot for the system over a (optional) frequency range. @@ -52,23 +53,53 @@ def nichols_plot( The `omega` parameter must be present (use omega=None if needed). grid : boolean, optional True if the plot should include a Nichols-chart grid. Default is True. - legend_loc : str, optional - For plots with multiple lines, a legend will be included in the - given location. Default is 'upper left'. Use False to supress. **kwargs : :func:`matplotlib.pyplot.plot` keyword properties, optional Additional keywords passed to `matplotlib` to specify line properties. Returns ------- - lines : array of Line2D - 1-D array of Line2D objects. The size of the array matches - the number of systems and the value of the array is a list of - Line2D objects for that system. + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: 1D array of :class:`matplotlib.lines.Line2D` objects. + The size of the array matches the number of systems and the + value of the array is a list of Line2D objects for that system. + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + * cplt.legend: legend object(s) contained in the plot + + See :class:`ControlPlot` for more detailed information. + + lines : array of Line2D + + Other Parameters + ---------------- + ax : matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified and + the current figure has a single axes, that axes is used. + Otherwise, a new figure is created. + label : str or array_like of str, optional + If present, replace automatically generated label(s) with given + label(s). If sysdata is a list, strings should be specified for each + system. + legend_loc : int or str, optional + Include a legend in the given location. Default is 'upper left', + with no legend for a single response. Use False to suppress legend. + show_legend : bool, optional + Force legend to be shown if ``True`` or hidden if ``False``. If + ``None``, then show legend when there is more than one line on the + plot or ``legend_loc`` has been specified. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). + """ # Get parameter values grid = config._get_param('nichols', 'grid', grid, True) - rcParams = config._get_param( - 'freqplot', 'rcParams', kwargs, _freqplot_defaults, pop=True) + label = _process_line_labels(label) + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # If argument was a singleton, turn it into a list if not isinstance(data, (tuple, list)): @@ -84,6 +115,8 @@ def nichols_plot( raise NotImplementedError("MIMO Nichols plots not implemented") fig, ax_nichols = _process_ax_keyword(ax, rcParams=rcParams, squeeze=True) + legend_loc, _, show_legend = _process_legend_keywords( + kwargs, None, 'upper left') # Create a list of lines for the output out = np.empty(len(data), dtype=object) @@ -99,12 +132,13 @@ def nichols_plot( x = unwrap(np.degrees(phase), 360) y = 20*np.log10(mag) - # Decide on the system name + # Decide on the system name and label sysname = response.sysname if response.sysname is not None \ else f"Unknown-{idx_sys}" + label_ = sysname if label is None else label[idx] # Generate the plot - out[idx] = ax_nichols.plot(x, y, *fmt, label=sysname, **kwargs) + out[idx] = ax_nichols.plot(x, y, *fmt, label=label_, **kwargs) # Label the plot axes plt.xlabel('Phase [deg]') @@ -121,16 +155,20 @@ def nichols_plot( lines, labels = _get_line_labels(ax_nichols) # Add legend if there is more than one system plotted - if len(labels) > 1 and legend_loc is not False: + if show_legend == True or (show_legend != False and len(labels) > 1): with plt.rc_context(rcParams): - ax_nichols.legend(lines, labels, loc=legend_loc) + legend = ax_nichols.legend(lines, labels, loc=legend_loc) + else: + legend = None # Add the title - if title is None: - title = "Nichols plot for " + ", ".join(labels) - suptitle(title, fig=fig, rcParams=rcParams) + if ax is None: + if title is None: + title = "Nichols plot for " + ", ".join(labels) + _update_plot_title( + title, fig=fig, rcParams=rcParams, use_existing=False) - return out + return ControlPlot(out, ax_nichols, fig, legend=legend) def _inner_extents(ax): @@ -162,7 +200,7 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, :doc:`Matplotlib linestyle \ ` ax : matplotlib.axes.Axes, optional - Axes to add grid to. If ``None``, use ``plt.gca()``. + Axes to add grid to. If ``None``, use ``matplotlib.pyplot.gca()``. label_cl_phases: bool, optional If True, closed-loop phase lines will be labelled. diff --git a/control/phaseplot.py b/control/phaseplot.py index c7ccd1d1e..b7a247c45 100644 --- a/control/phaseplot.py +++ b/control/phaseplot.py @@ -36,7 +36,8 @@ from scipy.integrate import odeint from . import config -from .ctrlplot import _add_arrows_to_line2D +from .ctrlplot import ControlPlot, _add_arrows_to_line2D, _get_color, \ + _process_ax_keyword, _update_plot_title from .exception import ControlNotImplemented from .nlsys import NonlinearIOSystem, find_eqpt, input_output_response @@ -52,7 +53,8 @@ def phase_plane_plot( sys, pointdata=None, timedata=None, gridtype=None, gridspec=None, plot_streamlines=True, plot_vectorfield=False, plot_equilpoints=True, - plot_separatrices=True, ax=None, suppress_warnings=False, **kwargs + plot_separatrices=True, ax=None, suppress_warnings=False, title=None, + **kwargs ): """Plot phase plane diagram. @@ -88,18 +90,32 @@ def phase_plane_plot( Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). - color : str + color : matplotlib color spec, optional Plot all elements in the given color (use `plot_={'color': c}` to set the color in one element of the phase plot. - ax : Axes - Use the given axes for the plot instead of creating a new figure. + ax : matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified and + the current figure has a single axes, that axes is used. + Otherwise, a new figure is created. Returns ------- - out : list of list of Artists - out[0] = list of Line2D objects (streamlines and separatrices) - out[1] = Quiver object (vector field arrows) - out[2] = list of Line2D objects (equilibrium points) + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: array of list of :class:`matplotlib.artist.Artist` + objects: + + - lines[0] = list of Line2D objects (streamlines, separatrices). + - lines[1] = Quiver object (vector field arrows). + - lines[2] = list of Line2D objects (equilibrium points). + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + See :class:`ControlPlot` for more detailed information. + Other parameters ---------------- @@ -121,21 +137,23 @@ def phase_plane_plot( in the dict as keywords to :func:`~control.phaseplot.separatrices`. suppress_warnings : bool, optional If set to `True`, suppress warning messages in generating trajectories. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). """ # Process arguments params = kwargs.get('params', None) sys = _create_system(sys, params) pointdata = [-1, 1, -1, 1] if pointdata is None else pointdata + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) # Create axis if needed - if ax is None: - fig, ax = plt.gcf(), plt.gca() - else: - fig = None # don't modify figure + user_ax = ax + fig, ax = _process_ax_keyword(user_ax, squeeze=True, rcParams=rcParams) # Create copy of kwargs for later checking to find unused arguments initial_kwargs = dict(kwargs) + passed_kwargs = False # Utility function to create keyword arguments def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): @@ -146,7 +164,7 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): return new_kwargs # Create list for storing outputs - out = [[], None, None] + out = np.array([[], None, None], dtype=object) # Plot out the main elements if plot_streamlines: @@ -200,12 +218,15 @@ def _create_kwargs(global_kwargs, local_kwargs, **other_kwargs): if initial_kwargs: raise TypeError("unrecognized keywords: ", str(initial_kwargs)) - if fig is not None: - ax.set_title(f"Phase portrait for {sys.name}") + if user_ax is None: + if title is None: + title = f"Phase portrait for {sys.name}" + _update_plot_title(title, use_existing=False, rcParams=rcParams) ax.set_xlabel(sys.state_labels[0]) ax.set_ylabel(sys.state_labels[1]) + plt.tight_layout() - return out + return ControlPlot(out, ax, fig) def vectorfield( @@ -242,9 +263,9 @@ def vectorfield( Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). - color : str + color : matplotlib color spec, optional Plot the vector field in the given color. - ax : Axes + ax : matplotlib.axes.Axes Use the given axes for the plot, otherwise use the current axes. Returns @@ -257,6 +278,9 @@ def vectorfield( If set to `True`, suppress warning messages in generating trajectories. """ + # Process keywords + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + # Get system parameters params = kwargs.pop('params', None) @@ -274,7 +298,7 @@ def vectorfield( xlim, ylim, maxlim = _set_axis_limits(ax, pointdata) # Figure out the color to use - color = _get_color(kwargs, ax) + color = _get_color(kwargs, ax=ax) # Make sure all keyword arguments were processed if check_kwargs and kwargs: @@ -287,9 +311,10 @@ def vectorfield( vfdata[i, :2] = x vfdata[i, 2:] = sys._rhs(0, x, 0) - out = ax.quiver( - vfdata[:, 0], vfdata[:, 1], vfdata[:, 2], vfdata[:, 3], - angles='xy', color=color) + with plt.rc_context(rcParams): + out = ax.quiver( + vfdata[:, 0], vfdata[:, 1], vfdata[:, 2], vfdata[:, 3], + angles='xy', color=color) return out @@ -333,7 +358,7 @@ def streamlines( dict with key 'args' and value given by a tuple (passed to callable). color : str Plot the streamlines in the given color. - ax : Axes + ax : matplotlib.axes.Axes Use the given axes for the plot, otherwise use the current axes. Returns @@ -346,6 +371,9 @@ def streamlines( If set to `True`, suppress warning messages in generating trajectories. """ + # Process keywords + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + # Get system parameters params = kwargs.pop('params', None) @@ -368,7 +396,7 @@ def streamlines( xlim, ylim, maxlim = _set_axis_limits(ax, pointdata) # Figure out the color to use - color = _get_color(kwargs, ax) + color = _get_color(kwargs, ax=ax) # Make sure all keyword arguments were processed if check_kwargs and kwargs: @@ -395,13 +423,12 @@ def streamlines( # Plot the trajectory (if there is one) if traj.shape[1] > 1: - out.append( - ax.plot(traj[0], traj[1], color=color)) - - # Add arrows to the lines at specified intervals - _add_arrows_to_line2D( - ax, out[-1][0], arrow_pos, arrowstyle=arrow_style, dir=1) + with plt.rc_context(rcParams): + out += ax.plot(traj[0], traj[1], color=color) + # Add arrows to the lines at specified intervals + _add_arrows_to_line2D( + ax, out[-1], arrow_pos, arrowstyle=arrow_style, dir=1) return out @@ -440,7 +467,7 @@ def equilpoints( dict with key 'args' and value given by a tuple (passed to callable). color : str Plot the equilibrium points in the given color. - ax : Axes + ax : matplotlib.axes.Axes Use the given axes for the plot, otherwise use the current axes. Returns @@ -448,6 +475,9 @@ def equilpoints( out : list of Line2D objects """ + # Process keywords + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + # Get system parameters params = kwargs.pop('params', None) @@ -475,9 +505,9 @@ def equilpoints( # Plot the equilibrium points out = [] for xeq in equilpts: - out.append( - ax.plot(xeq[0], xeq[1], marker='o', color=color)) - + with plt.rc_context(rcParams): + out.append( + ax.plot(xeq[0], xeq[1], marker='o', color=color)) return out @@ -518,9 +548,13 @@ def separatrices( Parameters to pass to system. For an I/O system, `params` should be a dict of parameters and values. For a callable, `params` should be dict with key 'args' and value given by a tuple (passed to callable). - color : str - Plot the streamlines in the given color. - ax : Axes + color : matplotlib color spec, optional + Plot the separatrics in the given color. If a single color + specification is given, this is used for both stable and unstable + separatrices. If a tuple is given, the first element is used as + the color specification for stable separatrices and the second + elmeent for unstable separatrices. + ax : matplotlib.axes.Axes Use the given axes for the plot, otherwise use the current axes. Returns @@ -533,6 +567,9 @@ def separatrices( If set to `True`, suppress warning messages in generating trajectories. """ + # Process keywords + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) + # Get system parameters params = kwargs.pop('params', None) @@ -582,8 +619,9 @@ def separatrices( out = [] for i, xeq in enumerate(equilpts): # Plot the equilibrium points - out.append( - ax.plot(xeq[0], xeq[1], marker='o', color='k')) + with plt.rc_context(rcParams): + out.append( + ax.plot(xeq[0], xeq[1], marker='o', color='k')) # Figure out the linearization and eigenvectors evals, evecs = np.linalg.eig(sys.linearize(xeq, 0, params=params).A) @@ -623,14 +661,15 @@ def separatrices( # Plot the trajectory (if there is one) if traj.shape[1] > 1: - out.append(ax.plot( - traj[0], traj[1], color=color, linestyle=linestyle)) + with plt.rc_context(rcParams): + out.append(ax.plot( + traj[0], traj[1], color=color, linestyle=linestyle)) # Add arrows to the lines at specified intervals - _add_arrows_to_line2D( - ax, out[-1][0], arrow_pos, arrowstyle=arrow_style, - dir=1) - + with plt.rc_context(rcParams): + _add_arrows_to_line2D( + ax, out[-1][0], arrow_pos, arrowstyle=arrow_style, + dir=1) return out @@ -887,23 +926,7 @@ def _parse_arrow_keywords(kwargs): return arrow_pos, arrow_style -def _get_color(kwargs, ax=None): - if 'color' in kwargs: - return kwargs.pop('color') - - # If we were passed an axis, try to increment color from previous - color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] - if ax is not None: - color_offset = 0 - if len(ax.lines) > 0: - last_color = ax.lines[-1].get_color() - if last_color in color_cycle: - color_offset = color_cycle.index(last_color) + 1 - return color_cycle[color_offset % len(color_cycle)] - else: - return None - - +# TODO: move to ctrlplot? def _create_trajectory( sys, revsys, timepts, X0, params, dir, suppress_warnings=False, gridtype=None, gridspec=None, xlim=None, ylim=None): diff --git a/control/pzmap.py b/control/pzmap.py index c7082db1d..c248cf84a 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -18,7 +18,10 @@ from numpy import cos, exp, imag, linspace, real, sin, sqrt from . import config -from .ctrlplot import _get_line_labels +from .config import _process_legacy_keyword +from .ctrlplot import ControlPlot, _get_color, _get_color_offset, \ + _get_line_labels, _process_ax_keyword, _process_legend_keywords, \ + _process_line_labels, _update_plot_title from .freqplot import _freqplot_defaults from .grid import nogrid, sgrid, zgrid from .iosys import isctime, isdtime @@ -118,13 +121,6 @@ def plot(self, *args, **kwargs): and keywords. """ - # If this is a root locus plot, use rlocus defaults for grid - if self.loci is not None: - from .rlocus import _rlocus_defaults - kwargs = kwargs.copy() - kwargs['grid'] = config._get_param( - 'rlocus', 'grid', kwargs.get('grid', None), _rlocus_defaults) - return pole_zero_plot(self, *args, **kwargs) @@ -176,10 +172,9 @@ def pole_zero_map(sysdata): # https://matplotlib.org/2.0.2/examples/axes_grid/demo_axisline_style.html # https://matplotlib.org/2.0.2/examples/axes_grid/demo_curvelinear_grid.html def pole_zero_plot( - data, plot=None, grid=None, title=None, marker_color=None, - marker_size=None, marker_width=None, legend_loc='upper right', - xlim=None, ylim=None, interactive=None, ax=None, scaling=None, - initial_gain=None, **kwargs): + data, plot=None, grid=None, title=None, color=None, marker_size=None, + marker_width=None, xlim=None, ylim=None, interactive=None, ax=None, + scaling=None, initial_gain=None, label=None, **kwargs): """Plot a pole/zero map for a linear system. If the system data include root loci, a root locus diagram for the @@ -207,14 +202,25 @@ def pole_zero_plot( Returns ------- - lines : array of list of Line2D - Array of Line2D objects for each set of markers in the plot. The - shape of the array is given by (nsys, 2) where nsys is the number - of systems or responses passed to the function. The second index - specifies the pzmap object type: + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects + for each set of markers in the plot. The shape of the array is + given by (`nsys`, 2) where `nsys` is the number of systems or + responses passed to the function. The second index specifies + the pzmap object type: + + - lines[idx, 0]: poles + - lines[idx, 1]: zeros + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. - * lines[idx, 0]: poles - * lines[idx, 1]: zeros + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + * cplt.legend: legend object(s) contained in the plot + + See :class:`ControlPlot` for more detailed information. poles, zeros: list of arrays (legacy) If the `plot` keyword is given, the system poles and zeros @@ -222,47 +228,65 @@ def pole_zero_plot( Other Parameters ---------------- - scaling : str or list, optional - Set the type of axis scaling. Can be 'equal' (default), 'auto', or - a list of the form [xmin, xmax, ymin, ymax]. - title : str, optional - Set the title of the plot. Defaults plot type and system name(s). + ax : matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified and + the current figure has a single axes, that axes is used. + Otherwise, a new figure is created. + color : matplotlib color spec, optional + Specify the color of the markers and lines. + initial_gain : float, optional + If given, the specified system gain will be marked on the plot. + interactive : bool, optional + Turn off interactive mode for root locus plots. + label : str or array_like of str, optional + If present, replace automatically generated label(s) with given + label(s). If data is a list, strings should be specified for each + system. + legend_loc : int or str, optional + Include a legend in the given location. Default is 'upper right', + with no legend for a single response. Use False to suppress legend. marker_color : str, optional Set the color of the markers used for poles and zeros. marker_size : int, optional Set the size of the markers used for poles and zeros. marker_width : int, optional Set the line width of the markers used for poles and zeros. - legend_loc : str, optional - For plots with multiple lines, a legend will be included in the - given location. Default is 'center right'. Use False to supress. + scaling : str or list, optional + Set the type of axis scaling. Can be 'equal' (default), 'auto', or + a list of the form [xmin, xmax, ymin, ymax]. + show_legend : bool, optional + Force legend to be shown if ``True`` or hidden if ``False``. If + ``None``, then show legend when there is more than one line on the + plot or ``legend_loc`` has been specified. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). xlim : list, optional Set the limits for the x axis. ylim : list, optional Set the limits for the y axis. - interactive : bool, optional - Turn off interactive mode for root locus plots. - initial_gain : float, optional - If given, the specified system gain will be marked on the plot. Notes ----- - By default, the pzmap function calls matplotlib.pyplot.axis('equal'), - which means that trying to reset the axis limits may not behave as - expected. To change the axis limits, use the `scaling` keyword of use - matplotlib.pyplot.gca().axis('auto') and then set the axis limits to - the desired values. + 1. By default, the pzmap function calls matplotlib.pyplot.axis('equal'), + which means that trying to reset the axis limits may not behave as + expected. To change the axis limits, use the `scaling` keyword of + use matplotlib.pyplot.gca().axis('auto') and then set the axis + limits to the desired values. + + 2. Pole/zero plots that use the continuous time omega-damping grid do + not work with the ``ax`` keyword argument, due to the way that axes + grids are implemented. The ``grid`` argument must be set to + ``False`` or ``'empty'`` when using the ``ax`` keyword argument. """ # Get parameter values - grid = config._get_param('pzmap', 'grid', grid, _pzmap_defaults) + label = _process_line_labels(label) marker_size = config._get_param('pzmap', 'marker_size', marker_size, 6) marker_width = config._get_param('pzmap', 'marker_width', marker_width, 1.5) - xlim_user, ylim_user = xlim, ylim - freqplot_rcParams = config._get_param( - 'freqplot', 'rcParams', kwargs, _freqplot_defaults, - pop=True, last=True) + user_color = _process_legacy_keyword(kwargs, 'marker_color', 'color', color) + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) user_ax = ax + xlim_user, ylim_user = xlim, ylim # If argument was a singleton, turn it into a tuple if not isinstance(data, (list, tuple)): @@ -303,58 +327,49 @@ def pole_zero_plot( return poles, zeros # Initialize the figure - # TODO: turn into standard utility function (from plotutil.py?) - if user_ax is None: - fig = plt.gcf() - axs = fig.get_axes() - else: - fig = ax.figure - axs = [ax] - - if len(axs) > 1: - # Need to generate a new figure - fig, axs = plt.figure(), [] - - with plt.rc_context(freqplot_rcParams): - if grid and grid != 'empty': - plt.clf() - if all([isctime(dt=response.dt) for response in data]): - ax, fig = sgrid(scaling=scaling) - elif all([isdtime(dt=response.dt) for response in data]): - ax, fig = zgrid(scaling=scaling) - else: - raise ValueError( - "incompatible time bases; don't know how to grid") - # Store the limits for later use - xlim, ylim = ax.get_xlim(), ax.get_ylim() - elif len(axs) == 0: - if grid == 'empty': - # Leave off grid entirely + fig, ax = _process_ax_keyword( + user_ax, rcParams=rcParams, squeeze=True, create_axes=False) + legend_loc, _, show_legend = _process_legend_keywords( + kwargs, None, 'upper right') + + # Make sure there are no remaining keyword arguments + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + + if ax is None: + # Determine what type of grid to use + if rlocus_plot: + from .rlocus import _rlocus_defaults + grid = config._get_param('rlocus', 'grid', grid, _rlocus_defaults) + else: + grid = config._get_param('pzmap', 'grid', grid, _pzmap_defaults) + + # Create the axes with the appropriate grid + with plt.rc_context(rcParams): + if grid and grid != 'empty': + if all([isctime(dt=response.dt) for response in data]): + ax, fig = sgrid(scaling=scaling) + elif all([isdtime(dt=response.dt) for response in data]): + ax, fig = zgrid(scaling=scaling) + else: + raise ValueError( + "incompatible time bases; don't know how to grid") + # Store the limits for later use + xlim, ylim = ax.get_xlim(), ax.get_ylim() + elif grid == 'empty': ax = plt.axes() xlim = ylim = [np.inf, -np.inf] # use data to set limits else: - # draw stability boundary; use first response timebase ax, fig = nogrid(data[0].dt, scaling=scaling) xlim, ylim = ax.get_xlim(), ax.get_ylim() - else: - # Use the existing axes and any grid that is there - ax = axs[0] - - # Store the limits for later use - xlim, ylim = ax.get_xlim(), ax.get_ylim() - - # Issue a warning if the user tried to set the grid type - if grid: - warnings.warn("axis already exists; grid keyword ignored") + else: + # Store the limits for later use + xlim, ylim = ax.get_xlim(), ax.get_ylim() + if grid is not None: + warnings.warn("axis already exists; grid keyword ignored") - # Handle color cycle manually as all root locus segments - # of the same system are expected to be of the same color - color_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color'] - color_offset = 0 - if len(ax.lines) > 0: - last_color = ax.lines[-1].get_color() - if last_color in color_cycle: - color_offset = color_cycle.index(last_color) + 1 + # Get color offset for the next line to be drawn + color_offset, color_cycle = _get_color_offset(ax) # Create a list of lines for the output out = np.empty( @@ -367,32 +382,33 @@ def pole_zero_plot( poles = response.poles zeros = response.zeros - # Get the color to use for this system - if marker_color is None: - color = color_cycle[(color_offset + idx) % len(color_cycle)] - else: - color = marker_color + # Get the color to use for this response + color = _get_color(user_color, offset=color_offset + idx) # Plot the locations of the poles and zeros if len(poles) > 0: - label = response.sysname if response.loci is None else None + if label is None: + label_ = response.sysname if response.loci is None else None + else: + label_ = label[idx] out[idx, 0] = ax.plot( real(poles), imag(poles), marker='x', linestyle='', markeredgecolor=color, markerfacecolor=color, markersize=marker_size, markeredgewidth=marker_width, - label=label) + color=color, label=label_) if len(zeros) > 0: out[idx, 1] = ax.plot( real(zeros), imag(zeros), marker='o', linestyle='', markeredgecolor=color, markerfacecolor='none', - markersize=marker_size, markeredgewidth=marker_width) + markersize=marker_size, markeredgewidth=marker_width, + color=color) # Plot the loci, if present if response.loci is not None: + label_ = response.sysname if label is None else label[idx] for locus in response.loci.transpose(): out[idx, 2] += ax.plot( - real(locus), imag(locus), color=color, - label=response.sysname) + real(locus), imag(locus), color=color, label=label_) # Compute the axis limits to use based on the response resp_xlim, resp_ylim = _compute_root_locus_limits(response) @@ -423,7 +439,7 @@ def pole_zero_plot( lines, labels = _get_line_labels(ax) # Add legend if there is more than one system plotted - if len(labels) > 1 and legend_loc is not False: + if show_legend or len(labels) > 1 and show_legend != False: if response.loci is None: # Use "x o" for the system label, via matplotlib tuple handler from matplotlib.legend_handler import HandlerTuple @@ -436,24 +452,28 @@ def pole_zero_plot( markeredgecolor=pole_line.get_markerfacecolor(), markerfacecolor='none', markersize=marker_size, markeredgewidth=marker_width) - handle = (pole_line, zero_line) - line_tuples.append(handle) + handle = (pole_line, zero_line) + line_tuples.append(handle) - with plt.rc_context(freqplot_rcParams): - ax.legend( + with plt.rc_context(rcParams): + legend = ax.legend( line_tuples, labels, loc=legend_loc, handler_map={tuple: HandlerTuple(ndivide=None)}) else: # Regular legend, with lines - with plt.rc_context(freqplot_rcParams): - ax.legend(lines, labels, loc=legend_loc) + with plt.rc_context(rcParams): + legend = ax.legend(lines, labels, loc=legend_loc) + else: + legend = None # Add the title if title is None: - title = "Pole/zero plot for " + ", ".join(labels) + title = ("Root locus plot for " if rlocus_plot + else "Pole/zero plot for ") + ", ".join(labels) if user_ax is None: - with plt.rc_context(freqplot_rcParams): - fig.suptitle(title) + _update_plot_title( + title, fig, rcParams=rcParams, frame='figure', + use_existing=False) # Add dispather to handle choosing a point on the diagram if interactive: @@ -475,7 +495,7 @@ def _click_dispatcher(event): _mark_root_locus_gain(ax, sys, K) # Display the parameters in the axes title - with plt.rc_context(freqplot_rcParams): + with plt.rc_context(rcParams): ax.set_title(_create_root_locus_label(sys, K, s)) ax.figure.canvas.draw() @@ -489,7 +509,7 @@ def _click_dispatcher(event): else: TypeError("system lists not supported with legacy return values") - return out + return ControlPlot(out, ax, fig, legend=legend) # Utility function to find gain corresponding to a click event diff --git a/control/rlocus.py b/control/rlocus.py index dab21f4ac..95fda3e9a 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -24,6 +24,7 @@ from numpy import array, imag, poly1d, real, vstack, zeros_like from . import config +from .ctrlplot import ControlPlot from .exception import ControlMIMONotImplemented from .iosys import isdtime from .lti import LTI @@ -126,29 +127,58 @@ def root_locus_plot( for continuous time systems, unit circle for discrete time systems. If `empty`, do not draw any additonal lines. Default value is set by config.default['rlocus.grid']. - ax : :class:`matplotlib.axes.Axes` - Axes on which to create root locus plot initial_gain : float, optional Mark the point on the root locus diagram corresponding to the given gain. + color : matplotlib color spec, optional + Specify the color of the markers and lines. Returns ------- - lines : array of list of Line2D - Array of Line2D objects for each set of markers in the plot. The - shape of the array is given by (nsys, 3) where nsys is the number - of systems or responses passed to the function. The second index - specifies the object type: + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: - * lines[idx, 0]: poles - * lines[idx, 1]: zeros - * lines[idx, 2]: loci + * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects + for each set of markers in the plot. The shape of the array is + given by (nsys, 3) where nsys is the number of systems or + responses passed to the function. The second index specifies + the object type: + + - lines[idx, 0]: poles + - lines[idx, 1]: zeros + - lines[idx, 2]: loci + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + See :class:`ControlPlot` for more detailed information. roots, gains : ndarray (legacy) If the `plot` keyword is given, returns the closed-loop root locations, arranged such that each row corresponds to a gain, and the array of gains (same as `gains` keyword argument if provided). + Other Parameters + ---------------- + ax : matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified and + the current figure has a single axes, that axes is used. + Otherwise, a new figure is created. + label : str or array_like of str, optional + If present, replace automatically generated label(s) with the given + label(s). If sysdata is a list, strings should be specified for each + system. + legend_loc : int or str, optional + Include a legend in the given location. Default is 'center right', + with no legend for a single response. Use False to suppress legend. + show_legend : bool, optional + Force legend to be shown if ``True`` or hidden if ``False``. If + ``None``, then show legend when there is more than one line on the + plot or ``legend_loc`` has been specified. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). + Notes ----- The root_locus_plot function calls matplotlib.pyplot.axis('equal'), which @@ -163,9 +193,6 @@ def root_locus_plot( for oldkey in ['kvect', 'k']: gains = config._process_legacy_keyword(kwargs, oldkey, 'gains', gains) - # Set default parameters - grid = config._get_param('rlocus', 'grid', grid, _rlocus_defaults) - if isinstance(sysdata, list) and all( [isinstance(sys, LTI) for sys in sysdata]) or \ isinstance(sysdata, LTI): @@ -188,13 +215,13 @@ def root_locus_plot( return responses.loci, responses.gains # Plot the root loci - out = responses.plot(grid=grid, **kwargs) + cplt = responses.plot(grid=grid, **kwargs) # Legacy processing: return locations of poles and zeros as a tuple if plot is True: return responses.loci, responses.gains - return out + return ControlPlot(cplt.lines, cplt.axes, cplt.figure) def _default_gains(num, den, xlim, ylim): diff --git a/control/sisotool.py b/control/sisotool.py index aca36e2d1..a6b9d468b 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -136,7 +136,7 @@ def sisotool(sys, initial_gain=None, xlim_rlocus=None, ylim_rlocus=None, # ax=fig.axes[1]) ax_rlocus = fig.axes[1] root_locus_map(sys[0, 0]).plot( - xlim=xlim_rlocus, ylim=ylim_rlocus, grid=rlocus_grid, + xlim=xlim_rlocus, ylim=ylim_rlocus, initial_gain=initial_gain, ax=ax_rlocus) if rlocus_grid is False: # Need to generate grid manually, since root_locus_plot() won't diff --git a/control/tests/ctrlplot_test.py b/control/tests/ctrlplot_test.py index 05970bdd1..1baab8761 100644 --- a/control/tests/ctrlplot_test.py +++ b/control/tests/ctrlplot_test.py @@ -1,19 +1,602 @@ # ctrlplot_test.py - test out control plotting utilities # RMM, 27 Jun 2024 +import inspect +import warnings + +import matplotlib.pyplot as plt +import numpy as np import pytest + import control as ct -import matplotlib.pyplot as plt +# List of all plotting functions +resp_plot_fcns = [ + # response function plotting function + (ct.frequency_response, ct.bode_plot), + (ct.frequency_response, ct.nichols_plot), + (ct.singular_values_response, ct.singular_values_plot), + (ct.gangof4_response, ct.gangof4_plot), + (ct.describing_function_response, ct.describing_function_plot), + (None, ct.phase_plane_plot), + (ct.pole_zero_map, ct.pole_zero_plot), + (ct.nyquist_response, ct.nyquist_plot), + (ct.root_locus_map, ct.root_locus_plot), + (ct.initial_response, ct.time_response_plot), + (ct.step_response, ct.time_response_plot), + (ct.impulse_response, ct.time_response_plot), + (ct.forced_response, ct.time_response_plot), + (ct.input_output_response, ct.time_response_plot), +] + +nolabel_plot_fcns = [ct.describing_function_plot, ct.phase_plane_plot] +legacy_plot_fcns = [ct.gangof4_plot] +multiaxes_plot_fcns = [ct.bode_plot, ct.gangof4_plot, ct.time_response_plot] +deprecated_fcns = [ct.phase_plot] + + +# Utility function to make sure legends are OK +def assert_legend(cplt, expected_texts): + # Check to make sure the labels are OK in legend + legend = None + for ax in cplt.axes.flatten(): + legend = ax.get_legend() + if legend is not None: + break + if expected_texts is None: + assert legend is None + else: + assert legend is not None + legend_texts = [entry.get_text() for entry in legend.get_texts()] + assert legend_texts == expected_texts + + +def setup_plot_arguments(resp_fcn, plot_fcn, compute_time_response=True): + # Create some systems to use + sys1 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[1]") + sys1c = ct.rss(2, 1, 1, strictly_proper=True, name="sys[1]_C") + sys2 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[2]") + + # Set up arguments + kwargs = resp_kwargs = plot_kwargs = meth_kwargs = {} + argsc = None + match resp_fcn, plot_fcn: + case ct.describing_function_response, _: + sys1 = ct.tf([1], [1, 2, 2, 1], name="sys[1]") + sys2 = ct.tf([1.1], [1, 2, 2, 1], name="sys[2]") + F = ct.descfcn.saturation_nonlinearity(1) + amp = np.linspace(1, 4, 10) + args1 = (sys1, F, amp) + args2 = (sys2, F, amp) + resp_kwargs = plot_kwargs = {'refine': False} + + case ct.gangof4_response, _: + args1 = (sys1, sys1c) + args2 = (sys2, sys1c) + default_labels = ["P=sys[1]", "P=sys[2]"] + + case ct.frequency_response, ct.nichols_plot: + args1 = (sys1, None) # to allow *fmt in linestyle test + args2 = (sys2, ) + meth_kwargs = {'plot_type': 'nichols'} + + case ct.frequency_response, ct.bode_plot: + args1 = (sys1, None) # to allow *fmt in linestyle test + args2 = (sys2, ) + + case ct.singular_values_response, ct.singular_values_plot: + args1 = (sys1, None) # to allow *fmt in linestyle test + args2 = (sys2, ) + + case ct.root_locus_map, ct.root_locus_plot: + args1 = (sys1, ) + args2 = (sys2, ) + plot_kwargs = {'interactive': False} + + case (ct.forced_response | ct.input_output_response, _): + timepts = np.linspace(1, 10) + U = np.sin(timepts) + if compute_time_response: + args1 = (resp_fcn(sys1, timepts, U), ) + args2 = (resp_fcn(sys2, timepts, U), ) + argsc = (resp_fcn([sys1, sys2], timepts, U), ) + else: + args1 = (sys1, timepts, U) + args2 = (sys2, timepts, U) + argsc = None + + case (ct.impulse_response | ct.initial_response | ct.step_response, _): + if compute_time_response: + args1 = (resp_fcn(sys1), ) + args2 = (resp_fcn(sys2), ) + argsc = (resp_fcn([sys1, sys2]), ) + else: + args1 = (sys1, ) + args2 = (sys2, ) + argsc = ([sys1, sys2], ) + + case _, _: + args1 = (sys1, ) + args2 = (sys2, ) + + return args1, args2, argsc, kwargs, meth_kwargs, plot_kwargs, resp_kwargs + + +# Make sure we didn't miss any plotting functions +def test_find_respplot_functions(): + # Get the list of plotting functions + plot_fcns = {respplot[1] for respplot in resp_plot_fcns} + + # Look through every object in the package + found = 0 + for name, obj in inspect.getmembers(ct): + # Skip anything that is outside of this module + if inspect.getmodule(obj) is not None and \ + not inspect.getmodule(obj).__name__.startswith('control'): + # Skip anything that isn't part of the control package + continue + + # Only look for non-deprecated functions ending in 'plot' + if not inspect.isfunction(obj) or name[-4:] != 'plot' or \ + obj in deprecated_fcns: + continue + + # Make sure that we have this on our list of functions + assert obj in plot_fcns + found += 1 + + assert found == len(plot_fcns) + + +@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) @pytest.mark.usefixtures('mplcleanup') -def test_rcParams(): - sys = ct.rss(2, 2, 2) +def test_plot_ax_processing(resp_fcn, plot_fcn): + # Set up arguments + args, _, _, kwargs, meth_kwargs, plot_kwargs, resp_kwargs = \ + setup_plot_arguments(resp_fcn, plot_fcn, compute_time_response=False) + get_line_color = lambda cplt: cplt.lines.reshape(-1)[0][0].get_color() + match resp_fcn, plot_fcn: + case None, ct.phase_plane_plot: + get_line_color = None + warnings.warn("ct.phase_plane_plot returns nonstandard lines") + + # Call the plot through the response function + if resp_fcn is not None: + resp = resp_fcn(*args, **kwargs, **resp_kwargs) + cplt1 = resp.plot(**kwargs, **meth_kwargs) + else: + # No response function available; just plot the data + cplt1 = plot_fcn(*args, **kwargs, **plot_kwargs) + assert isinstance(cplt1, ct.ControlPlot) + + # Call the plot directly, plotting on top of previous plot + if plot_fcn == ct.time_response_plot: + # Can't call the time_response_plot() with system => reuse data + cplt2 = plot_fcn(resp, **kwargs, **plot_kwargs) + else: + cplt2 = plot_fcn(*args, **kwargs, **plot_kwargs) + assert isinstance(cplt2, ct.ControlPlot) + + # Plot should have landed on top of previous plot, in different colors + assert cplt2.figure == cplt1.figure + assert np.all(cplt2.axes == cplt1.axes) + assert len(cplt2.lines[0]) == len(cplt1.lines[0]) + if get_line_color is not None: + assert get_line_color(cplt2) != get_line_color(cplt1) + + # Pass axes explicitly + if resp_fcn is not None: + cplt3 = resp.plot(**kwargs, **meth_kwargs, ax=cplt1.axes) + else: + cplt3 = plot_fcn(*args, **kwargs, **plot_kwargs, ax=cplt1.axes) + assert cplt3.figure == cplt1.figure + + # Plot should have landed on top of previous plot, in different colors + assert np.all(cplt3.axes == cplt1.axes) + assert len(cplt3.lines[0]) == len(cplt1.lines[0]) + if get_line_color is not None: + assert get_line_color(cplt3) != get_line_color(cplt1) + assert get_line_color(cplt3) != get_line_color(cplt2) + + # + # Plot on a user-contructed figure + # + + # Store modified properties from previous figure + cplt_titlesize = cplt3.figure._suptitle.get_fontsize() + cplt_labelsize = \ + cplt3.axes.reshape(-1)[0].get_yticklabels()[0].get_fontsize() + + # Set up some axes with a known title + fig, axs = plt.subplots(2, 3) + title = "User-constructed figure" + plt.suptitle(title) + titlesize = fig._suptitle.get_fontsize() + assert titlesize != cplt_titlesize + labelsize = axs[0, 0].get_yticklabels()[0].get_fontsize() + assert labelsize != cplt_labelsize + + # Figure out what to pass as the ax keyword + match resp_fcn, plot_fcn: + case _, ct.bode_plot: + ax = [axs[0, 1], axs[1, 1]] + + case ct.gangof4_response, _: + ax = [axs[0, 1], axs[0, 2], axs[1, 1], axs[1, 2]] + + case (ct.forced_response | ct.input_output_response, _): + ax = [axs[0, 1], axs[1, 1]] + + case _, _: + ax = [axs[0, 1]] + + # Call the plotting function, passing the axes + if resp_fcn is not None: + resp = resp_fcn(*args, **kwargs, **resp_kwargs) + cplt4 = resp.plot(**kwargs, **meth_kwargs, ax=ax) + else: + # No response function available; just plot the data + cplt4 = plot_fcn(*args, **kwargs, **plot_kwargs, ax=ax) + + # Check to make sure original settings did not change + assert fig._suptitle.get_text() == title + assert fig._suptitle.get_fontsize() == titlesize + assert ax[0].get_yticklabels()[0].get_fontsize() == labelsize + + # Make sure that docstring documents ax keyword + if plot_fcn not in legacy_plot_fcns: + if plot_fcn in multiaxes_plot_fcns: + assert "ax : array of matplotlib.axes.Axes, optional" \ + in plot_fcn.__doc__ + else: + assert "ax : matplotlib.axes.Axes, optional" in plot_fcn.__doc__ + + +@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) +@pytest.mark.usefixtures('mplcleanup') +def test_plot_label_processing(resp_fcn, plot_fcn): + # Set up arguments + args1, args2, argsc, kwargs, meth_kwargs, plot_kwargs, resp_kwargs = \ + setup_plot_arguments(resp_fcn, plot_fcn) + default_labels = ["sys[1]", "sys[2]"] + expected_labels = ["sys1_", "sys2_"] + match resp_fcn, plot_fcn: + case ct.gangof4_response, _: + default_labels = ["P=sys[1]", "P=sys[2]"] + + if plot_fcn in nolabel_plot_fcns: + pytest.skip(f"labels not implemented for {plot_fcn}") + + # Generate the first plot, with default labels + cplt1 = plot_fcn(*args1, **kwargs, **plot_kwargs) + assert isinstance(cplt1, ct.ControlPlot) + assert_legend(cplt1, None) + + # Generate second plot with default labels + cplt2 = plot_fcn(*args2, **kwargs, **plot_kwargs) + assert isinstance(cplt2, ct.ControlPlot) + assert_legend(cplt2, default_labels) + plt.close() + + # Generate both plots at the same time + if len(args1) == 1 and plot_fcn != ct.time_response_plot: + cplt = plot_fcn([*args1, *args2], **kwargs, **plot_kwargs) + assert isinstance(cplt, ct.ControlPlot) + assert_legend(cplt, default_labels) + elif len(args1) == 1 and plot_fcn == ct.time_response_plot: + # Use TimeResponseList.plot() to generate combined response + cplt = argsc[0].plot(**kwargs, **meth_kwargs) + assert isinstance(cplt, ct.ControlPlot) + assert_legend(cplt, default_labels) + plt.close() + + # Generate plots sequentially, with updated labels + cplt1 = plot_fcn( + *args1, **kwargs, **plot_kwargs, label=expected_labels[0]) + assert isinstance(cplt1, ct.ControlPlot) + assert_legend(cplt1, None) + + cplt2 = plot_fcn( + *args2, **kwargs, **plot_kwargs, label=expected_labels[1]) + assert isinstance(cplt2, ct.ControlPlot) + assert_legend(cplt2, expected_labels) + plt.close() + + # Generate both plots at the same time, with updated labels + if len(args1) == 1 and plot_fcn != ct.time_response_plot: + cplt = plot_fcn( + [*args1, *args2], **kwargs, **plot_kwargs, + label=expected_labels) + assert isinstance(cplt, ct.ControlPlot) + assert_legend(cplt, expected_labels) + elif len(args1) == 1 and plot_fcn == ct.time_response_plot: + # Use TimeResponseList.plot() to generate combined response + cplt = argsc[0].plot( + **kwargs, **meth_kwargs, label=expected_labels) + assert isinstance(cplt, ct.ControlPlot) + assert_legend(cplt, expected_labels) + plt.close() + + # Make sure that docstring documents label + if plot_fcn not in legacy_plot_fcns: + assert "label : str or array_like of str, optional" in plot_fcn.__doc__ + + +@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) +@pytest.mark.usefixtures('mplcleanup') +def test_plot_linestyle_processing(resp_fcn, plot_fcn): + # Create some systems to use + sys1 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[1]") + sys1c = ct.rss(4, 1, 1, strictly_proper=True, name="sys[1]_C") + sys2 = ct.rss(4, 1, 1, strictly_proper=True, name="sys[2]") + + # Set up arguments + args1, args2, _, kwargs, meth_kwargs, plot_kwargs, resp_kwargs = \ + setup_plot_arguments(resp_fcn, plot_fcn) + default_labels = ["sys[1]", "sys[2]"] + expected_labels = ["sys1_", "sys2_"] + match resp_fcn, plot_fcn: + case ct.gangof4_response, _: + default_labels = ["P=sys[1]", "P=sys[2]"] + + # Set line color + cplt1 = plot_fcn(*args1, **kwargs, **plot_kwargs, color='r') + assert cplt1.lines.reshape(-1)[0][0].get_color() == 'r' + + # Second plot, new line color + cplt2 = plot_fcn(*args2, **kwargs, **plot_kwargs, color='g') + assert cplt2.lines.reshape(-1)[0][0].get_color() == 'g' + + # Make sure that docstring documents line properties + if plot_fcn not in legacy_plot_fcns: + assert "line properties" in plot_fcn.__doc__ or \ + "color : matplotlib color spec, optional" in plot_fcn.__doc__ + + # Set other characteristics if documentation says we can + if "line properties" in plot_fcn.__doc__: + cplt = plot_fcn(*args1, **kwargs, **plot_kwargs, linewidth=5) + assert cplt.lines.reshape(-1)[0][0].get_linewidth() == 5 + + # If fmt string is allowed, use it to set line color and style + if "*fmt" in plot_fcn.__doc__: + cplt = plot_fcn(*args1, 'r--', **kwargs, **plot_kwargs) + assert cplt.lines.reshape(-1)[0][0].get_color() == 'r' + assert cplt.lines.reshape(-1)[0][0].get_linestyle() == '--' + + +@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) +@pytest.mark.usefixtures('mplcleanup') +def test_siso_plot_legend_processing(resp_fcn, plot_fcn): + # Set up arguments + args1, args2, argsc, kwargs, meth_kwargs, plot_kwargs, resp_kwargs = \ + setup_plot_arguments(resp_fcn, plot_fcn) + default_labels = ["sys[1]", "sys[2]"] + match resp_fcn, plot_fcn: + case ct.gangof4_response, _: + # Multi-axes plot => test in next function + return + + if plot_fcn in nolabel_plot_fcns: + # Make sure that using legend keywords generates an error + with pytest.raises(TypeError, match="unexpected|unrecognized"): + cplt = plot_fcn(*args1, legend_loc=None) + with pytest.raises(TypeError, match="unexpected|unrecognized"): + cplt = plot_fcn(*args1, legend_map=None) + with pytest.raises(TypeError, match="unexpected|unrecognized"): + cplt = plot_fcn(*args1, show_legend=None) + return + + # Single system, with forced legend + cplt = plot_fcn(*args1, **kwargs, **plot_kwargs, show_legend=True) + assert_legend(cplt, default_labels[:1]) + plt.close() + + # Single system, with forced location + cplt = plot_fcn(*args1, **kwargs, **plot_kwargs, legend_loc=10) + assert cplt.axes[0, 0].get_legend()._loc == 10 + plt.close() + + # Generate two plots, but turn off legends + if len(args1) == 1 and plot_fcn != ct.time_response_plot: + cplt = plot_fcn( + [*args1, *args2], **kwargs, **plot_kwargs, show_legend=False) + assert_legend(cplt, None) + elif len(args1) == 1 and plot_fcn == ct.time_response_plot: + # Use TimeResponseList.plot() to generate combined response + cplt = argsc[0].plot(**kwargs, **meth_kwargs, show_legend=False) + assert_legend(cplt, None) + plt.close() + + # Make sure that docstring documents legend_loc, show_legend + assert "legend_loc : int or str, optional" in plot_fcn.__doc__ + assert "show_legend : bool, optional" in plot_fcn.__doc__ + + # Make sure that single axes plots generate an error with legend_map + if plot_fcn not in multiaxes_plot_fcns: + with pytest.raises(TypeError, match="unexpected"): + cplt = plot_fcn(*args1, legend_map=False) + else: + assert "legend_map : array of str" in plot_fcn.__doc__ + + +@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) +@pytest.mark.usefixtures('mplcleanup') +def test_mimo_plot_legend_processing(resp_fcn, plot_fcn): + # Generate the response that we will use for plotting + match resp_fcn, plot_fcn: + case ct.frequency_response, ct.bode_plot: + resp = ct.frequency_response([ct.rss(4, 2, 2), ct.rss(3, 2, 2)]) + case ct.step_response, ct.time_response_plot: + resp = ct.step_response([ct.rss(4, 2, 2), ct.rss(3, 2, 2)]) + case ct.gangof4_response, ct.gangof4_plot: + resp = ct.gangof4_response(ct.rss(4, 1, 1), ct.rss(3, 1, 1)) + case _, ct.time_response_plot: + # Skip remaining time response plots to avoid duplicate tests + return + case _, _: + # Skip everything else that doesn't support multi-axes plots + assert plot_fcn not in multiaxes_plot_fcns + return + + # Generate a standard plot with legend in the center + cplt1 = resp.plot(legend_loc=10) + assert cplt1.axes.ndim == 2 + for legend_idx, ax in enumerate(cplt1.axes.flatten()): + if ax.get_legend() is not None: + break; + assert legend_idx != 0 # Make sure legend is not in first subplot + assert ax.get_legend()._loc == 10 + plt.close() + + # Regenerate the plot with no legend + cplt2 = resp.plot(show_legend=False) + for ax in cplt2.axes.flatten(): + if ax.get_legend() is not None: + break; + assert ax.get_legend() is None + plt.close() + + # Regenerate the plot with no legend in a different way + cplt2 = resp.plot(legend_loc=False) + for ax in cplt2.axes.flatten(): + if ax.get_legend() is not None: + break; + assert ax.get_legend() is None + plt.close() + + # Regenerate the plot with no legend in a different way + cplt2 = resp.plot(legend_map=False) + for ax in cplt2.axes.flatten(): + if ax.get_legend() is not None: + break; + assert ax.get_legend() is None + plt.close() + + # Put the legend in a different (first) subplot + legend_map = np.full(cplt2.shape, None, dtype=object) + legend_map[0, 0] = 5 + legend_map[-1, -1] = 6 + cplt3 = resp.plot(legend_map=legend_map) + assert cplt3.axes[0, 0].get_legend()._loc == 5 + assert cplt3.axes[-1, -1].get_legend()._loc == 6 + plt.close() + + +@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) +@pytest.mark.usefixtures('mplcleanup') +def test_plot_title_processing(resp_fcn, plot_fcn): + # Create some systems to use + sys1 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[1]") + sys1c = ct.rss(4, 1, 1, strictly_proper=True, name="sys[1]_C") + sys2 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[2]") + + # Set up arguments + args1, args2, argsc, kwargs, meth_kwargs, plot_kwargs, resp_kwargs = \ + setup_plot_arguments(resp_fcn, plot_fcn) + default_title = "sys[1], sys[2]" + expected_title = "sys1_, sys2_" + match resp_fcn, plot_fcn: + case ct.gangof4_response, _: + default_title = "P=sys[1], C=sys[1]_C, P=sys[2], C=sys[1]_C" + + # Store the expected title prefix + match resp_fcn, plot_fcn: + case _, ct.bode_plot: + title_prefix = "Bode plot for " + case _, ct.nichols_plot: + title_prefix = "Nichols plot for " + case _, ct.singular_values_plot: + title_prefix = "Singular values for " + case _, ct.gangof4_plot: + title_prefix = "Gang of Four for " + case _, ct.describing_function_plot: + title_prefix = "Nyquist plot for " + case _, ct.phase_plane_plot: + title_prefix = "Phase portrait for " + case _, ct.pole_zero_plot: + title_prefix = "Pole/zero plot for " + case _, ct.nyquist_plot: + title_prefix = "Nyquist plot for " + case _, ct.root_locus_plot: + title_prefix = "Root locus plot for " + case ct.initial_response, _: + title_prefix = "Initial response for " + case ct.step_response, _: + title_prefix = "Step response for " + case ct.impulse_response, _: + title_prefix = "Impulse response for " + case ct.forced_response, _: + title_prefix = "Forced response for " + case ct.input_output_response, _: + title_prefix = "Input/output response for " + case _: + raise RuntimeError(f"didn't recognize {resp_fnc}, {plot_fnc}") + + # Generate the first plot, with default title + cplt1 = plot_fcn(*args1, **kwargs, **plot_kwargs) + assert cplt1.figure._suptitle._text.startswith(title_prefix) + + # Skip functions not intended for sequential calling + if plot_fcn not in nolabel_plot_fcns: + # Generate second plot with default title + cplt2 = plot_fcn(*args2, **kwargs, **plot_kwargs) + assert cplt1.figure._suptitle._text == title_prefix + default_title + plt.close() + + # Generate both plots at the same time + if len(args1) == 1 and plot_fcn != ct.time_response_plot: + cplt = plot_fcn([*args1, *args2], **kwargs, **plot_kwargs) + assert cplt.figure._suptitle._text == title_prefix + default_title + elif len(args1) == 1 and plot_fcn == ct.time_response_plot: + # Use TimeResponseList.plot() to generate combined response + cplt = argsc[0].plot(**kwargs, **meth_kwargs) + assert cplt.figure._suptitle._text == title_prefix + default_title + plt.close() + + # Generate plots sequentially, with updated titles + cplt1 = plot_fcn( + *args1, **kwargs, **plot_kwargs, title="My first title") + cplt2 = plot_fcn( + *args2, **kwargs, **plot_kwargs, title="My new title") + assert cplt2.figure._suptitle._text == "My new title" + plt.close() + + # Update using set_plot_title + cplt2.set_plot_title("Another title") + assert cplt2.figure._suptitle._text == "Another title" + plt.close() + + # Generate the plots with no title + cplt = plot_fcn( + *args1, **kwargs, **plot_kwargs, title=False) + assert cplt.figure._suptitle == None + plt.close() + + # Make sure that docstring documents title + if plot_fcn not in legacy_plot_fcns: + assert "title : str, optional" in plot_fcn.__doc__ + + +@pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) +@pytest.mark.usefixtures('mplcleanup', 'editsdefaults') +def test_rcParams(resp_fcn, plot_fcn): + # Create some systems to use + sys1 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[1]") + sys1c = ct.rss(4, 1, 1, strictly_proper=True, name="sys[1]_C") + sys2 = ct.rss(2, 1, 1, strictly_proper=True, name="sys[2]") + + # Set up arguments + args1, args2, argsc, kwargs, meth_kwargs, plot_kwargs, resp_kwargs = \ + setup_plot_arguments(resp_fcn, plot_fcn) + default_title = "sys[1], sys[2]" + expected_title = "sys1_, sys2_" + match resp_fcn, plot_fcn: + case ct.gangof4_response, _: + default_title = "P=sys[1], C=sys[1]_C, P=sys[2], C=sys[1]_C" # Create new set of rcParams my_rcParams = {} - for key in [ - 'axes.labelsize', 'axes.titlesize', 'figure.titlesize', - 'legend.fontsize', 'xtick.labelsize', 'ytick.labelsize']: + for key in ct.ctrlplot.rcParams: match plt.rcParams[key]: case 8 | 9 | 10: my_rcParams[key] = plt.rcParams[key] + 1 @@ -23,20 +606,186 @@ def test_rcParams(): my_rcParams[key] = 9.5 case _: raise ValueError(f"unknown rcParam type for {key}") + checked_params = my_rcParams.copy() # make sure we check everything # Generate a figure with the new rcParams - out = ct.step_response(sys).plot(rcParams=my_rcParams) - ax = out[0, 0][0].axes - fig = ax.figure + if plot_fcn not in nolabel_plot_fcns: + cplt = plot_fcn( + *args1, **kwargs, **plot_kwargs, rcParams=my_rcParams, + show_legend=True) + else: + cplt = plot_fcn(*args1, **kwargs, **plot_kwargs, rcParams=my_rcParams) + + # Check lower left figure (should always have ticks, labels) + ax, fig = cplt.axes[-1, 0], cplt.figure # Check to make sure new settings were used assert ax.xaxis.get_label().get_fontsize() == my_rcParams['axes.labelsize'] assert ax.yaxis.get_label().get_fontsize() == my_rcParams['axes.labelsize'] + checked_params.pop('axes.labelsize') + + assert ax.title.get_fontsize() == my_rcParams['axes.titlesize'] + checked_params.pop('axes.titlesize') + + assert ax.get_xticklabels()[0].get_fontsize() == \ + my_rcParams['xtick.labelsize'] + checked_params.pop('xtick.labelsize') + + assert ax.get_yticklabels()[0].get_fontsize() == \ + my_rcParams['ytick.labelsize'] + checked_params.pop('ytick.labelsize') + + assert fig._suptitle.get_fontsize() == my_rcParams['figure.titlesize'] + checked_params.pop('figure.titlesize') + + if plot_fcn not in nolabel_plot_fcns: + for ax in cplt.axes.flatten(): + legend = ax.get_legend() + if legend is not None: + break + assert legend is not None + assert legend.get_texts()[0].get_fontsize() == \ + my_rcParams['legend.fontsize'] + checked_params.pop('legend.fontsize') + + # Make sure we checked everything + assert not checked_params + plt.close() + + # Change the default rcParams + ct.ctrlplot.rcParams.update(my_rcParams) + if plot_fcn not in nolabel_plot_fcns: + cplt = plot_fcn( + *args1, **kwargs, **plot_kwargs, show_legend=True) + else: + cplt = plot_fcn(*args1, **kwargs, **plot_kwargs) + + # Check everything + ax, fig = cplt.axes[-1, 0], cplt.figure + assert ax.xaxis.get_label().get_fontsize() == my_rcParams['axes.labelsize'] + assert ax.yaxis.get_label().get_fontsize() == my_rcParams['axes.labelsize'] assert ax.title.get_fontsize() == my_rcParams['axes.titlesize'] assert ax.get_xticklabels()[0].get_fontsize() == \ my_rcParams['xtick.labelsize'] assert ax.get_yticklabels()[0].get_fontsize() == \ my_rcParams['ytick.labelsize'] assert fig._suptitle.get_fontsize() == my_rcParams['figure.titlesize'] + if plot_fcn not in nolabel_plot_fcns: + for ax in cplt.axes.flatten(): + legend = ax.get_legend() + if legend is not None: + break + assert legend is not None + assert legend.get_texts()[0].get_fontsize() == \ + my_rcParams['legend.fontsize'] + plt.close() + + # Make sure that resetting parameters works correctly + ct.reset_defaults() + for key in ct.ctrlplot.rcParams: + assert ct.defaults['ctrlplot.rcParams'][key] != my_rcParams[key] + assert ct.ctrlplot.rcParams[key] != my_rcParams[key] + + +def test_deprecation_warnings(): + sys = ct.rss(2, 2, 2) + lines = ct.step_response(sys).plot(overlay_traces=True) + with pytest.warns(FutureWarning, match="deprecated"): + assert len(lines[0, 0]) == 2 + + cplt = ct.step_response(sys).plot() + with pytest.warns(FutureWarning, match="deprecated"): + axs = ct.get_plot_axes(cplt) + assert np.all(axs == cplt.axes) + + with pytest.warns(FutureWarning, match="deprecated"): + axs = ct.get_plot_axes(cplt.lines) + assert np.all(axs == cplt.axes) + + with pytest.warns(FutureWarning, match="deprecated"): + ct.suptitle("updated title") + assert cplt.figure._suptitle.get_text() == "updated title" + + +def test_ControlPlot_init(): + sys = ct.rss(2, 2, 2) + cplt = ct.step_response(sys).plot() + + # Create a ControlPlot from data, without the axes or figure + cplt_raw = ct.ControlPlot(cplt.lines) + assert np.all(cplt_raw.lines == cplt.lines) + assert np.all(cplt_raw.axes == cplt.axes) + assert cplt_raw.figure == cplt.figure + + +def test_pole_zero_subplots(savefig=False): + ax_array = ct.pole_zero_subplots(2, 1, grid=[True, False]) + sys1 = ct.tf([1, 2], [1, 2, 3], name='sys1') + sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='sys2') + ct.root_locus_plot([sys1, sys2], ax=ax_array[0, 0]) + cplt = ct.root_locus_plot([sys1, sys2], ax=ax_array[1, 0]) + with pytest.warns(UserWarning, match="Tight layout not applied"): + cplt.set_plot_title("Root locus plots (w/ specified axes)") + if savefig: + plt.savefig("ctrlplot-pole_zero_subplots.png") + + # Single type of of grid for all axes + ax_array = ct.pole_zero_subplots(2, 2, grid='empty') + assert ax_array[0, 0].xaxis.get_label().get_text() == '' + + # Discrete system grid + ax_array = ct.pole_zero_subplots(2, 2, grid=True, dt=1) + assert ax_array[0, 0].xaxis.get_label().get_text() == 'Real' + assert ax_array[0, 0].get_lines()[0].get_color() == 'grey' + + +if __name__ == "__main__": + # + # Interactive mode: generate plots for manual viewing + # + # Running this script in python (or better ipython) will show a + # collection of figures that should all look OK on the screeen. + # + + # In interactive mode, turn on ipython interactive graphics + plt.ion() + + # Start by clearing existing figures + plt.close('all') + + # + # Combination plot + # + + P = ct.tf([0.02], [1, 0.1, 0.01]) # servomechanism + C1 = ct.tf([1, 1], [1, 0]) # unstable + L1 = P * C1 + C2 = ct.tf([1, 0.05], [1, 0]) # stable + L2 = P * C2 + + plt.rcParams.update(ct.rcParams) + fig = plt.figure(figsize=[7, 4]) + ax_mag = fig.add_subplot(2, 2, 1) + ax_phase = fig.add_subplot(2, 2, 3) + ax_nyquist = fig.add_subplot(1, 2, 2) + + ct.bode_plot( + [L1, L2], ax=[ax_mag, ax_phase], + label=["$L_1$ (unstable)", "$L_2$ (unstable)"], + show_legend=False) + ax_mag.set_title("Bode plot for $L_1$, $L_2$") + ax_mag.tick_params(labelbottom=False) + fig.align_labels() + + ct.nyquist_plot(L1, ax=ax_nyquist, label="$L_1$ (unstable)") + ct.nyquist_plot( + L2, ax=ax_nyquist, label="$L_2$ (stable)", + max_curve_magnitude=22, legend_loc='upper right') + ax_nyquist.set_title("Nyquist plot for $L_1$, $L_2$") + fig.suptitle("Loop analysis for servomechanism control design") + plt.tight_layout() + plt.savefig('ctrlplot-servomech.png') + plt.figure() + test_pole_zero_subplots(savefig=True) diff --git a/control/tests/descfcn_test.py b/control/tests/descfcn_test.py index ceeff1123..a5f7a06c2 100644 --- a/control/tests/descfcn_test.py +++ b/control/tests/descfcn_test.py @@ -7,14 +7,15 @@ """ -import pytest +import math +import matplotlib.pyplot as plt import numpy as np +import pytest + import control as ct -import math -import matplotlib.pyplot as plt -from control.descfcn import saturation_nonlinearity, \ - friction_backlash_nonlinearity, relay_hysteresis_nonlinearity +from control.descfcn import friction_backlash_nonlinearity, \ + relay_hysteresis_nonlinearity, saturation_nonlinearity # Static function via a class @@ -187,13 +188,13 @@ def test_describing_function_plot(): assert len(response.intersections) == 1 assert len(plt.gcf().get_axes()) == 0 # make sure there is no plot - out = response.plot() + cplt = response.plot() assert len(plt.gcf().get_axes()) == 1 # make sure there is a plot - assert len(out[0]) == 4 and len(out[1]) == 1 + assert len(cplt.lines[0]) == 4 and len(cplt.lines[1]) == 1 # Call plot directly - out = ct.describing_function_plot(H_larger, F_saturation, amp, omega) - assert len(out[0]) == 4 and len(out[1]) == 1 + cplt = ct.describing_function_plot(H_larger, F_saturation, amp, omega) + assert len(cplt.lines[0]) == 4 and len(cplt.lines[1]) == 1 def test_describing_function_exceptions(): @@ -231,3 +232,8 @@ def test_describing_function_exceptions(): with pytest.raises(AttributeError, match="no property|unexpected keyword"): response = ct.describing_function_response(H_simple, F_saturation, amp) response.plot(unknown=None) + + # Describing function plot for non-describing function object + resp = ct.frequency_response(H_simple) + with pytest.raises(TypeError, match="data must be DescribingFunction"): + cplt = ct.describing_function_plot(resp) diff --git a/control/tests/freqplot_test.py b/control/tests/freqplot_test.py index f7105cb96..4b98167d8 100644 --- a/control/tests/freqplot_test.py +++ b/control/tests/freqplot_test.py @@ -1,13 +1,14 @@ # freqplot_test.py - test out frequency response plots # RMM, 23 Jun 2023 -import pytest -import control as ct import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np +import pytest + +import control as ct +from control.tests.conftest import editsdefaults, slycotonly -from control.tests.conftest import slycotonly, editsdefaults pytestmark = pytest.mark.usefixtures("mplcleanup") # @@ -61,7 +62,7 @@ def test_response_plots( ovlout, ovlinp, clear=True): # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') # Save up the keyword arguments kwargs = dict( @@ -82,21 +83,22 @@ def test_response_plots( # Plot the frequency response plt.figure() - out = response.plot(**kwargs) + cplt = response.plot(**kwargs) # Check the shape if ovlout and ovlinp: - assert out.shape == (pltmag + pltphs, 1) + assert cplt.lines.shape == (pltmag + pltphs, 1) elif ovlout: - assert out.shape == (pltmag + pltphs, sys.ninputs) + assert cplt.lines.shape == (pltmag + pltphs, sys.ninputs) elif ovlinp: - assert out.shape == (sys.noutputs * (pltmag + pltphs), 1) + assert cplt.lines.shape == (sys.noutputs * (pltmag + pltphs), 1) else: - assert out.shape == (sys.noutputs * (pltmag + pltphs), sys.ninputs) + assert cplt.lines.shape == \ + (sys.noutputs * (pltmag + pltphs), sys.ninputs) # Make sure all of the outputs are of the right type nlines_plotted = 0 - for ax_lines in np.nditer(out, flags=["refs_ok"]): + for ax_lines in np.nditer(cplt.lines, flags=["refs_ok"]): for line in ax_lines.item() or []: assert isinstance(line, mpl.lines.Line2D) nlines_plotted += 1 @@ -124,13 +126,12 @@ def test_response_plots( assert len(ax.get_lines()) > 1 # Update the title so we can see what is going on - fig = out[0, 0][0].axes.figure - ct.suptitle( - fig._suptitle._text + + cplt.set_plot_title( + cplt.figure._suptitle._text + f" [{sys.noutputs}x{sys.ninputs}, pm={pltmag}, pp={pltphs}," f" sm={shrmag}, sp={shrphs}, sf={shrfrq}]", # TODO: ", " # f"oo={ovlout}, oi={ovlinp}, ss={secsys}]", # TODO: add back - frame='figure', fontsize='small') + frame='figure') # Get rid of the figure to free up memory if clear: @@ -140,8 +141,8 @@ def test_response_plots( # Use the manaul response to verify that different settings are working def test_manual_response_limits(): # Default response: limits should be the same across rows - out = manual_response.plot() - axs = ct.get_plot_axes(out) + cplt = manual_response.plot() + axs = cplt.axes for i in range(manual_response.noutputs): for j in range(1, manual_response.ninputs): # Everything in the same row should have the same limits @@ -157,7 +158,7 @@ def test_manual_response_limits(): @pytest.mark.usefixtures("editsdefaults") def test_line_styles(plt_fcn): # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') # Define a couple of systems for testing sys1 = ct.tf([1], [1, 2, 1], name='sys1') @@ -265,7 +266,7 @@ def test_gangof4_plots(savefigs=False): @pytest.mark.usefixtures("editsdefaults") def test_first_arg_listable(response_cmd, return_type): # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') sys = ct.rss(2, 1, 1) @@ -301,11 +302,11 @@ def test_first_arg_listable(response_cmd, return_type): @pytest.mark.usefixtures("editsdefaults") def test_bode_share_options(): # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') # Default sharing should share along rows and cols for mag and phase - lines = ct.bode_plot(manual_response) - axs = ct.get_plot_axes(lines) + cplt = ct.bode_plot(manual_response) + axs = cplt.axes for i in range(axs.shape[0]): for j in range(axs.shape[1]): # Share y limits along rows @@ -316,8 +317,8 @@ def test_bode_share_options(): # Sharing along y axis for mag but not phase plt.figure() - lines = ct.bode_plot(manual_response, share_phase='none') - axs = ct.get_plot_axes(lines) + cplt = ct.bode_plot(manual_response, share_phase='none') + axs = cplt.axes for i in range(int(axs.shape[0] / 2)): for j in range(axs.shape[1]): if i != 0: @@ -329,8 +330,8 @@ def test_bode_share_options(): # Turn off sharing for magnitude and phase plt.figure() - lines = ct.bode_plot(manual_response, sharey='none') - axs = ct.get_plot_axes(lines) + cplt = ct.bode_plot(manual_response, sharey='none') + axs = cplt.axes for i in range(int(axs.shape[0] / 2)): for j in range(axs.shape[1]): if i != 0: @@ -344,7 +345,7 @@ def test_bode_share_options(): # Turn off sharing in x axes plt.figure() - lines = ct.bode_plot(manual_response, sharex='none') + cplt = ct.bode_plot(manual_response, sharex='none') # TODO: figure out what to check @@ -354,17 +355,17 @@ def test_freqplot_plot_type(plot_type): response = ct.singular_values_response(ct.rss(2, 1, 1)) else: response = ct.frequency_response(ct.rss(2, 1, 1)) - lines = response.plot(plot_type=plot_type) + cplt = response.plot(plot_type=plot_type) if plot_type == 'bode': - assert lines.shape == (2, 1) + assert cplt.lines.shape == (2, 1) else: - assert lines.shape == (1, ) + assert cplt.lines.shape == (1, ) @pytest.mark.parametrize("plt_fcn", [ct.bode_plot, ct.singular_values_plot]) @pytest.mark.usefixtures("editsdefaults") def test_freqplot_omega_limits(plt_fcn): # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') # Utility function to check visible limits def _get_visible_limits(ax): @@ -378,14 +379,14 @@ def _get_visible_limits(ax): ct.tf([1], [1, 2, 1]), np.logspace(-1, 1)) # Generate a plot without overridding the limits - lines = plt_fcn(response) - ax = ct.get_plot_axes(lines) + cplt = plt_fcn(response) + ax = cplt.axes np.testing.assert_allclose( _get_visible_limits(ax.reshape(-1)[0]), np.array([0.1, 10])) # Now reset the limits - lines = plt_fcn(response, omega_limits=(1, 100)) - ax = ct.get_plot_axes(lines) + cplt = plt_fcn(response, omega_limits=(1, 100)) + ax = cplt.axes np.testing.assert_allclose( _get_visible_limits(ax.reshape(-1)[0]), np.array([1, 100])) @@ -393,21 +394,40 @@ def _get_visible_limits(ax): def test_gangof4_trace_labels(): P1 = ct.rss(2, 1, 1, name='P1') P2 = ct.rss(3, 1, 1, name='P2') - C = ct.rss(1, 1, 1, name='C') + C1 = ct.rss(1, 1, 1, name='C1') + C2 = ct.rss(1, 1, 1, name='C2') # Make sure default labels are as expected - out = ct.gangof4_response(P1, C).plot() - out = ct.gangof4_response(P2, C).plot() - axs = ct.get_plot_axes(out) + cplt = ct.gangof4_response(P1, C1).plot() + cplt = ct.gangof4_response(P2, C2).plot() + axs = cplt.axes legend = axs[0, 1].get_legend().get_texts() - assert legend[0].get_text() == 'None' - assert legend[1].get_text() == 'None' + assert legend[0].get_text() == 'P=P1, C=C1' + assert legend[1].get_text() == 'P=P2, C=C2' + plt.close() + + # Suffix truncation + cplt = ct.gangof4_response(P1, C1).plot() + cplt = ct.gangof4_response(P2, C1).plot() + axs = cplt.axes + legend = axs[0, 1].get_legend().get_texts() + assert legend[0].get_text() == 'P=P1' + assert legend[1].get_text() == 'P=P2' + plt.close() + + # Prefix turncation + cplt = ct.gangof4_response(P1, C1).plot() + cplt = ct.gangof4_response(P1, C2).plot() + axs = cplt.axes + legend = axs[0, 1].get_legend().get_texts() + assert legend[0].get_text() == 'C=C1' + assert legend[1].get_text() == 'C=C2' plt.close() # Override labels - out = ct.gangof4_response(P1, C).plot(label='xxx, line1, yyy') - out = ct.gangof4_response(P2, C).plot(label='xxx, line2, yyy') - axs = ct.get_plot_axes(out) + cplt = ct.gangof4_response(P1, C1).plot(label='xxx, line1, yyy') + cplt = ct.gangof4_response(P2, C2).plot(label='xxx, line2, yyy') + axs = cplt.axes legend = axs[0, 1].get_legend().get_texts() assert legend[0].get_text() == 'xxx, line1, yyy' assert legend[1].get_text() == 'xxx, line2, yyy' @@ -417,16 +437,16 @@ def test_gangof4_trace_labels(): @pytest.mark.parametrize( "plt_fcn", [ct.bode_plot, ct.singular_values_plot, ct.nyquist_plot]) @pytest.mark.usefixtures("editsdefaults") -def test_freqplot_trace_labels(plt_fcn): +def test_freqplot_line_labels(plt_fcn): sys1 = ct.rss(2, 1, 1, name='sys1') sys2 = ct.rss(3, 1, 1, name='sys2') # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') # Make sure default labels are as expected - out = plt_fcn([sys1, sys2]) - axs = ct.get_plot_axes(out) + cplt = plt_fcn([sys1, sys2]) + axs = cplt.axes if axs.ndim == 1: legend = axs[0].get_legend().get_texts() else: @@ -436,8 +456,8 @@ def test_freqplot_trace_labels(plt_fcn): plt.close() # Override labels all at once - out = plt_fcn([sys1, sys2], label=['line1', 'line2']) - axs = ct.get_plot_axes(out) + cplt = plt_fcn([sys1, sys2], label=['line1', 'line2']) + axs = cplt.axes if axs.ndim == 1: legend = axs[0].get_legend().get_texts() else: @@ -447,9 +467,9 @@ def test_freqplot_trace_labels(plt_fcn): plt.close() # Override labels one at a time - out = plt_fcn(sys1, label='line1') - out = plt_fcn(sys2, label='line2') - axs = ct.get_plot_axes(out) + cplt = plt_fcn(sys1, label='line1') + cplt = plt_fcn(sys2, label='line2') + axs = cplt.axes if axs.ndim == 1: legend = axs[0].get_legend().get_texts() else: @@ -458,32 +478,28 @@ def test_freqplot_trace_labels(plt_fcn): assert legend[1].get_text() == 'line2' plt.close() - if plt_fcn == ct.bode_plot: - # Multi-dimensional data - sys1 = ct.rss(2, 2, 2, name='sys1') - sys2 = ct.rss(3, 2, 2, name='sys2') - - # Check out some errors first - with pytest.raises(ValueError, match="number of labels must match"): - ct.bode_plot([sys1, sys2], label=['line1']) - - with pytest.xfail(reason="need better broadcast checking on labels"): - with pytest.raises( - ValueError, match="labels must be given for each"): - ct.bode_plot(sys1, overlay_inputs=True, label=['line1']) - - # Now do things that should work - out = ct.bode_plot( - [sys1, sys2], - label=[ - [['line1', 'line1'], ['line1', 'line1']], - [['line2', 'line2'], ['line2', 'line2']], - ]) - axs = ct.get_plot_axes(out) - legend = axs[0, -1].get_legend().get_texts() - assert legend[0].get_text() == 'line1' - assert legend[1].get_text() == 'line2' - plt.close() + +@pytest.mark.skip(reason="line label override not yet implemented") +@pytest.mark.parametrize("kwargs, labels", [ + ({}, ['sys1', 'sys2']), + ({'overlay_outputs': True}, [ + 'x sys1 out1 y', 'x sys1 out2 y', 'x sys2 out1 y', 'x sys2 out2 y']), +]) +def test_line_labels_bode(kwargs, labels): + # Multi-dimensional data + sys1 = ct.rss(2, 2, 2) + sys2 = ct.rss(3, 2, 2) + + # Check out some errors first + with pytest.raises(ValueError, match="number of labels must match"): + ct.bode_plot([sys1, sys2], label=['line1']) + + cplt = ct.bode_plot([sys1, sys2], label=labels, **kwargs) + axs = cplt.axes + legend_texts = axs[0, -1].get_legend().get_texts() + for i, legend in enumerate(legend_texts): + assert legend.get_text() == labels[i] + plt.close() @pytest.mark.parametrize( @@ -499,28 +515,28 @@ def test_freqplot_ax_keyword(plt_fcn, ninputs, noutputs): pytest.skip("MIMO not implemented for Nyquist/Nichols") # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') # System to use sys = ct.rss(4, ninputs, noutputs) # Create an initial figure - out1 = plt_fcn(sys) + cplt1 = plt_fcn(sys) # Draw again on the same figure, using array - axs = ct.get_plot_axes(out1) - out2 = plt_fcn(sys, ax=axs) - np.testing.assert_equal(ct.get_plot_axes(out1), ct.get_plot_axes(out2)) + axs = cplt1.axes + cplt2 = plt_fcn(sys, ax=axs) + np.testing.assert_equal(cplt1.axes, cplt2.axes) # Pass things in as a list instead axs_list = axs.tolist() - out3 = plt_fcn(sys, ax=axs) - np.testing.assert_equal(ct.get_plot_axes(out1), ct.get_plot_axes(out3)) + cplt3 = plt_fcn(sys, ax=axs) + np.testing.assert_equal(cplt1.axes, cplt3.axes) # Flatten the list axs_list = axs.squeeze().tolist() - out3 = plt_fcn(sys, ax=axs_list) - np.testing.assert_equal(ct.get_plot_axes(out1), ct.get_plot_axes(out3)) + cplt4 = plt_fcn(sys, ax=axs_list) + np.testing.assert_equal(cplt1.axes, cplt4.axes) def test_mixed_systypes(): @@ -536,47 +552,50 @@ def test_mixed_systypes(): resp_tf = ct.frequency_response(sys_tf) resp_ss = ct.frequency_response(sys_ss) plt.figure() - ct.bode_plot([resp_tf, resp_ss, sys_frd1, sys_frd2], plot_phase=False) - ct.suptitle("bode_plot([resp_tf, resp_ss, sys_frd1, sys_frd2])") + cplt = ct.bode_plot( + [resp_tf, resp_ss, sys_frd1, sys_frd2], plot_phase=False) + cplt.set_plot_title("bode_plot([resp_tf, resp_ss, sys_frd1, sys_frd2])") # Same thing, but using frequency response plt.figure() resp = ct.frequency_response([sys_tf, sys_ss, sys_frd1, sys_frd2]) - resp.plot(plot_phase=False) - ct.suptitle("frequency_response([sys_tf, sys_ss, sys_frd1, sys_frd2])") + cplt = resp.plot(plot_phase=False) + cplt.set_plot_title( + "frequency_response([sys_tf, sys_ss, sys_frd1, sys_frd2])") # Same thing, but using bode_plot plt.figure() - resp = ct.bode_plot([sys_tf, sys_ss, sys_frd1, sys_frd2], plot_phase=False) - ct.suptitle("bode_plot([sys_tf, sys_ss, sys_frd1, sys_frd2])") + cplt = ct.bode_plot([sys_tf, sys_ss, sys_frd1, sys_frd2], plot_phase=False) + cplt.set_plot_title("bode_plot([sys_tf, sys_ss, sys_frd1, sys_frd2])") def test_suptitle(): - sys = ct.rss(2, 2, 2) + sys = ct.rss(2, 2, 2, strictly_proper=True) # Default location: center of axes - out = ct.bode_plot(sys) + cplt = ct.bode_plot(sys) assert plt.gcf()._suptitle._x != 0.5 # Try changing the the title - ct.suptitle("New title") + cplt.set_plot_title("New title") assert plt.gcf()._suptitle._text == "New title" # Change the location of the title - ct.suptitle("New title", frame='figure') + cplt.set_plot_title("New title", frame='figure') assert plt.gcf()._suptitle._x == 0.5 # Change the location of the title back - ct.suptitle("New title", frame='axes') + cplt.set_plot_title("New title", frame='axes') assert plt.gcf()._suptitle._x != 0.5 # Bad frame with pytest.raises(ValueError, match="unknown"): - ct.suptitle("New title", frame='nowhere') + cplt.set_plot_title("New title", frame='nowhere') # Bad keyword - with pytest.raises(AttributeError, match="unexpected keyword|no property"): - ct.suptitle("New title", unknown=None) + with pytest.raises( + TypeError, match="unexpected keyword|no property"): + cplt.set_plot_title("New title", unknown=None) @pytest.mark.parametrize("plt_fcn", [ct.bode_plot, ct.singular_values_plot]) @@ -598,6 +617,15 @@ def test_freqplot_errors(plt_fcn): with pytest.raises(ValueError, match="invalid limits"): plt_fcn(response, omega_limits=[1e2, 1e-2]) +def test_freqresplist_unknown_kw(): + sys1 = ct.rss(2, 1, 1) + sys2 = ct.rss(2, 1, 1) + resp = ct.frequency_response([sys1, sys2]) + assert isinstance(resp, ct.FrequencyResponseList) + + with pytest.raises(AttributeError, match="unexpected keyword"): + resp.plot(unknown=True) + if __name__ == "__main__": # @@ -638,7 +666,7 @@ def test_freqplot_errors(plt_fcn): for args in test_cases: test_response_plots(*args, ovlinp=False, ovlout=False, clear=False) - # Reset suptitle_frame to the default value + # Reset title_frame to the default value ct.reset_defaults() # Define and run a selected set of interesting tests diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 4d252ab19..020910e73 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -11,13 +11,14 @@ # is a unit test that checks for unrecognized keywords. import inspect -import pytest import warnings + import matplotlib.pyplot as plt +import pytest import control import control.flatsys - +import control.tests.descfcn_test as descfcn_test # List of all of the test modules where kwarg unit tests are defined import control.tests.flatsys_test as flatsys_test import control.tests.frd_test as frd_test @@ -26,9 +27,9 @@ import control.tests.optimal_test as optimal_test import control.tests.statefbk_test as statefbk_test import control.tests.stochsys_test as stochsys_test -import control.tests.trdata_test as trdata_test import control.tests.timeplot_test as timeplot_test -import control.tests.descfcn_test as descfcn_test +import control.tests.trdata_test as trdata_test + @pytest.mark.parametrize("module, prefix", [ (control, ""), (control.flatsys, "flatsys."), @@ -54,8 +55,9 @@ def test_kwarg_search(module, prefix): # Get the signature for the function sig = inspect.signature(obj) - # Skip anything that is inherited - if inspect.isclass(module) and obj.__name__ not in module.__dict__: + # Skip anything that is inherited or hidden + if inspect.isclass(module) and obj.__name__ not in module.__dict__ \ + or obj.__name__.startswith('_'): continue # See if there is a variable keyword argument @@ -297,9 +299,11 @@ def test_response_plot_kwargs(data_fcn, plot_fcn, mimo): 'optimal.create_mpc_iosystem': optimal_test.test_mpc_iosystem_rename, 'optimal.solve_ocp': optimal_test.test_ocp_argument_errors, 'optimal.solve_oep': optimal_test.test_oep_argument_errors, + 'ControlPlot.set_plot_title': freqplot_test.test_suptitle, 'FrequencyResponseData.__init__': frd_test.TestFRD.test_unrecognized_keyword, 'FrequencyResponseData.plot': test_response_plot_kwargs, + 'FrequencyResponseList.plot': freqplot_test.test_freqresplist_unknown_kw, 'DescribingFunctionResponse.plot': descfcn_test.test_describing_function_exceptions, 'InputOutputSystem.__init__': test_unrecognized_kwargs, diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index 73a4ed8b6..823d65732 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -161,38 +161,40 @@ def test_nyquist_fbs_examples(): """Run through various examples from FBS2e to compare plots""" plt.figure() - ct.suptitle("Figure 10.4: L(s) = 1.4 e^{-s}/(s+1)^2") sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4)) response = ct.nyquist_response(sys) - response.plot() + cplt = response.plot() + cplt.set_plot_title("Figure 10.4: L(s) = 1.4 e^{-s}/(s+1)^2") assert _Z(sys) == response.count + _P(sys) plt.figure() - ct.suptitle("Figure 10.4: L(s) = 1/(s + a)^2 with a = 0.6") sys = 1/(s + 0.6)**3 response = ct.nyquist_response(sys) - response.plot() + cplt = response.plot() + cplt.set_plot_title("Figure 10.4: L(s) = 1/(s + a)^2 with a = 0.6") assert _Z(sys) == response.count + _P(sys) plt.figure() - ct.suptitle("Figure 10.6: L(s) = 1/(s (s+1)^2) - pole at the origin") sys = 1/(s * (s+1)**2) response = ct.nyquist_response(sys) - response.plot() + cplt = response.plot() + cplt.set_plot_title( + "Figure 10.6: L(s) = 1/(s (s+1)^2) - pole at the origin") assert _Z(sys) == response.count + _P(sys) plt.figure() - ct.suptitle("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2)") sys = 3 * (s+6)**2 / (s * (s+1)**2) response = ct.nyquist_response(sys) - response.plot() + cplt = response.plot() + cplt.set_plot_title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2)") assert _Z(sys) == response.count + _P(sys) plt.figure() - ct.suptitle("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2) [zoom]") with pytest.warns(UserWarning, match="encirclements does not match"): response = ct.nyquist_response(sys, omega_limits=[1.5, 1e3]) - response.plot() + cplt = response.plot() + cplt.set_plot_title( + "Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2) [zoom]") # Frequency limits for zoom give incorrect encirclement count # assert _Z(sys) == response.count + _P(sys) assert response.count == -1 @@ -207,9 +209,9 @@ def test_nyquist_fbs_examples(): def test_nyquist_arrows(arrows): sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4)) plt.figure(); - ct.suptitle("L(s) = 1.4 e^{-s}/(s+1)^2 / arrows = %s" % arrows) response = ct.nyquist_response(sys) - response.plot(arrows=arrows) + cplt = response.plot(arrows=arrows) + cplt.set_plot_title("L(s) = 1.4 e^{-s}/(s+1)^2 / arrows = %s" % arrows) assert _Z(sys) == response.count + _P(sys) @@ -236,14 +238,14 @@ def test_nyquist_encirclements(): plt.figure(); response = ct.nyquist_response(sys) - response.plot() - ct.suptitle("Stable system; encirclements = %d" % response.count) + cplt = response.plot() + cplt.set_plot_title("Stable system; encirclements = %d" % response.count) assert _Z(sys) == response.count + _P(sys) plt.figure(); response = ct.nyquist_response(sys * 3) - response.plot() - ct.suptitle("Unstable system; encirclements = %d" %response.count) + cplt = response.plot() + cplt.set_plot_title("Unstable system; encirclements = %d" %response.count) assert _Z(sys * 3) == response.count + _P(sys * 3) # System with pole at the origin @@ -251,8 +253,9 @@ def test_nyquist_encirclements(): plt.figure(); response = ct.nyquist_response(sys) - response.plot() - ct.suptitle("Pole at the origin; encirclements = %d" %response.count) + cplt = response.plot() + cplt.set_plot_title( + "Pole at the origin; encirclements = %d" %response.count) assert _Z(sys) == response.count + _P(sys) # Non-integer number of encirclements @@ -265,8 +268,9 @@ def test_nyquist_encirclements(): # strip out matrix warnings response = ct.nyquist_response( sys, omega_limits=[0.5, 1e3], encirclement_threshold=0.2) - response.plot() - ct.suptitle("Non-integer number of encirclements [%g]" %response.count) + cplt = response.plot() + cplt.set_plot_title( + "Non-integer number of encirclements [%g]" %response.count) @pytest.fixture @@ -280,8 +284,8 @@ def indentsys(): def test_nyquist_indent_default(indentsys): plt.figure(); response = ct.nyquist_response(indentsys) - response.plot() - ct.suptitle("Pole at origin; indent_radius=default") + cplt = response.plot() + cplt.set_plot_title("Pole at origin; indent_radius=default") assert _Z(indentsys) == response.count + _P(indentsys) @@ -307,8 +311,9 @@ def test_nyquist_indent_do(indentsys): response = ct.nyquist_response( indentsys, indent_radius=0.01, return_contour=True) count, contour = response - response.plot() - ct.suptitle("Pole at origin; indent_radius=0.01; encirclements = %d" % count) + cplt = response.plot() + cplt.set_plot_title( + "Pole at origin; indent_radius=0.01; encirclements = %d" % count) assert _Z(indentsys) == count + _P(indentsys) # indent radius is smaller than the start of the default omega vector # check that a quarter circle around the pole at origin has been added. @@ -328,8 +333,8 @@ def test_nyquist_indent_do(indentsys): def test_nyquist_indent_left(indentsys): plt.figure(); response = ct.nyquist_response(indentsys, indent_direction='left') - response.plot() - ct.suptitle( + cplt = response.plot() + cplt.set_plot_title( "Pole at origin; indent_direction='left'; encirclements = %d" % response.count) assert _Z(indentsys) == response.count + _P(indentsys, indent='left') @@ -342,15 +347,15 @@ def test_nyquist_indent_im(): # Imaginary poles with standard indentation plt.figure(); response = ct.nyquist_response(sys) - response.plot() - ct.suptitle("Imaginary poles; encirclements = %d" % response.count) + cplt = response.plot() + cplt.set_plot_title("Imaginary poles; encirclements = %d" % response.count) assert _Z(sys) == response.count + _P(sys) # Imaginary poles with indentation to the left plt.figure(); response = ct.nyquist_response(sys, indent_direction='left') - response.plot(label_freq=300) - ct.suptitle( + cplt = response.plot(label_freq=300) + cplt.set_plot_title( "Imaginary poles; indent_direction='left'; encirclements = %d" % response.count) assert _Z(sys) == response.count + _P(sys, indent='left') @@ -360,8 +365,8 @@ def test_nyquist_indent_im(): with pytest.warns(UserWarning, match="encirclements does not match"): response = ct.nyquist_response( sys, np.linspace(0, 1e3, 1000), indent_direction='none') - response.plot() - ct.suptitle( + cplt = response.plot() + cplt.set_plot_title( "Imaginary poles; indent_direction='none'; encirclements = %d" % response.count) assert _Z(sys) == response.count + _P(sys) @@ -399,17 +404,17 @@ def test_linestyle_checks(): sys = ct.tf([100], [1, 1, 1]) # Set the line styles - lines = ct.nyquist_plot( + cplt = ct.nyquist_plot( sys, primary_style=[':', ':'], mirror_style=[':', ':']) - assert all([line.get_linestyle() == ':' for line in lines[0]]) + assert all([line.get_linestyle() == ':' for line in cplt.lines[0]]) # Set the line colors - lines = ct.nyquist_plot(sys, color='g') - assert all([line.get_color() == 'g' for line in lines[0]]) + cplt = ct.nyquist_plot(sys, color='g') + assert all([line.get_color() == 'g' for line in cplt.lines[0]]) # Turn off the mirror image - lines = ct.nyquist_plot(sys, mirror_style=False) - assert lines[0][2:] == [None, None] + cplt = ct.nyquist_plot(sys, mirror_style=False) + assert cplt.lines[0][2:] == [None, None] with pytest.raises(ValueError, match="invalid 'primary_style'"): ct.nyquist_plot(sys, primary_style=False) @@ -505,7 +510,7 @@ def test_nyquist_frd(): # Computing Nyquist response w/ different frequencies OK if given as a list nyqresp = ct.nyquist_response([sys1, sys2]) - out = nyqresp.plot() + cplt = nyqresp.plot() warnings.resetwarnings() @@ -556,19 +561,19 @@ def test_nyquist_frd(): print("Unusual Nyquist plot") sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1]) plt.figure() - ct.suptitle("Poles: %s" % - np.array2string(sys.poles(), precision=2, separator=',')) response = ct.nyquist_response(sys) - response.plot() + cplt = response.plot() + cplt.set_plot_title("Poles: %s" % + np.array2string(sys.poles(), precision=2, separator=',')) assert _Z(sys) == response.count + _P(sys) print("Discrete time systems") sys = ct.c2d(sys, 0.01) plt.figure() - ct.suptitle("Discrete-time; poles: %s" % - np.array2string(sys.poles(), precision=2, separator=',')) response = ct.nyquist_response(sys) - response.plot() + cplt = response.plot() + cplt.set_plot_title("Discrete-time; poles: %s" % + np.array2string(sys.poles(), precision=2, separator=',')) print("Frequency response data (FRD) systems") sys = ct.tf( @@ -577,5 +582,5 @@ def test_nyquist_frd(): sys1 = ct.frd(sys, np.logspace(-1, 1, 15), name='frd1') sys2 = ct.frd(sys, np.logspace(-2, 2, 20), name='frd2') plt.figure() - ct.nyquist_plot([sys, sys1, sys2]) - ct.suptitle("Mixed FRD, tf data") + cplt = ct.nyquist_plot([sys, sys1, sys2]) + cplt.set_plot_title("Mixed FRD, tf data") diff --git a/control/tests/phaseplot_test.py b/control/tests/phaseplot_test.py index 18e06716f..5e7a31651 100644 --- a/control/tests/phaseplot_test.py +++ b/control/tests/phaseplot_test.py @@ -10,15 +10,16 @@ """ import warnings +from math import pi import matplotlib.pyplot as plt import numpy as np import pytest -from math import pi import control as ct import control.phaseplot as pp from control import phase_plot +from control.tests.conftest import mplcleanup # Legacy tests @@ -116,6 +117,7 @@ def oscillator_ode(self, x, t, m=1., b=1, k=1, extra=None): [ct.phaseplot.separatrices, [5], {'params': {}, 'gridspec': [5, 5]}], [ct.phaseplot.separatrices, [5], {'color': ('r', 'g')}], ]) +@pytest.mark.usefixtures('mplcleanup') def test_helper_functions(func, args, kwargs): # Test with system sys = ct.nlsys( @@ -128,6 +130,7 @@ def test_helper_functions(func, args, kwargs): out = func(rhsfcn, [-1, 1, -1, 1], *args, **kwargs) +@pytest.mark.usefixtures('mplcleanup') def test_system_types(): # Sample dynamical systems - inverted pendulum def invpend_ode(t, x, m=0, l=0, b=0, g=0): @@ -135,13 +138,14 @@ def invpend_ode(t, x, m=0, l=0, b=0, g=0): # Use callable form, with parameters (if not correct, will get /0 error) ct.phase_plane_plot( - invpend_ode, [-5, 5, 2, 2], params={'args': (1, 1, 0.2, 1)}) + invpend_ode, [-5, 5, -2, 2], params={'args': (1, 1, 0.2, 1)}) # Linear I/O system ct.phase_plane_plot( ct.ss([[0, 1], [-1, -1]], [[0], [1]], [[1, 0]], 0)) +@pytest.mark.usefixtures('mplcleanup') def test_phaseplane_errors(): with pytest.raises(ValueError, match="invalid grid specification"): ct.phase_plane_plot(ct.rss(2, 1, 1), gridspec='bad') @@ -176,6 +180,7 @@ def invpend_ode(t, x, m=0, l=0, b=0, g=0): plot_separatrices=False, suppress_warnings=True) +@pytest.mark.usefixtures('mplcleanup') def test_basic_phase_plots(savefigs=False): sys = ct.nlsys( lambda t, x, u, params: np.array([[0, 1], [-1, -1]]) @ x, diff --git a/control/tests/pzmap_test.py b/control/tests/pzmap_test.py index ce8adf6e7..04eb037ab 100644 --- a/control/tests/pzmap_test.py +++ b/control/tests/pzmap_test.py @@ -119,7 +119,7 @@ def test_pzmap_raises(): def test_pzmap_limits(): sys = ct.tf([1, 2], [1, 2, 3]) - out = ct.pole_zero_plot(sys, xlim=[-1, 1], ylim=[-1, 1]) - ax = ct.get_plot_axes(out)[0, 0] + cplt = ct.pole_zero_plot(sys, xlim=[-1, 1], ylim=[-1, 1]) + ax = cplt.axes[0, 0] assert ax.get_xlim() == (-1, 1) assert ax.get_ylim() == (-1, 1) diff --git a/control/tests/rlocus_test.py b/control/tests/rlocus_test.py index 15eb67d97..38111e98e 100644 --- a/control/tests/rlocus_test.py +++ b/control/tests/rlocus_test.py @@ -95,7 +95,7 @@ def test_root_locus_plot_grid(self, sys, grid, method): if grid == 'empty': assert n_gridlines == 0 assert not isinstance(ax, AA.Axes) - elif grid is False or method == 'pzmap' and grid is None: + elif grid is False: assert n_gridlines == 2 if sys.isctime() else 3 assert not isinstance(ax, AA.Axes) elif sys.isdtime(strict=True): @@ -174,6 +174,7 @@ def test_rlocus_default_wn(self): "sys, grid, xlim, ylim, interactive", [ (ct.tf([1], [1, 2, 1]), None, None, None, False), ]) +@pytest.mark.usefixtures("mplcleanup") def test_root_locus_plots(sys, grid, xlim, ylim, interactive): ct.root_locus_map(sys).plot( grid=grid, xlim=xlim, ylim=ylim, interactive=interactive) @@ -182,6 +183,7 @@ def test_root_locus_plots(sys, grid, xlim, ylim, interactive): # Test deprecated keywords @pytest.mark.parametrize("keyword", ["kvect", "k"]) +@pytest.mark.usefixtures("mplcleanup") def test_root_locus_legacy(keyword): sys = ct.rss(2, 1, 1) with pytest.warns(DeprecationWarning, match=f"'{keyword}' is deprecated"): @@ -189,6 +191,7 @@ def test_root_locus_legacy(keyword): # Generate plots used in documentation +@pytest.mark.usefixtures("mplcleanup") def test_root_locus_documentation(savefigs=False): plt.figure() sys = ct.tf([1, 2], [1, 2, 3], name='SISO transfer function') @@ -204,8 +207,8 @@ def test_root_locus_documentation(savefigs=False): # TODO: generate event in order to generate real title plt.figure() - out = ct.root_locus_map(sys).plot(initial_gain=3.506) - ax = ct.get_plot_axes(out)[0, 0] + cplt = ct.root_locus_map(sys).plot(initial_gain=3.506) + ax = cplt.axes[0, 0] freqplot_rcParams = ct.config._get_param('freqplot', 'rcParams') with plt.rc_context(freqplot_rcParams): ax.set_title( @@ -278,6 +281,9 @@ def test_root_locus_documentation(savefigs=False): plt.figure() test_root_locus_plots( sys, grid=grid, xlim=xlim, ylim=ylim, interactive=interactive) + ct.suptitle( + f"sys={sys.name}, {grid=}, {xlim=}, {ylim=}, {interactive=}", + frame='figure') # Run tests that generate plots for the documentation test_root_locus_documentation(savefigs=True) diff --git a/control/tests/timeplot_test.py b/control/tests/timeplot_test.py index dda5eb25c..9525c7e02 100644 --- a/control/tests/timeplot_test.py +++ b/control/tests/timeplot_test.py @@ -1,13 +1,14 @@ # timeplot_test.py - test out time response plots # RMM, 23 Jun 2023 -import pytest -import control as ct import matplotlib as mpl import matplotlib.pyplot as plt import numpy as np +import pytest + +import control as ct +from control.tests.conftest import mplcleanup, slycotonly -from control.tests.conftest import slycotonly, mplcleanup # Detailed test of (almost) all functionality # @@ -123,22 +124,22 @@ def test_response_plots( pltinp is False or response.ninputs == 0 or pltinp is None and response.plot_inputs is False): with pytest.raises(ValueError, match=".* no data to plot"): - out = response.plot(**kwargs) + cplt = response.plot(**kwargs) return None elif not pltout and pltinp == 'overlay': with pytest.raises(ValueError, match="can't overlay inputs"): - out = response.plot(**kwargs) + cplt = response.plot(**kwargs) return None elif pltinp in [True, 'overlay'] and response.ninputs == 0: with pytest.raises(ValueError, match=".* but no inputs"): - out = response.plot(**kwargs) + cplt = response.plot(**kwargs) return None - out = response.plot(**kwargs) + cplt = response.plot(**kwargs) # Make sure all of the outputs are of the right type nlines_plotted = 0 - for ax_lines in np.nditer(out, flags=["refs_ok"]): + for ax_lines in np.nditer(cplt.lines, flags=["refs_ok"]): for line in ax_lines.item(): assert isinstance(line, mpl.lines.Line2D) nlines_plotted += 1 @@ -179,7 +180,7 @@ def test_response_plots( assert len(ax.get_lines()) > 1 # Update the title so we can see what is going on - fig = out[0, 0][0].axes.figure + fig = cplt.figure fig.suptitle( fig._suptitle._text + f" [{sys.noutputs}x{sys.ninputs}, cs={cmbsig}, " @@ -193,46 +194,44 @@ def test_response_plots( @pytest.mark.usefixtures('mplcleanup') def test_axes_setup(): - get_plot_axes = ct.get_plot_axes - sys_2x3 = ct.rss(4, 2, 3) sys_2x3b = ct.rss(4, 2, 3) sys_3x2 = ct.rss(4, 3, 2) sys_3x1 = ct.rss(4, 3, 1) # Two plots of the same size leaves axes unchanged - out1 = ct.step_response(sys_2x3).plot() - out2 = ct.step_response(sys_2x3b).plot() - np.testing.assert_equal(get_plot_axes(out1), get_plot_axes(out2)) + cplt1 = ct.step_response(sys_2x3).plot() + cplt2 = ct.step_response(sys_2x3b).plot() + np.testing.assert_equal(cplt1.axes, cplt2.axes) plt.close() # Two plots of same net size leaves axes unchanged (unfortunately) - out1 = ct.step_response(sys_2x3).plot() - out2 = ct.step_response(sys_3x2).plot() + cplt1 = ct.step_response(sys_2x3).plot() + cplt2 = ct.step_response(sys_3x2).plot() np.testing.assert_equal( - get_plot_axes(out1).reshape(-1), get_plot_axes(out2).reshape(-1)) + cplt1.axes.reshape(-1), cplt2.axes.reshape(-1)) plt.close() # Plots of different shapes generate new plots - out1 = ct.step_response(sys_2x3).plot() - out2 = ct.step_response(sys_3x1).plot() - ax1_list = get_plot_axes(out1).reshape(-1).tolist() - ax2_list = get_plot_axes(out2).reshape(-1).tolist() + cplt1 = ct.step_response(sys_2x3).plot() + cplt2 = ct.step_response(sys_3x1).plot() + ax1_list = cplt1.axes.reshape(-1).tolist() + ax2_list = cplt2.axes.reshape(-1).tolist() for ax in ax1_list: assert ax not in ax2_list plt.close() # Passing a list of axes preserves those axes - out1 = ct.step_response(sys_2x3).plot() - out2 = ct.step_response(sys_3x1).plot() - out3 = ct.step_response(sys_2x3b).plot(ax=get_plot_axes(out1)) - np.testing.assert_equal(get_plot_axes(out1), get_plot_axes(out3)) + cplt1 = ct.step_response(sys_2x3).plot() + cplt2 = ct.step_response(sys_3x1).plot() + cplt3 = ct.step_response(sys_2x3b).plot(ax=cplt1.axes) + np.testing.assert_equal(cplt1.axes, cplt3.axes) plt.close() # Sending an axes array of the wrong size raises exception with pytest.raises(ValueError, match="not the right shape"): - out = ct.step_response(sys_2x3).plot() - ct.step_response(sys_3x1).plot(ax=get_plot_axes(out)) + cplt = ct.step_response(sys_2x3).plot() + ct.step_response(sys_3x1).plot(ax=cplt.axes) sys_2x3 = ct.rss(4, 2, 3) sys_2x3b = ct.rss(4, 2, 3) sys_3x2 = ct.rss(4, 3, 2) @@ -351,26 +350,26 @@ def test_list_responses(resp_fcn): # Sequential plotting results in colors rotating plt.figure() - out1 = resp1.plot() - out2 = resp2.plot() - assert out1.shape == shape - assert out2.shape == shape + cplt1 = resp1.plot() + cplt2 = resp2.plot() + assert cplt1.shape == shape # legacy access (OK here) + assert cplt2.shape == shape # legacy access (OK here) for row in range(2): # just look at the outputs for col in range(shape[1]): - assert out1[row, col][0].get_color() == 'tab:blue' - assert out2[row, col][0].get_color() == 'tab:orange' + assert cplt1.lines[row, col][0].get_color() == 'tab:blue' + assert cplt2.lines[row, col][0].get_color() == 'tab:orange' plt.figure() resp_combined = resp_fcn([sys1, sys2], **kwargs) assert isinstance(resp_combined, ct.timeresp.TimeResponseList) assert resp_combined[0].time[-1] == max(resp1.time[-1], resp2.time[-1]) assert resp_combined[1].time[-1] == max(resp1.time[-1], resp2.time[-1]) - out = resp_combined.plot() - assert out.shape == shape + cplt = resp_combined.plot() + assert cplt.lines.shape == shape for row in range(2): # just look at the outputs for col in range(shape[1]): - assert out[row, col][0].get_color() == 'tab:blue' - assert out[row, col][1].get_color() == 'tab:orange' + assert cplt.lines[row, col][0].get_color() == 'tab:blue' + assert cplt.lines[row, col][1].get_color() == 'tab:orange' @slycotonly @@ -380,20 +379,20 @@ def test_linestyles(): sys_mimo = ct.tf2ss( [[[1], [0.1]], [[0.2], [1]]], [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="MIMO") - out = ct.step_response(sys_mimo).plot('k--', plot_inputs=True) - for ax in np.nditer(out, flags=["refs_ok"]): + cplt = ct.step_response(sys_mimo).plot('k--', plot_inputs=True) + for ax in np.nditer(cplt.lines, flags=["refs_ok"]): for line in ax.item(): assert line.get_color() == 'k' assert line.get_linestyle() == '--' - out = ct.step_response(sys_mimo).plot( + cplt = ct.step_response(sys_mimo).plot( plot_inputs='overlay', overlay_signals=True, overlay_traces=True, output_props=[{'color': c} for c in ['blue', 'orange']], input_props=[{'color': c} for c in ['red', 'green']], trace_props=[{'linestyle': s} for s in ['-', '--']]) - assert out.shape == (1, 1) - lines = out[0, 0] + assert cplt.lines.shape == (1, 1) + lines = cplt.lines[0, 0] assert lines[0].get_color() == 'blue' and lines[0].get_linestyle() == '-' assert lines[1].get_color() == 'orange' and lines[1].get_linestyle() == '-' assert lines[2].get_color() == 'red' and lines[2].get_linestyle() == '-' @@ -428,11 +427,11 @@ def test_timeplot_trace_labels(resp_fcn): kwargs = {'T': T, 'U': U} # Use figure frame for suptitle to speed things up - ct.set_defaults('freqplot', suptitle_frame='figure') + ct.set_defaults('freqplot', title_frame='figure') # Make sure default labels are as expected - out = resp_fcn([sys1, sys2], **kwargs).plot() - axs = ct.get_plot_axes(out) + cplt = resp_fcn([sys1, sys2], **kwargs).plot() + axs = cplt.axes if axs.ndim == 1: legend = axs[0].get_legend().get_texts() else: @@ -442,8 +441,8 @@ def test_timeplot_trace_labels(resp_fcn): plt.close() # Override labels all at once - out = resp_fcn([sys1, sys2], **kwargs).plot(label=['line1', 'line2']) - axs = ct.get_plot_axes(out) + cplt = resp_fcn([sys1, sys2], **kwargs).plot(label=['line1', 'line2']) + axs = cplt.axes if axs.ndim == 1: legend = axs[0].get_legend().get_texts() else: @@ -453,9 +452,9 @@ def test_timeplot_trace_labels(resp_fcn): plt.close() # Override labels one at a time - out = resp_fcn(sys1, **kwargs).plot(label='line1') - out = resp_fcn(sys2, **kwargs).plot(label='line2') - axs = ct.get_plot_axes(out) + cplt = resp_fcn(sys1, **kwargs).plot(label='line1') + cplt = resp_fcn(sys2, **kwargs).plot(label='line2') + axs = cplt.axes if axs.ndim == 1: legend = axs[0].get_legend().get_texts() else: @@ -485,10 +484,10 @@ def test_full_label_override(): labels_4d[i, j, k, 1] = "inp" + sys + trace + out # Test 4D labels - out = ct.step_response([sys1, sys2]).plot( + cplt = ct.step_response([sys1, sys2]).plot( overlay_signals=True, overlay_traces=True, plot_inputs=True, label=labels_4d) - axs = ct.get_plot_axes(out) + axs = cplt.axes assert axs.shape == (2, 1) legend_text = axs[0, 0].get_legend().get_texts() for i, label in enumerate(labels_2d[0]): @@ -498,10 +497,10 @@ def test_full_label_override(): assert legend_text[i].get_text() == label # Test 2D labels - out = ct.step_response([sys1, sys2]).plot( + cplt = ct.step_response([sys1, sys2]).plot( overlay_signals=True, overlay_traces=True, plot_inputs=True, label=labels_2d) - axs = ct.get_plot_axes(out) + axs = cplt.axes assert axs.shape == (2, 1) legend_text = axs[0, 0].get_legend().get_texts() for i, label in enumerate(labels_2d[0]): @@ -520,8 +519,8 @@ def test_relabel(): ct.step_response(sys1).plot() # Generate a new plot, which overwrites labels - out = ct.step_response(sys2).plot() - ax = ct.get_plot_axes(out) + cplt = ct.step_response(sys2).plot() + ax = cplt.axes assert ax[0, 0].get_ylabel() == 'y[0]' # Regenerate the first plot @@ -529,9 +528,9 @@ def test_relabel(): ct.step_response(sys1).plot() # Generate a new plt, without relabeling - out = ct.step_response(sys2).plot(relabel=False) - ax = ct.get_plot_axes(out) - assert ax[0, 0].get_ylabel() == 'y' + with pytest.warns(FutureWarning, match="deprecated"): + cplt = ct.step_response(sys2).plot(relabel=False) + assert cplt.axes[0, 0].get_ylabel() == 'y' def test_errors(): @@ -551,8 +550,8 @@ def test_errors(): for kw in ['input_props', 'output_props', 'trace_props']: propkw = {kw: {'color': 'green'}} with pytest.warns(UserWarning, match="ignored since fmt string"): - out = stepresp.plot('k-', **propkw) - assert out[0, 0][0].get_color() == 'k' + cplt = stepresp.plot('k-', **propkw) + assert cplt.lines[0, 0][0].get_color() == 'k' # Make sure TimeResponseLists also work stepresp = ct.step_response([sys, sys]) @@ -568,24 +567,24 @@ def test_legend_customization(): resp = ct.input_output_response(sys, timepts, U) # Generic input/output plot - out = resp.plot(overlay_signals=True) - axs = ct.get_plot_axes(out) + cplt = resp.plot(overlay_signals=True) + axs = cplt.axes assert axs[0, 0].get_legend()._loc == 7 # center right assert len(axs[0, 0].get_legend().get_texts()) == 2 assert axs[1, 0].get_legend() == None plt.close() # Hide legend - out = resp.plot(overlay_signals=True, show_legend=False) - axs = ct.get_plot_axes(out) + cplt = resp.plot(overlay_signals=True, show_legend=False) + axs = cplt.axes assert axs[0, 0].get_legend() == None assert axs[1, 0].get_legend() == None plt.close() # Put legend in both axes - out = resp.plot( + cplt = resp.plot( overlay_signals=True, legend_map=[['center left'], ['center right']]) - axs = ct.get_plot_axes(out) + axs = cplt.axes assert axs[0, 0].get_legend()._loc == 6 # center left assert len(axs[0, 0].get_legend().get_texts()) == 2 assert axs[1, 0].get_legend()._loc == 7 # center right @@ -685,7 +684,7 @@ def test_legend_customization(): plt.savefig('timeplot-mimo_ioresp-mt_tr.png') plt.figure() - out = ct.step_response(sys_mimo).plot( + cplt = ct.step_response(sys_mimo).plot( plot_inputs='overlay', overlay_signals=True, overlay_traces=True, output_props=[{'color': c} for c in ['blue', 'orange']], input_props=[{'color': c} for c in ['red', 'green']], @@ -697,22 +696,22 @@ def test_legend_customization(): resp_list = ct.step_response([sys1, sys2]) fig = plt.figure() - ct.combine_time_responses( + cplt = ct.combine_time_responses( [ct.step_response(sys1, resp_list[0].time), ct.step_response(sys2, resp_list[1].time)] ).plot(overlay_traces=True) - ct.suptitle("[Combine] " + fig._suptitle._text) + cplt.set_plot_title("[Combine] " + fig._suptitle._text) fig = plt.figure() ct.step_response(sys1).plot() - ct.step_response(sys2).plot() - ct.suptitle("[Sequential] " + fig._suptitle._text) + cplt = ct.step_response(sys2).plot() + cplt.set_plot_title("[Sequential] " + fig._suptitle._text) fig = plt.figure() ct.step_response(sys1).plot(color='b') - ct.step_response(sys2).plot(color='r') - ct.suptitle("[Seq w/color] " + fig._suptitle._text) + cplt = ct.step_response(sys2).plot(color='r') + cplt.set_plot_title("[Seq w/color] " + fig._suptitle._text) fig = plt.figure() - ct.step_response([sys1, sys2]).plot() - ct.suptitle("[List] " + fig._suptitle._text) + cplt = ct.step_response([sys1, sys2]).plot() + cplt.set_plot_title("[List] " + fig._suptitle._text) diff --git a/control/timeplot.py b/control/timeplot.py index e9e566961..d29c212df 100644 --- a/control/timeplot.py +++ b/control/timeplot.py @@ -8,6 +8,7 @@ # Note: It might eventually make sense to put the functions here # directly into timeresp.py. +import itertools from warnings import warn import matplotlib as mpl @@ -15,13 +16,13 @@ import numpy as np from . import config -from .ctrlplot import _ctrlplot_rcParams, _make_legend_labels, _update_suptitle +from .ctrlplot import ControlPlot, _make_legend_labels,\ + _process_legend_keywords, _update_plot_title __all__ = ['time_response_plot', 'combine_time_responses'] # Default values for module parameter variables _timeplot_defaults = { - 'timeplot.rcParams': _ctrlplot_rcParams, 'timeplot.trace_props': [ {'linestyle': s} for s in ['-', '--', ':', '-.']], 'timeplot.output_props': [ @@ -38,9 +39,8 @@ def time_response_plot( data, *fmt, ax=None, plot_inputs=None, plot_outputs=True, transpose=False, overlay_traces=False, overlay_signals=False, - legend_map=None, legend_loc=None, add_initial_zero=True, label=None, - trace_labels=None, title=None, relabel=True, show_legend=None, - **kwargs): + legend=None, add_initial_zero=True, label=None, + trace_labels=None, title=None, relabel=True, **kwargs): """Plot the time response of an input/output system. This function creates a standard set of plots for the input/output @@ -52,15 +52,6 @@ def time_response_plot( ---------- data : TimeResponseData Data to be plotted. - ax : array of Axes - The matplotlib Axes to draw the figure on. If not specified, the - Axes for the current figure are used or, if there is no current - figure with the correct number and shape of Axes, a new figure is - created. The default shape of the array should be (noutputs + - ninputs, ntraces), but if `overlay_traces` is set to `True` then - only one row is needed and if `overlay_signals` is set to `True` - then only one or two columns are needed (depending on plot_inputs - and plot_outputs). plot_inputs : bool or str, optional Sets how and where to plot the inputs: * False: don't plot the inputs @@ -89,44 +80,66 @@ def time_response_plot( Returns ------- - out : array of list of Line2D - Array of Line2D objects for each line in the plot. The shape of - the array matches the subplots shape and the value of the array is a - list of Line2D objects in that subplot. + cplt : :class:`ControlPlot` object + Object containing the data that were plotted: + + * cplt.lines: Array of :class:`matplotlib.lines.Line2D` objects + for each line in the plot. The shape of the array matches the + subplots shape and the value of the array is a list of Line2D + objects in that subplot. + + * cplt.axes: 2D array of :class:`matplotlib.axes.Axes` for the plot. + + * cplt.figure: :class:`matplotlib.figure.Figure` containing the plot. + + * cplt.legend: legend object(s) contained in the plot + + See :class:`ControlPlot` for more detailed information. Other Parameters ---------------- add_initial_zero : bool Add an initial point of zero at the first time point for all inputs with type 'step'. Default is True. + ax : array of matplotlib.axes.Axes, optional + The matplotlib axes to draw the figure on. If not specified, the + axes for the current figure are used or, if there is no current + figure with the correct number and shape of axes, a new figure is + created. The shape of the array must match the shape of the + plotted data. input_props : array of dicts List of line properties to use when plotting combined inputs. The default values are set by config.defaults['timeplot.input_props']. - label : str or array_like of str + label : str or array_like of str, optional If present, replace automatically generated label(s) with the given label(s). If more than one line is being generated, an array of labels should be provided with label[trace, :, 0] representing the output labels and label[trace, :, 1] representing the input labels. - legend_map : array of str, option - Location of the legend for multi-trace plots. Specifies an array + legend_map : array of str, optional + Location of the legend for multi-axes plots. Specifies an array of legend location strings matching the shape of the subplots, with each entry being either None (for no legend) or a legend location string (see :func:`~matplotlib.pyplot.legend`). - legend_loc : str - Location of the legend within the axes for which it appears. This - value is used if legend_map is None. - output_props : array of dicts + legend_loc : int or str, optional + Include a legend in the given location. Default is 'center right', + with no legend for a single response. Use False to suppress legend. + output_props : array of dicts, optional List of line properties to use when plotting combined outputs. The default values are set by config.defaults['timeplot.output_props']. relabel : bool, optional - By default, existing figures and axes are relabeled when new data - are added. If set to `False`, just plot new data on existing axes. + [deprecated] By default, existing figures and axes are relabeled + when new data are added. If set to `False`, just plot new data on + existing axes. show_legend : bool, optional Force legend to be shown if ``True`` or hidden if ``False``. If ``None``, then show legend when there is more than one line on an - axis or ``legend_loc`` or ``legend_map`` have been specified. + axis or ``legend_loc`` or ``legend_map`` has been specified. time_label : str, optional Label to use for the time axis. + title : str, optional + Set the title of the plot. Defaults to plot type and system name(s). + trace_labels : list of str, optional + Replace the default trace labels with the given labels. trace_props : array of dicts List of line properties to use when plotting combined outputs. The default values are set by config.defaults['timeplot.trace_props']. @@ -159,10 +172,10 @@ def time_response_plot( # Process keywords and set defaults # # Set up defaults + ax_user = ax time_label = config._get_param( 'timeplot', 'time_label', kwargs, _timeplot_defaults, pop=True) - rcParams = config._get_param( - 'timeplot', 'rcParams', kwargs, _timeplot_defaults, pop=True) + rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) if kwargs.get('input_props', None) and len(fmt) > 0: warn("input_props ignored since fmt string was present") @@ -182,9 +195,6 @@ def time_response_plot( 'timeplot', 'trace_props', kwargs, _timeplot_defaults, pop=True) tprop_len = len(trace_props) - # Set the title for the data - title = data.title if title == None else title - # Determine whether or not to plot the input data (and how) if plot_inputs is None: plot_inputs = data.plot_inputs @@ -277,6 +287,8 @@ def time_response_plot( # See if we can use the current figure axes fig, ax_array = _process_ax_keyword(ax, (nrows, ncols), rcParams=rcParams) + legend_loc, legend_map, show_legend = _process_legend_keywords( + kwargs, (nrows, ncols), 'center right') # # Map inputs/outputs and traces to axes @@ -370,7 +382,7 @@ def _make_line_label(signal_index, signal_labels, trace_index): # # To allow repeated calls to time_response_plot() to cycle through # colors, we store an offset in the figure object that we can - # retrieve at a later date, if needed. + # retrieve in a later call, if needed. # output_offset = fig._output_offset = getattr(fig, '_output_offset', 0) input_offset = fig._input_offset = getattr(fig, '_input_offset', 0) @@ -442,7 +454,8 @@ def _make_line_label(signal_index, signal_labels, trace_index): # Stop here if the user wants to control everything if not relabel: - return out + warn("relabel keyword is deprecated", FutureWarning) + return ControlPlot(out, ax_array, fig) # # Label the axes (including trace labels) @@ -555,12 +568,8 @@ def _make_line_label(signal_index, signal_labels, trace_index): # # Figure out where to put legends - if legend_map is None: + if show_legend != False and legend_map is None: legend_map = np.full(ax_array.shape, None, dtype=object) - if legend_loc == None: - legend_loc = 'center right' - else: - show_legend = True if show_legend is None else show_legend if transpose: if (overlay_signals or plot_inputs == 'overlay') and overlay_traces: @@ -585,6 +594,7 @@ def _make_line_label(signal_index, signal_labels, trace_index): else: # Put legend in the upper right legend_map[0, -1] = legend_loc + else: # regular layout if (overlay_signals or plot_inputs == 'overlay') and overlay_traces: # Put a legend in each plot for inputs and outputs @@ -608,29 +618,25 @@ def _make_line_label(signal_index, signal_labels, trace_index): else: # Put legend in the upper right legend_map[0, -1] = legend_loc - else: - # Make sure the legend map is the right size - legend_map = np.atleast_2d(legend_map) - if legend_map.shape != ax_array.shape: - raise ValueError("legend_map shape just match axes shape") - - # Turn legend on unless overridden by user - show_legend = True if show_legend is None else show_legend - # Create axis legends - for i in range(nrows): - for j in range(ncols): + if show_legend != False: + # Create axis legends + legend_array = np.full(ax_array.shape, None, dtype=object) + for i, j in itertools.product(range(nrows), range(ncols)): + if legend_map[i, j] is None: + continue ax = ax_array[i, j] labels = [line.get_label() for line in ax.get_lines()] if line_labels is None: labels = _make_legend_labels(labels, plot_inputs == 'overlay') # Update the labels to remove common strings - if show_legend != False and \ - (len(labels) > 1 or show_legend) and \ - legend_map[i, j] != None: + if show_legend == True or len(labels) > 1: with plt.rc_context(rcParams): - ax.legend(labels, loc=legend_map[i, j]) + legend_array[i, j] = ax.legend( + labels, loc=legend_map[i, j]) + else: + legend_array = None # # Update the plot title (= figure suptitle) @@ -642,9 +648,13 @@ def _make_line_label(signal_index, signal_labels, trace_index): # list of systems (e.g., "Step response for sys[1], sys[2]"). # - _update_suptitle(fig, title, rcParams=rcParams) + if ax_user is None and title is None: + title = data.title if title == None else title + _update_plot_title(title, fig, rcParams=rcParams) + elif ax_user is None: + _update_plot_title(title, fig, rcParams=rcParams, use_existing=False) - return out + return ControlPlot(out, ax_array, fig, legend=legend_map) def combine_time_responses(response_list, trace_labels=None, title=None): @@ -660,6 +670,9 @@ def combine_time_responses(response_list, trace_labels=None, title=None): trace_labels : list of str, optional List of labels for each trace. If not specified, trace names are taken from the input data or set to None. + title : str, optional + Set the title to use when plotting. Defaults to plot type and + system name(s). Returns ------- diff --git a/control/timeresp.py b/control/timeresp.py index f844b1df4..5813c166d 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -79,7 +79,6 @@ from scipy.linalg import eig, eigvals, matrix_balance, norm from . import config -from .ctrlplot import _update_suptitle from .exception import pandas_check from .iosys import isctime, isdtime from .timeplot import time_response_plot @@ -112,7 +111,7 @@ class TimeResponseData: :attr:`time`, :attr:`outputs`, :attr:`states`, :attr:`inputs`. When accessing time responses via their properties, squeeze processing is applied so that (by default) single-input, single-output systems will have - the output and input indices supressed. This behavior is set using the + the output and input indices suppressed. This behavior is set using the ``squeeze`` keyword. Attributes @@ -193,6 +192,9 @@ class TimeResponseData: response. If ntraces is 0 (default) then the data represents a single trace with the trace index surpressed in the data. + title : str, optional + Set the title to use when plotting. + trace_labels : array of string, optional Labels to use for traces (set to sysname it ntraces is 0) @@ -745,19 +747,21 @@ class TimeResponseList(list): """ def plot(self, *args, **kwargs): - out_full = None + from .ctrlplot import ControlPlot + + lines = None label = kwargs.pop('label', [None] * len(self)) for i, response in enumerate(self): - out = TimeResponseData.plot( + cplt = TimeResponseData.plot( response, *args, label=label[i], **kwargs) - if out_full is None: - out_full = out + if lines is None: + lines = cplt.lines else: # Append the lines in the new plot to previous lines - for row in range(out.shape[0]): - for col in range(out.shape[1]): - out_full[row, col] += out[row, col] - return out_full + for row in range(cplt.lines.shape[0]): + for col in range(cplt.lines.shape[1]): + lines[row, col] += cplt.lines[row, col] + return ControlPlot(lines, cplt.axes, cplt.figure) # Process signal labels diff --git a/doc/Makefile b/doc/Makefile index dfd34f4f1..f1a54c3cc 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -17,7 +17,7 @@ help: # Rules to create figures FIGS = classes.pdf timeplot-mimo_step-default.png \ freqplot-siso_bode-default.png rlocus-siso_ctime-default.png \ - phaseplot-dampedosc-default.png + phaseplot-dampedosc-default.png ctrlplot-servomech.png classes.pdf: classes.fig fig2dev -Lpdf $< $@ @@ -33,6 +33,9 @@ rlocus-siso_ctime-default.png: ../control/tests/rlocus_test.py phaseplot-dampedosc-default.png: ../control/tests/phaseplot_test.py PYTHONPATH=.. python $< +ctrlplot-servomech.png: ../control/tests/ctrlplot_test.py + PYTHONPATH=.. python $< + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). html pdf clean doctest: Makefile $(FIGS) diff --git a/doc/conf.py b/doc/conf.py index 7a45ba3f9..824f57904 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -105,7 +105,7 @@ intersphinx_mapping = \ {'scipy': ('https://docs.scipy.org/doc/scipy/reference', None), 'numpy': ('https://numpy.org/doc/stable', None), - 'matplotlib': ('https://matplotlib.org/', None), + 'matplotlib': ('https://matplotlib.org/stable/', None), } # If this is True, todo and todolist produce output, else they produce nothing. diff --git a/doc/control.rst b/doc/control.rst index 366454d42..1544f93d0 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -51,7 +51,6 @@ Frequency domain plotting gangof4_plot nichols_plot nichols_grid - suptitle Note: For plotting commands that create multiple axes on the same plot, the individual axes can be retrieved using the axes label (retrieved using the @@ -74,7 +73,6 @@ Time domain simulation input_output_response phase_plot step_response - TimeResponseData Control system analysis ======================= diff --git a/doc/ctrlplot-pole_zero_subplots.png b/doc/ctrlplot-pole_zero_subplots.png new file mode 100644 index 000000000..bc6158971 Binary files /dev/null and b/doc/ctrlplot-pole_zero_subplots.png differ diff --git a/doc/ctrlplot-servomech.png b/doc/ctrlplot-servomech.png new file mode 100644 index 000000000..ce02c2638 Binary files /dev/null and b/doc/ctrlplot-servomech.png differ diff --git a/doc/freqplot-mimo_bode-default.png b/doc/freqplot-mimo_bode-default.png index 86414d916..88a45071b 100644 Binary files a/doc/freqplot-mimo_bode-default.png and b/doc/freqplot-mimo_bode-default.png differ diff --git a/doc/freqplot-nyquist-custom.png b/doc/freqplot-nyquist-custom.png index 06ccda040..eff33135b 100644 Binary files a/doc/freqplot-nyquist-custom.png and b/doc/freqplot-nyquist-custom.png differ diff --git a/doc/freqplot-nyquist-default.png b/doc/freqplot-nyquist-default.png index ede50925b..ce5215493 100644 Binary files a/doc/freqplot-nyquist-default.png and b/doc/freqplot-nyquist-default.png differ diff --git a/doc/freqplot-siso_bode-default.png b/doc/freqplot-siso_bode-default.png index 3cf235a31..f4e3fc67e 100644 Binary files a/doc/freqplot-siso_bode-default.png and b/doc/freqplot-siso_bode-default.png differ diff --git a/doc/freqplot-siso_bode-omega.png b/doc/freqplot-siso_bode-omega.png index 0240473ad..7763d51bb 100644 Binary files a/doc/freqplot-siso_bode-omega.png and b/doc/freqplot-siso_bode-omega.png differ diff --git a/doc/phaseplot-dampedosc-default.png b/doc/phaseplot-dampedosc-default.png index da4e24e35..64ca7bd36 100644 Binary files a/doc/phaseplot-dampedosc-default.png and b/doc/phaseplot-dampedosc-default.png differ diff --git a/doc/phaseplot-invpend-meshgrid.png b/doc/phaseplot-invpend-meshgrid.png index 040b45558..eba45c153 100644 Binary files a/doc/phaseplot-invpend-meshgrid.png and b/doc/phaseplot-invpend-meshgrid.png differ diff --git a/doc/phaseplot-oscillator-helpers.png b/doc/phaseplot-oscillator-helpers.png index 0b5ebf43f..beceb948b 100644 Binary files a/doc/phaseplot-oscillator-helpers.png and b/doc/phaseplot-oscillator-helpers.png differ diff --git a/doc/phaseplots.py b/doc/phaseplots.py deleted file mode 120000 index 4b0575c0f..000000000 --- a/doc/phaseplots.py +++ /dev/null @@ -1 +0,0 @@ -../examples/phaseplots.py \ No newline at end of file diff --git a/doc/plotting.rst b/doc/plotting.rst index 2450c576b..6832122af 100644 --- a/doc/plotting.rst +++ b/doc/plotting.rst @@ -24,11 +24,12 @@ resulting in the following standard pattern:: response = ct.nyquist_response([sys1, sys2]) count = ct.response.count # number of encirclements of -1 - lines = ct.nyquist_plot(response) # Nyquist plot + cplt = ct.nyquist_plot(response) # Nyquist plot -The returned value `lines` provides access to the individual lines in the -generated plot, allowing various aspects of the plot to be modified to suit -specific needs. +Plotting commands return a :class:`~control.ControlPlot` object that +provides access to the individual lines in the generated plot using +`cplt.lines`, allowing various aspects of the plot to be modified to +suit specific needs. The plotting function is also available via the `plot()` method of the analysis object, allowing the following type of calls:: @@ -146,7 +147,7 @@ Additional customization is possible using the `input_props`, `output_props`, and `trace_props` keywords to set complementary line colors and styles for various signals and traces:: - out = ct.step_response(sys_mimo).plot( + cplt = ct.step_response(sys_mimo).plot( plot_inputs='overlay', overlay_signals=True, overlay_traces=True, output_props=[{'color': c} for c in ['blue', 'orange']], input_props=[{'color': c} for c in ['red', 'green']], @@ -422,6 +423,185 @@ Instead, the plot is generated directly be a call to the :mod:`~control.phaseplot` helper functions. +Customizing control plots +========================= + +A set of common options are available to customize control plots in +various ways. The following general rules apply: + +* If a plotting function is called multiple times with data that generate + control plots with the same shape for the array of subplots, the new data + will be overlaid with the old data, with a change in color(s) for the + new data (chosen from the standard matplotlib color cycle). If not + overridden, the plot title and legends will be updated to reflect all + data shown on the plot. + +* If a plotting function is called and the shape for the array of subplots + does not match the currently displayed plot, a new figure is created. + Note that only the shape is checked, so if two different types of + plotting commands that generate the same shape of subplots are called + sequentially, the :func:`matplotlib.pyplot.figure` command should be used + to explicitly create a new figure. + +* The ``ax`` keyword argument can be used to direct the plotting function + to use a specific axes or array of axes. The value of the ``ax`` keyword + must have the proper number of axes for the plot (so a plot generating a + 2x2 array of subplots should be given a 2x2 array of axes for the ``ax`` + keyword). + +* The ``color``, ``linestyle``, ``linewidth``, and other matplotlib line + property arguments can be used to override the default line properties. + If these arguments are absent, the default matplotlib line properties are + used and the color cycles through the default matplotlib color cycle. + + The :func:`~control.bode_plot`, :func:`~control.time_response_plot`, + and selected other commands can also accept a matplotlib format + string (e.g., ``'r--'``). The format string must appear as a positional + argument right after the required data argument. + + Note that line property arguments are the same for all lines generated as + part of a single plotting command call, including when multiple responses + are passed as a list to the plotting command. For this reason it is + often easiest to call multiple plot commands in sequence, with each + command setting the line properties for that system/trace. + +* The ``label`` keyword argument can be used to override the line labels + that are used in generating the title and legend. If more than one line + is being plotted in a given call to a plot command, the ``label`` + argument value should be a list of labels, one for each line, in the + order they will appear in the legend. + + For input/output plots (frequency and time responses), the labels that + appear in the legend are of the form ", , , ". The trace name is used only for multi-trace time + plots (for example, step responses for MIMO systems). Common information + present in all traces is removed, so that the labels appearing in the + legend represent the unique characteristics of each line. + + For non-input/output plots (e.g., Nyquist plots, pole/zero plots, root + locus plots), the default labels are the system name. + + If ``label`` is set to ``False``, individual lines are still given + labels, but no legend is generated in the plot. (This can also be + accomplished by setting ``legend_map`` to ``False``). + + Note: the ``label`` keyword argument is not implemented for describing + function plots or phase plane plots, since these plots are primarily + intended to be for a single system. Standard ``matplotlib`` commands can + be used to customize these plots for displaying information for multiple + systems. + +* The ``legend_loc``, ``legend_map`` and ``show_legend`` keyword arguments + can be used to customize the locations for legends. By default, a + minimal number of legends are used such that lines can be uniquely + identified and no legend is generated if there is only one line in the + plot. Setting ``show_legend`` to ``False`` will suppress the legend and + setting it to ``True`` will force the legend to be displayed even if + there is only a single line in each axes. In addition, if the value of + the ``legend_loc`` keyword argument is set to a string or integer, it + will set the position of the legend as described in the + :func:`matplotlib.legend` documentation. Finally, ``legend_map`` can be + set to an array that matches the shape of the subplots, with each item + being a string indicating the location of the legend for that axes (or + ``None`` for no legend). + +* The ``rcParams`` keyword argument can be used to override the default + matplotlib style parameters used when creating a plot. The default + parameters for all control plots are given by the ``ct.rcParams`` + dictionary and have the following values: + + .. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Key + - Value + * - 'axes.labelsize' + - 'small' + * - 'axes.titlesize' + - 'small' + * - 'figure.titlesize' + - 'medium' + * - 'legend.fontsize' + - 'x-small' + * - 'xtick.labelsize' + - 'small' + * - 'ytick.labelsize' + - 'small' + + Only those values that should be changed from the default need to be + specified in the ``rcParams`` keyword argument. To override the defaults + for all control plots, update the ``ct.rcParams`` dictionary entries. + + The default values for style parameters for control plots can be restored + using :func:`~control.reset_rcParams`. + +* The ``title`` keyword can be used to override the automatic creation of + the plot title. The default title is a string of the form " plot + for " where is a list of the sys names contained in + the plot (which can be updated if the plotting is called multiple times). + Use ``title=False`` to suppress the title completely. The title can also + be updated using the :func:`~control.ControlPlot.set_plot_title` method + for the returned control plot object. + + The plot title is only generated if ``ax`` is ``None``. + +The following code illustrates the use of some of these customization +features:: + + P = ct.tf([0.02], [1, 0.1, 0.01]) # servomechanism + C1 = ct.tf([1, 1], [1, 0]) # unstable + L1 = P * C1 + C2 = ct.tf([1, 0.05], [1, 0]) # stable + L2 = P * C2 + + plt.rcParams.update(ct.rcParams) + fig = plt.figure(figsize=[7, 4]) + ax_mag = fig.add_subplot(2, 2, 1) + ax_phase = fig.add_subplot(2, 2, 3) + ax_nyquist = fig.add_subplot(1, 2, 2) + + ct.bode_plot( + [L1, L2], ax=[ax_mag, ax_phase], + label=["$L_1$ (unstable)", "$L_2$ (unstable)"], + show_legend=False) + ax_mag.set_title("Bode plot for $L_1$, $L_2$") + ax_mag.tick_params(labelbottom=False) + fig.align_labels() + + ct.nyquist_plot(L1, ax=ax_nyquist, label="$L_1$ (unstable)") + ct.nyquist_plot( + L2, ax=ax_nyquist, label="$L_2$ (stable)", + max_curve_magnitude=22, legend_loc='upper right') + ax_nyquist.set_title("Nyquist plot for $L_1$, $L_2$") + + fig.suptitle("Loop analysis for servomechanism control design") + plt.tight_layout() + +.. image:: ctrlplot-servomech.png + +As this example illustrates, python-control plotting functions and +Matplotlib plotting functions can generally be intermixed. One type of +plot for which this does not currently work is pole/zero plots with a +continuous time omega-damping grid (including root locus diagrams), due to +the way that axes grids are implemented. As a workaround, the +:func:`~control.pole_zero_subplots` command can be used to create an array +of subplots with different grid types, as illustrated in the following +example:: + + ax_array = ct.pole_zero_subplots(2, 1, grid=[True, False]) + sys1 = ct.tf([1, 2], [1, 2, 3], name='sys1') + sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='sys2') + ct.root_locus_plot([sys1, sys2], ax=ax_array[0, 0]) + cplt = ct.root_locus_plot([sys1, sys2], ax=ax_array[1, 0]) + cplt.set_plot_title("Root locus plots (w/ specified axes)") + +.. image:: ctrlplot-pole_zero_subplots.png + +Alternatively, turning off the omega-damping grid (using ``grid=False`` or +``grid='empty'``) allows use of Matplotlib layout commands. + + Response and plotting functions =============================== @@ -431,7 +611,7 @@ Response functions Response functions take a system or list of systems and return a response object that can be used to retrieve information about the system (e.g., the number of encirclements for a Nyquist plot) as well as plotting (via the -`plot` method). +``plot`` method). .. autosummary:: :toctree: generated/ @@ -480,18 +660,19 @@ returned values from plotting routines. :toctree: generated/ ~control.combine_time_responses - ~control.get_plot_axes - ~control.suptitle + ~control.pole_zero_subplots + ~control.reset_rcParams -Response classes ----------------- +Response and plotting classes +----------------------------- The following classes are used in generating response data. .. autosummary:: :toctree: generated/ + ~control.ControlPlot ~control.DescribingFunctionResponse ~control.FrequencyResponseData ~control.FrequencyResponseList diff --git a/doc/pzmap-siso_ctime-default.png b/doc/pzmap-siso_ctime-default.png index 1caa7cadf..fd8b18eef 100644 Binary files a/doc/pzmap-siso_ctime-default.png and b/doc/pzmap-siso_ctime-default.png differ diff --git a/doc/rlocus-siso_ctime-clicked.png b/doc/rlocus-siso_ctime-clicked.png index dff339371..e7d7a1001 100644 Binary files a/doc/rlocus-siso_ctime-clicked.png and b/doc/rlocus-siso_ctime-clicked.png differ diff --git a/doc/rlocus-siso_ctime-default.png b/doc/rlocus-siso_ctime-default.png index 636951ed5..8a1984316 100644 Binary files a/doc/rlocus-siso_ctime-default.png and b/doc/rlocus-siso_ctime-default.png differ diff --git a/doc/rlocus-siso_dtime-default.png b/doc/rlocus-siso_dtime-default.png index 301778729..4ae6f09c6 100644 Binary files a/doc/rlocus-siso_dtime-default.png and b/doc/rlocus-siso_dtime-default.png differ diff --git a/doc/rlocus-siso_multiple-nogrid.png b/doc/rlocus-siso_multiple-nogrid.png index 07ece6505..9976ea81a 100644 Binary files a/doc/rlocus-siso_multiple-nogrid.png and b/doc/rlocus-siso_multiple-nogrid.png differ diff --git a/doc/timeplot-mimo_ioresp-mt_tr.png b/doc/timeplot-mimo_ioresp-mt_tr.png index e4c800086..2371d50b6 100644 Binary files a/doc/timeplot-mimo_ioresp-mt_tr.png and b/doc/timeplot-mimo_ioresp-mt_tr.png differ diff --git a/doc/timeplot-mimo_ioresp-ov_lm.png b/doc/timeplot-mimo_ioresp-ov_lm.png index 27dd89159..d65056e0f 100644 Binary files a/doc/timeplot-mimo_ioresp-ov_lm.png and b/doc/timeplot-mimo_ioresp-ov_lm.png differ diff --git a/doc/timeplot-mimo_step-default.png b/doc/timeplot-mimo_step-default.png index 877764fbf..4d9a87456 100644 Binary files a/doc/timeplot-mimo_step-default.png and b/doc/timeplot-mimo_step-default.png differ diff --git a/doc/timeplot-mimo_step-linestyle.png b/doc/timeplot-mimo_step-linestyle.png index 9685ea6fa..1db7d2a4b 100644 Binary files a/doc/timeplot-mimo_step-linestyle.png and b/doc/timeplot-mimo_step-linestyle.png differ diff --git a/doc/timeplot-mimo_step-pi_cs.png b/doc/timeplot-mimo_step-pi_cs.png index 6046c8cce..bbeb6b189 100644 Binary files a/doc/timeplot-mimo_step-pi_cs.png and b/doc/timeplot-mimo_step-pi_cs.png differ diff --git a/examples/plot_gallery.py b/examples/plot_gallery.py index 272de3d8e..d7a700c91 100644 --- a/examples/plot_gallery.py +++ b/examples/plot_gallery.py @@ -120,12 +120,13 @@ def invpend_update(t, x, u, params): # root locus with create_figure("Root locus plot") as fig: - ax1, ax2 = fig.subplots(2, 1) + ax_array = ct.pole_zero_subplots(2, 1, grid=[True, False]) + ax1, ax2 = ax_array[:, 0] sys1 = ct.tf([1, 2], [1, 2, 3], name='sys1') sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='sys2') - ct.root_locus_plot([sys1, sys2], grid=True, ax=ax1) - ct.root_locus_plot([sys1, sys2], grid=False, ax=ax2) - print(" -- BUG: should have 2 x 1 array of plots") + ct.root_locus_plot([sys1, sys2], ax=ax1) + ct.root_locus_plot([sys1, sys2], ax=ax2) + plt.suptitle("Root locus plots (w/ specified axes)", fontsize='medium') # sisotool with create_figure("sisotool"): diff --git a/pyproject.toml b/pyproject.toml index f3df75f1d..649dcad5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dynamic = ["version"] packages = ["control"] [project.optional-dependencies] -test = ["pytest", "pytest-timeout"] +test = ["pytest", "pytest-timeout", "ruff"] slycot = [ "slycot>=0.4.0" ] cvxopt = [ "cvxopt>=1.2.0" ] @@ -56,3 +56,9 @@ addopts = "-ra" filterwarnings = [ "error:.*matrix subclass:PendingDeprecationWarning", ] + +[tool.ruff.lint] +select = ['D', 'E', 'W', 'DOC'] + +[tool.ruff.lint.pydocstyle] +convention = 'numpy'