Repository: python-control/python-control
Pull request #1040 (2024-08-13 14:06:56)

Update shared axes processing in plot_time_response
This PR updates the way that tickmarks are labeled on shared axes in time response plots, in response to a comment from @slivingston in #1034.  Tickmark labels are now omitted on shared axes, consistent with frequency response plots.

To allow tickmark labels to be added, two new keyword arguments are included in `time_response_plot`: `sharex` and `sharey`.  These are passthrough arguments to `matplotlib.pyplot.subplots` and allow axes sharing to be turned on and off in the same way as `bode_plot` => frequency and time response plots are now consistent.

Also added unit tests checking for consistency and updated the user documentation and relevant docstrings.

| Without tickmark labels  | With tickmark labels  |
|---|---|
| Version 0.10.0: <br> <img width=320 alt="plot_gallery-0 10 0-2024-06-20" src="https://github.com/user-attachments/assets/b254d19c-7066-4f6f-9d92-c9c7849a6177"> | Previous to this PR (from #1034): <br> <img width=320 alt="plot_gallery-0 10 0-191-g373ff11e-2024-08-08" src="https://github.com/user-attachments/assets/8d400463-c4ec-427d-904f-f5d7c517442a"> |
| After this PR (using default settings): <br> <img width=320 alt="plot_gallery-0 10 0-201-gc9a07c13-2024-08-09" src="https://github.com/user-attachments/assets/bcdc687d-0cbf-4f72-8972-ac90b21c0b60"> | After this PR (using `sharex=False`): <br> <img width=320 alt="step_response-sharex_false" src="https://github.com/user-attachments/assets/45937685-0907-4fe7-a3c5-a581aa2ec878">



Patch:
diff --git a/control/ctrlplot.py b/control/ctrlplot.py
index 7f6a37af..bb57f19f 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 d80ee5a8..54442529 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_<axis>'].
     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/timeplot.py b/control/timeplot.py
index 9f389b4a..1c7efe89 100644
--- a/control/timeplot.py
+++ b/control/timeplot.py
@@ -32,6 +32,8 @@ _timeplot_defaults = {
         {'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 167f5d00..a4611f1b 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 "<Type> plot
   for <syslist>" where <syslist> is a list of the sys names contained in


Test patch:
diff --git a/control/tests/ctrlplot_test.py b/control/tests/ctrlplot_test.py
index 1baab876..78ec7d89 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):