diff --git a/control/ctrlplot.py b/control/ctrlplot.py index 7f6a37af4..bb57f19fd 100644 --- a/control/ctrlplot.py +++ b/control/ctrlplot.py @@ -334,7 +334,7 @@ def reset_rcParams(): def _process_ax_keyword( axs, shape=(1, 1), rcParams=None, squeeze=False, clear_text=False, - create_axes=True): + create_axes=True, sharex=False, sharey=False): """Process ax keyword to plotting commands. This function processes the `ax` keyword to plotting commands. If no @@ -364,10 +364,12 @@ def _process_ax_keyword( with plt.rc_context(rcParams): if len(axs) != 0 and create_axes: # Create a new figure - fig, axs = plt.subplots(*shape, squeeze=False) + fig, axs = plt.subplots( + *shape, sharex=sharex, sharey=sharey, squeeze=False) elif create_axes: # Create new axes on (empty) figure - axs = fig.subplots(*shape, squeeze=False) + axs = fig.subplots( + *shape, sharex=sharex, sharey=sharey, squeeze=False) else: # Create an empty array and let user create axes axs = np.full(shape, None) diff --git a/control/freqplot.py b/control/freqplot.py index d80ee5a8f..544425298 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -198,7 +198,12 @@ def bode_plot( Determine whether and how axis limits are shared between the indicated variables. Can be set set to 'row' to share across all subplots in a row, 'col' to set across all subplots in a column, or - `False` to allow independent limits. + `False` to allow independent limits. Note: if `sharex` is given, + it sets the value of `share_frequency`; if `sharey` is given, it + sets the value of both `share_magnitude` and `share_phase`. + Default values are 'row' for `share_magnitude` and `share_phase', + 'col', for `share_frequency`, and can be set using + config.defaults['freqplot.share_']. 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 @@ -228,12 +233,12 @@ def bode_plot( Notes ----- - 1. Starting with python-control version 0.10, `bode_plot`returns an - array of lines instead of magnitude, phase, and frequency. To - recover the old behavior, call `bode_plot` with `plot=True`, which - will force the legacy values (mag, phase, omega) to be returned - (with a warning). To obtain just the frequency response of a system - (or list of systems) without plotting, use the + 1. Starting with python-control version 0.10, `bode_plot` returns a + :class:`ControlPlot` object instead of magnitude, phase, and + frequency. To recover the old behavior, call `bode_plot` with + `plot=True`, which will force the legacy values (mag, phase, omega) + to be returned (with a warning). To obtain just the frequency + response of a system (or list of systems) without plotting, use the :func:`~control.frequency_response` command. 2. If a discrete time model is given, the frequency response is plotted @@ -583,7 +588,7 @@ def bode_plot( # axes are available and no updates should be made. # - # Utility function to turn off sharing + # Utility function to turn on sharing def _share_axes(ref, share_map, axis): ref_ax = ax_array[ref] for index in np.nditer(share_map, flags=["refs_ok"]): diff --git a/control/tests/ctrlplot_test.py b/control/tests/ctrlplot_test.py index 1baab8761..78ec7d895 100644 --- a/control/tests/ctrlplot_test.py +++ b/control/tests/ctrlplot_test.py @@ -2,6 +2,7 @@ # RMM, 27 Jun 2024 import inspect +import itertools import warnings import matplotlib.pyplot as plt @@ -577,6 +578,45 @@ def test_plot_title_processing(resp_fcn, plot_fcn): assert "title : str, optional" in plot_fcn.__doc__ +@pytest.mark.parametrize("plot_fcn", multiaxes_plot_fcns) +@pytest.mark.usefixtures('mplcleanup') +def test_tickmark_label_processing(plot_fcn): + # Generate the response that we will use for plotting + top_row, bot_row = 0, -1 + match plot_fcn: + case ct.bode_plot: + resp = ct.frequency_response(ct.rss(4, 2, 2)) + top_row = 1 + case ct.time_response_plot: + resp = ct.step_response(ct.rss(4, 2, 2)) + case ct.gangof4_plot: + resp = ct.gangof4_response(ct.rss(4, 1, 1), ct.rss(3, 1, 1)) + case _: + pytest.fail("unknown plot_fcn") + + # Turn off axis sharing => all axes have ticklabels + cplt = resp.plot(sharex=False, sharey=False) + for i, j in itertools.product( + range(cplt.axes.shape[0]), range(cplt.axes.shape[1])): + assert len(cplt.axes[i, j].get_xticklabels()) > 0 + assert len(cplt.axes[i, j].get_yticklabels()) > 0 + plt.clf() + + # Turn on axis sharing => only outer axes have ticklabels + cplt = resp.plot(sharex=True, sharey=True) + for i, j in itertools.product( + range(cplt.axes.shape[0]), range(cplt.axes.shape[1])): + if i < cplt.axes.shape[0] - 1: + assert len(cplt.axes[i, j].get_xticklabels()) == 0 + else: + assert len(cplt.axes[i, j].get_xticklabels()) > 0 + + if j > 0: + assert len(cplt.axes[i, j].get_yticklabels()) == 0 + else: + assert len(cplt.axes[i, j].get_yticklabels()) > 0 + + @pytest.mark.parametrize("resp_fcn, plot_fcn", resp_plot_fcns) @pytest.mark.usefixtures('mplcleanup', 'editsdefaults') def test_rcParams(resp_fcn, plot_fcn): diff --git a/control/timeplot.py b/control/timeplot.py index 9f389b4ab..1c7efe894 100644 --- a/control/timeplot.py +++ b/control/timeplot.py @@ -32,6 +32,8 @@ {'color': c} for c in [ 'tab:red', 'tab:purple', 'tab:brown', 'tab:olive', 'tab:cyan']], 'timeplot.time_label': "Time [s]", + 'timeplot.sharex': 'col', + 'timeplot.sharey': False, } @@ -66,6 +68,14 @@ def time_response_plot( overlay_signals : bool, optional If set to True, combine all input and output signals onto a single plot (for each). + sharex, sharey : str or bool, optional + Determine whether and how x- and y-axis limits are shared between + subplots. Can be set set to 'row' to share across all subplots in + a row, 'col' to set across all subplots in a column, 'all' to share + across all subplots, or `False` to allow independent limits. + Default values are `False` for `sharex' and 'col' for `sharey`, and + can be set using config.defaults['timeplot.sharex'] and + config.defaults['timeplot.sharey']. transpose : bool, optional If transpose is False (default), signals are plotted from top to bottom, starting with outputs (if plotted) and then inputs. @@ -176,6 +186,8 @@ def time_response_plot( # # Set up defaults ax_user = ax + sharex = config._get_param('timeplot', 'sharex', kwargs, pop=True) + sharey = config._get_param('timeplot', 'sharey', kwargs, pop=True) time_label = config._get_param( 'timeplot', 'time_label', kwargs, _timeplot_defaults, pop=True) rcParams = config._get_param('ctrlplot', 'rcParams', kwargs, pop=True) @@ -289,7 +301,8 @@ def time_response_plot( nrows, ncols = ncols, nrows # See if we can use the current figure axes - fig, ax_array = _process_ax_keyword(ax, (nrows, ncols), rcParams=rcParams) + fig, ax_array = _process_ax_keyword( + ax, (nrows, ncols), rcParams=rcParams, sharex=sharex, sharey=sharey) legend_loc, legend_map, show_legend = _process_legend_keywords( kwargs, (nrows, ncols), 'center right') diff --git a/doc/plotting.rst b/doc/plotting.rst index 167f5d001..a4611f1bd 100644 --- a/doc/plotting.rst +++ b/doc/plotting.rst @@ -536,6 +536,19 @@ various ways. The following general rules apply: The default values for style parameters for control plots can be restored using :func:`~control.reset_rcParams`. +* For multi-input, multi-output time and frequency domain plots, the + `sharex` and `sharey` keyword arguments can be used to determine whether + and how axis limits are shared between the individual subplots. Setting + the keyword to 'row' will share the axes limits across all subplots in a + row, 'col' will share across all subplots in a column, 'all' will share + across all subplots in the figure, and `False` will allow independent + limits for each subplot. + + For Bode plots, the `share_magnitude` and `share_phase` keyword arguments + can be used to independently control axis limit sharing for the magnitude + and phase portions of the plot, and `share_frequency` can be used instead + of `sharex`. + * 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 diff --git a/doc/timeplot-mimo_ioresp-mt_tr.png b/doc/timeplot-mimo_ioresp-mt_tr.png index 2371d50b6..7ddbe3c49 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 d65056e0f..987a08f34 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 4d9a87456..5850dcb87 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-pi_cs.png b/doc/timeplot-mimo_step-pi_cs.png index bbeb6b189..44a630f55 100644 Binary files a/doc/timeplot-mimo_step-pi_cs.png and b/doc/timeplot-mimo_step-pi_cs.png differ