From 782e526059f17dc15ec68f2d1eb7855ece740969 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 16 Apr 2022 15:12:25 +0200 Subject: [PATCH 1/5] Improvements to Nichols chart plotting Clip closed-loop contour labels. Add labels for constant closed-loop phase contours. Use smaller of data or view extents when deciding on how big, in phase, a chart to create. Use more uniformly spaced closed-loop phase contours, and use more widely-spaced contours when phase extent is large. Add optional `ax` argument, for axes to add grid to. --- control/nichols.py | 81 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/control/nichols.py b/control/nichols.py index a643d8580..88ff22974 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -51,6 +51,8 @@ import numpy as np import matplotlib.pyplot as plt +import matplotlib.transforms + from .ctrlutil import unwrap from .freqplot import _default_frequency_range from . import config @@ -119,7 +121,18 @@ def nichols_plot(sys_list, omega=None, grid=None): nichols_grid() -def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): +def _inner_extents(ax): + # intersection of data and view extents + # if intersection empty, return view extents + _inner = matplotlib.transforms.Bbox.intersection(ax.viewLim, ax.dataLim) + if _inner is None: + return ax.ViewLim.extents + else: + return _inner.extents + + +def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, + label_cl_phases=True): """Nichols chart grid Plots a Nichols chart grid on the current axis, or creates a new chart @@ -136,8 +149,14 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): line_style : string, optional :doc:`Matplotlib linestyle \ ` - + ax : matplotlib.axes.Axes, optional + Axes to add grid to. If ``None``, use ``plt.gca()``. + label_cl_phases: bool, optional + If True, closed-loop phase lines will be labelled. """ + if ax is None: + ax = plt.gca() + # Default chart size ol_phase_min = -359.99 ol_phase_max = 0.0 @@ -145,8 +164,8 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): ol_mag_max = default_ol_mag_max = 50.0 # Find bounds of the current dataset, if there is one. - if plt.gcf().gca().has_data(): - ol_phase_min, ol_phase_max, ol_mag_min, ol_mag_max = plt.axis() + if ax.has_data(): + ol_phase_min, ol_mag_min, ol_phase_max, ol_mag_max = _inner_extents(ax) # M-circle magnitudes. if cl_mags is None: @@ -165,17 +184,18 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): ol_mag_min + cl_mag_step, cl_mag_step) cl_mags = np.concatenate((extended_cl_mags, key_cl_mags)) + phase_offset_min = 360.0*np.ceil(ol_phase_min/360.0) + phase_offset_max = 360.0*np.ceil(ol_phase_max/360.0) + 360.0 + # N-circle phases (should be in the range -360 to 0) if cl_phases is None: - # Choose a reasonable set of default phases (denser if the open-loop - # data is restricted to a relatively small range of phases). - key_cl_phases = np.array([-0.25, -45.0, -90.0, -180.0, -270.0, - -325.0, -359.75]) - if np.abs(ol_phase_max - ol_phase_min) < 90.0: - other_cl_phases = np.arange(-10.0, -360.0, -10.0) - else: - other_cl_phases = np.arange(-10.0, -360.0, -20.0) - cl_phases = np.concatenate((key_cl_phases, other_cl_phases)) + # aim for 9 lines, but always show (-360+eps, -180, -eps) + # smallest spacing is 45, biggest is 180 + phase_span = phase_offset_max - phase_offset_min + spacing = np.clip(round(phase_span / 8 / 45) * 45, 45, 180) + key_cl_phases = np.array([-0.25, -359.75]) + other_cl_phases = np.arange(-spacing, -360.0, -spacing) + cl_phases = np.unique(np.concatenate((key_cl_phases, other_cl_phases))) else: assert ((-360.0 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0)) @@ -196,27 +216,46 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted'): # over the range -360 < phase < 0. Given the range # the base chart is computed over, the phase offset should be 0 # for -360 < ol_phase_min < 0. - phase_offset_min = 360.0*np.ceil(ol_phase_min/360.0) - phase_offset_max = 360.0*np.ceil(ol_phase_max/360.0) + 360.0 phase_offsets = np.arange(phase_offset_min, phase_offset_max, 360.0) for phase_offset in phase_offsets: # Draw M and N contours - plt.plot(m_phase + phase_offset, m_mag, color='lightgray', + ax.plot(m_phase + phase_offset, m_mag, color='lightgray', linestyle=line_style, zorder=0) - plt.plot(n_phase + phase_offset, n_mag, color='lightgray', + ax.plot(n_phase + phase_offset, n_mag, color='lightgray', linestyle=line_style, zorder=0) # Add magnitude labels for x, y, m in zip(m_phase[:][-1] + phase_offset, m_mag[:][-1], cl_mags): align = 'right' if m < 0.0 else 'left' - plt.text(x, y, str(m) + ' dB', size='small', ha=align, - color='gray') + ax.text(x, y, str(m) + ' dB', size='small', ha=align, + color='gray', clip_on=True) + + # phase labels + if label_cl_phases: + for x, y, p in zip(n_phase[:][0] + phase_offset, + n_mag[:][0], + cl_phases): + if p > -175: + align = 'right' + elif p > -185: + align = 'center' + else: + align = 'left' + ax.text(x, y, f'{round(p)}\N{DEGREE SIGN}', + size='small', + ha=align, + va='bottom', + color='gray', + clip_on=True) + # Fit axes to generated chart - plt.axis([phase_offset_min - 360.0, phase_offset_max - 360.0, - np.min(cl_mags), np.max([ol_mag_max, default_ol_mag_max])]) + ax.axis([phase_offset_min - 360.0, + phase_offset_max - 360.0, + np.min(np.concatenate([cl_mags,[ol_mag_min]])), + np.max([ol_mag_max, default_ol_mag_max])]) # # Utility functions From 3281a8d6eafe8980eb1cf967cb357bd86f292912 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Wed, 27 Apr 2022 10:05:23 +0200 Subject: [PATCH 2/5] nichols_grid returns artists of grid elements; further tighten phase extent --- control/nichols.py | 68 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/control/nichols.py b/control/nichols.py index 88ff22974..69546678b 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -153,6 +153,19 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, Axes to add grid to. If ``None``, use ``plt.gca()``. label_cl_phases: bool, optional If True, closed-loop phase lines will be labelled. + + Returns + ------- + cl_mag_lines: list of `matplotlib.line.Line2D` + The constant closed-loop gain contours + cl_phase_lines: list of `matplotlib.line.Line2D` + The constant closed-loop phase contours + cl_mag_labels: list of `matplotlib.text.Text` + mcontour labels; each entry corresponds to the respective entry + in ``cl_mag_lines`` + cl_phase_labels: list of `matplotlib.text.Text` + ncontour labels; each entry corresponds to the respective entry + in ``cl_phase_lines`` """ if ax is None: ax = plt.gca() @@ -163,8 +176,8 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, ol_mag_min = -40.0 ol_mag_max = default_ol_mag_max = 50.0 - # Find bounds of the current dataset, if there is one. if ax.has_data(): + # Find extent of intersection the current dataset or view ol_phase_min, ol_mag_min, ol_phase_max, ol_mag_max = _inner_extents(ax) # M-circle magnitudes. @@ -184,20 +197,22 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, ol_mag_min + cl_mag_step, cl_mag_step) cl_mags = np.concatenate((extended_cl_mags, key_cl_mags)) - phase_offset_min = 360.0*np.ceil(ol_phase_min/360.0) - phase_offset_max = 360.0*np.ceil(ol_phase_max/360.0) + 360.0 + # a minimum 360deg extent containing the phases + phase_round_max = 360.0*np.ceil(ol_phase_max/360.0) + phase_round_min = min(phase_round_max-360, + 360.0*np.floor(ol_phase_min/360.0)) # N-circle phases (should be in the range -360 to 0) if cl_phases is None: # aim for 9 lines, but always show (-360+eps, -180, -eps) # smallest spacing is 45, biggest is 180 - phase_span = phase_offset_max - phase_offset_min + phase_span = phase_round_max - phase_round_min spacing = np.clip(round(phase_span / 8 / 45) * 45, 45, 180) key_cl_phases = np.array([-0.25, -359.75]) other_cl_phases = np.arange(-spacing, -360.0, -spacing) cl_phases = np.unique(np.concatenate((key_cl_phases, other_cl_phases))) - else: - assert ((-360.0 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0)) + elif not ((-360 < np.min(cl_phases)) and (np.max(cl_phases) < 0.0)): + raise ValueError('cl_phases must between -360 and 0, exclusive') # Find the M-contours m = m_circles(cl_mags, phase_min=np.min(cl_phases), @@ -216,21 +231,29 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, # over the range -360 < phase < 0. Given the range # the base chart is computed over, the phase offset should be 0 # for -360 < ol_phase_min < 0. - phase_offsets = np.arange(phase_offset_min, phase_offset_max, 360.0) + phase_offsets = 360 + np.arange(phase_round_min, phase_round_max, 360.0) + + cl_mag_lines = [] + cl_phase_lines = [] + cl_mag_labels = [] + cl_phase_labels = [] for phase_offset in phase_offsets: # Draw M and N contours - ax.plot(m_phase + phase_offset, m_mag, color='lightgray', - linestyle=line_style, zorder=0) - ax.plot(n_phase + phase_offset, n_mag, color='lightgray', - linestyle=line_style, zorder=0) + cl_mag_lines.extend( + ax.plot(m_phase + phase_offset, m_mag, color='lightgray', + linestyle=line_style, zorder=0)) + cl_phase_lines.extend( + ax.plot(n_phase + phase_offset, n_mag, color='lightgray', + linestyle=line_style, zorder=0)) # Add magnitude labels for x, y, m in zip(m_phase[:][-1] + phase_offset, m_mag[:][-1], cl_mags): align = 'right' if m < 0.0 else 'left' - ax.text(x, y, str(m) + ' dB', size='small', ha=align, - color='gray', clip_on=True) + cl_mag_labels.append( + ax.text(x, y, str(m) + ' dB', size='small', ha=align, + color='gray', clip_on=True)) # phase labels if label_cl_phases: @@ -243,20 +266,23 @@ def nichols_grid(cl_mags=None, cl_phases=None, line_style='dotted', ax=None, align = 'center' else: align = 'left' - ax.text(x, y, f'{round(p)}\N{DEGREE SIGN}', - size='small', - ha=align, - va='bottom', - color='gray', - clip_on=True) + cl_phase_labels.append( + ax.text(x, y, f'{round(p)}\N{DEGREE SIGN}', + size='small', + ha=align, + va='bottom', + color='gray', + clip_on=True)) # Fit axes to generated chart - ax.axis([phase_offset_min - 360.0, - phase_offset_max - 360.0, + ax.axis([phase_round_min, + phase_round_max, np.min(np.concatenate([cl_mags,[ol_mag_min]])), np.max([ol_mag_max, default_ol_mag_max])]) + return cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels + # # Utility functions # From b813e0caea6f994ec0f718043218fda1255799f6 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Wed, 27 Apr 2022 10:05:56 +0200 Subject: [PATCH 3/5] Add tests for nichols_grid --- control/tests/nichols_test.py | 74 ++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/control/tests/nichols_test.py b/control/tests/nichols_test.py index 4cdfcaa65..cd28dd9a4 100644 --- a/control/tests/nichols_test.py +++ b/control/tests/nichols_test.py @@ -3,9 +3,11 @@ RMM, 31 Mar 2011 """ +import matplotlib.pyplot as plt + import pytest -from control import StateSpace, nichols_plot, nichols +from control import StateSpace, nichols_plot, nichols, nichols_grid, pade, tf @pytest.fixture() @@ -26,3 +28,73 @@ def test_nichols(tsys, mplcleanup): def test_nichols_alias(tsys, mplcleanup): """Test the control.nichols alias and the grid=False parameter""" nichols(tsys, grid=False) + + +class TestNicholsGrid: + def test_ax(self): + # check grid is plotted into gca, or specified axis + fig, axs = plt.subplots(2,2) + plt.sca(axs[0,1]) + + cl_mag_lines = nichols_grid()[1] + assert cl_mag_lines[0].axes is axs[0, 1] + + cl_mag_lines = nichols_grid(ax=axs[1,1])[1] + assert cl_mag_lines[0].axes is axs[1, 1] + # nichols_grid didn't change what the "current axes" are + assert plt.gca() is axs[0, 1] + + + def test_cl_phase_label_control(self): + # test label_cl_phases argument + plt.clf() + cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \ + = nichols_grid() + assert len(cl_phase_labels) > 0 + + plt.clf() + cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \ + = nichols_grid(label_cl_phases=False) + assert len(cl_phase_labels) == 0 + + + def test_labels_clipped(self): + # regression test: check that contour labels are clipped + plt.clf() + mcontours, ncontours, mlabels, nlabels = nichols_grid() + assert all(ml.get_clip_on() for ml in mlabels) + assert all(nl.get_clip_on() for nl in nlabels) + + + def test_minimal_phase(self): + # regression test: phase extent is minimal + g = tf([1],[1,1]) * tf([1],[1/1, 2*0.1/1, 1]) + plt.clf() + nichols(g) + ax = plt.gca() + assert ax.get_xlim()[1] <= 0 + + + def test_fixed_view(self): + # respect xlim, ylim set by user + g = (tf([1],[1/1, 2*0.01/1, 1]) + * tf([1],[1/100**2, 2*0.001/100, 1]) + * tf(*pade(0.01, 5))) + + # normally a broad axis + plt.clf() + m, p = nichols(g) + + assert(plt.xlim()[0] == -1440) + assert(plt.ylim()[0] <= -240) + + plt.clf() + nichols(g, grid=False) + + # zoom in + plt.axis([-360,0,-40,50]) + + # nichols_grid doesn't expand limits + nichols_grid() + assert(plt.xlim()[0] == -360) + assert(plt.ylim()[1] >= -40) From 71f3b6134b0622d337eefa3521a3409c0de3ef9b Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Wed, 27 Apr 2022 10:11:33 +0200 Subject: [PATCH 4/5] Fix nichols_grid test --- control/tests/nichols_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/tests/nichols_test.py b/control/tests/nichols_test.py index cd28dd9a4..d81ee9d18 100644 --- a/control/tests/nichols_test.py +++ b/control/tests/nichols_test.py @@ -83,7 +83,7 @@ def test_fixed_view(self): # normally a broad axis plt.clf() - m, p = nichols(g) + nichols(g) assert(plt.xlim()[0] == -1440) assert(plt.ylim()[0] <= -240) From 59cd8728bd4835897471674b301b0cf605ac4bc3 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Wed, 27 Apr 2022 12:58:04 +0200 Subject: [PATCH 5/5] In nichols_grid tests: use mplcleanup, and remove plt.clf calls --- control/tests/nichols_test.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/control/tests/nichols_test.py b/control/tests/nichols_test.py index d81ee9d18..90ea74cf7 100644 --- a/control/tests/nichols_test.py +++ b/control/tests/nichols_test.py @@ -30,6 +30,7 @@ def test_nichols_alias(tsys, mplcleanup): nichols(tsys, grid=False) +@pytest.mark.usefixtures("mplcleanup") class TestNicholsGrid: def test_ax(self): # check grid is plotted into gca, or specified axis @@ -47,12 +48,10 @@ def test_ax(self): def test_cl_phase_label_control(self): # test label_cl_phases argument - plt.clf() cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \ = nichols_grid() assert len(cl_phase_labels) > 0 - plt.clf() cl_mag_lines, cl_phase_lines, cl_mag_labels, cl_phase_labels \ = nichols_grid(label_cl_phases=False) assert len(cl_phase_labels) == 0 @@ -60,7 +59,6 @@ def test_cl_phase_label_control(self): def test_labels_clipped(self): # regression test: check that contour labels are clipped - plt.clf() mcontours, ncontours, mlabels, nlabels = nichols_grid() assert all(ml.get_clip_on() for ml in mlabels) assert all(nl.get_clip_on() for nl in nlabels) @@ -69,7 +67,6 @@ def test_labels_clipped(self): def test_minimal_phase(self): # regression test: phase extent is minimal g = tf([1],[1,1]) * tf([1],[1/1, 2*0.1/1, 1]) - plt.clf() nichols(g) ax = plt.gca() assert ax.get_xlim()[1] <= 0 @@ -82,13 +79,11 @@ def test_fixed_view(self): * tf(*pade(0.01, 5))) # normally a broad axis - plt.clf() nichols(g) assert(plt.xlim()[0] == -1440) assert(plt.ylim()[0] <= -240) - plt.clf() nichols(g, grid=False) # zoom in