diff --git a/control/grid.py b/control/grid.py index a383dd27c..07ca4a59d 100644 --- a/control/grid.py +++ b/control/grid.py @@ -12,17 +12,20 @@ class FormatterDMS(object): '''Transforms angle ticks to damping ratios''' def __call__(self, direction, factor, values): - angles_deg = values/factor + angles_deg = np.asarray(values)/factor damping_ratios = np.cos((180-angles_deg) * np.pi/180) ret = ["%.2f" % val for val in damping_ratios] return ret class ModifiedExtremeFinderCycle(angle_helper.ExtremeFinderCycle): - '''Changed to allow only left hand-side polar grid''' + '''Changed to allow only left hand-side polar grid + + https://matplotlib.org/_modules/mpl_toolkits/axisartist/angle_helper.html#ExtremeFinderCycle.__call__ + ''' def __call__(self, transform_xy, x1, y1, x2, y2): - x_, y_ = np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny) - x, y = np.meshgrid(x_, y_) + x, y = np.meshgrid( + np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny)) lon, lat = transform_xy(np.ravel(x), np.ravel(y)) with np.errstate(invalid='ignore'): @@ -31,17 +34,33 @@ def __call__(self, transform_xy, x1, y1, x2, y2): # Changed from 180 to 360 to be able to span only # 90-270 (left hand side) lon -= 360. * ((lon - lon0) > 360.) - if self.lat_cycle is not None: + if self.lat_cycle is not None: # pragma: no cover lat0 = np.nanmin(lat) - # Changed from 180 to 360 to be able to span only - # 90-270 (left hand side) - lat -= 360. * ((lat - lat0) > 360.) + lat -= 360. * ((lat - lat0) > 180.) lon_min, lon_max = np.nanmin(lon), np.nanmax(lon) lat_min, lat_max = np.nanmin(lat), np.nanmax(lat) lon_min, lon_max, lat_min, lat_max = \ - self._adjust_extremes(lon_min, lon_max, lat_min, lat_max) + self._add_pad(lon_min, lon_max, lat_min, lat_max) + + # check cycle + if self.lon_cycle: + lon_max = min(lon_max, lon_min + self.lon_cycle) + if self.lat_cycle: # pragma: no cover + lat_max = min(lat_max, lat_min + self.lat_cycle) + + if self.lon_minmax is not None: + min0 = self.lon_minmax[0] + lon_min = max(min0, lon_min) + max0 = self.lon_minmax[1] + lon_max = min(max0, lon_max) + + if self.lat_minmax is not None: + min0 = self.lat_minmax[0] + lat_min = max(min0, lat_min) + max0 = self.lat_minmax[1] + lat_max = min(max0, lat_max) return lon_min, lon_max, lat_min, lat_max diff --git a/control/pzmap.py b/control/pzmap.py index fe8e551a0..a7752e484 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -58,7 +58,7 @@ # TODO: Implement more elegant cross-style axes. See: # http://matplotlib.sourceforge.net/examples/axes_grid/demo_axisline_style.html # http://matplotlib.sourceforge.net/examples/axes_grid/demo_curvelinear_grid.html -def pzmap(sys, plot=True, grid=False, title='Pole Zero Map', **kwargs): +def pzmap(sys, plot=None, grid=None, title='Pole Zero Map', **kwargs): """ Plot a pole/zero map for a linear system. @@ -87,8 +87,8 @@ def pzmap(sys, plot=True, grid=False, title='Pole Zero Map', **kwargs): plot = kwargs['Plot'] # Get parameter values - plot = config._get_param('rlocus', 'plot', plot, True) - grid = config._get_param('rlocus', 'grid', grid, False) + plot = config._get_param('pzmap', 'plot', plot, True) + grid = config._get_param('pzmap', 'grid', grid, False) if not isinstance(sys, LTI): raise TypeError('Argument ``sys``: must be a linear system.') diff --git a/control/tests/conftest.py b/control/tests/conftest.py index e98bbe1d7..60c3d0de1 100755 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -1,13 +1,39 @@ # contest.py - pytest local plugins and fixtures -import control import os +import matplotlib as mpl import pytest +import control + @pytest.fixture(scope="session", autouse=True) def use_numpy_ndarray(): """Switch the config to use ndarray instead of matrix""" if os.getenv("PYTHON_CONTROL_STATESPACE_ARRAY") == "1": control.config.defaults['statesp.use_numpy_matrix'] = False + + +@pytest.fixture(scope="function") +def editsdefaults(): + """Make sure any changes to the defaults only last during a test""" + restore = control.config.defaults.copy() + yield + control.config.defaults.update(restore) + + +@pytest.fixture(scope="function") +def mplcleanup(): + """Workaround for python2 + + python 2 does not like to mix the original mpl decorator with pytest + fixtures. So we roll our own. + """ + save = mpl.units.registry.copy() + try: + yield + finally: + mpl.units.registry.clear() + mpl.units.registry.update(save) + mpl.pyplot.close("all") diff --git a/control/tests/pzmap_test.py b/control/tests/pzmap_test.py new file mode 100755 index 000000000..8d41807b8 --- /dev/null +++ b/control/tests/pzmap_test.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" pzmap_test.py - test pzmap() + +Created on Thu Aug 20 20:06:21 2020 + +@author: bnavigator +""" + +import matplotlib +import numpy as np +import pytest +from matplotlib import pyplot as plt +from mpl_toolkits.axisartist import Axes as mpltAxes + +from control import TransferFunction, config, pzmap + + +@pytest.mark.parametrize("kwargs", + [pytest.param(dict(), id="default"), + pytest.param(dict(plot=False), id="plot=False"), + pytest.param(dict(plot=True), id="plot=True"), + pytest.param(dict(grid=True), id="grid=True"), + pytest.param(dict(title="My Title"), id="title")]) +@pytest.mark.parametrize("setdefaults", [False, True], ids=["kw", "config"]) +@pytest.mark.parametrize("dt", [0, 1], ids=["s", "z"]) +def test_pzmap(kwargs, setdefaults, dt, editsdefaults, mplcleanup): + """Test pzmap""" + # T from from pvtol-nested example + T = TransferFunction([-9.0250000e-01, -4.7200750e+01, -8.6812900e+02, + +5.6261850e+03, +2.1258472e+05, +8.4724600e+05, + +1.0192000e+06, +2.3520000e+05], + [9.02500000e-03, 9.92862812e-01, 4.96974094e+01, + 1.35705659e+03, 2.09294163e+04, 1.64898435e+05, + 6.54572220e+05, 1.25274600e+06, 1.02420000e+06, + 2.35200000e+05], + dt) + + Pref = [-23.8877+19.3837j, -23.8877-19.3837j, -23.8349+15.7846j, + -23.8349-15.7846j, -5.2320 +0.4117j, -5.2320 -0.4117j, + -2.2246 +0.0000j, -1.5160 +0.0000j, -0.3627 +0.0000j] + Zref = [-23.8877+19.3837j, -23.8877-19.3837j, +14.3637 +0.0000j, + -14.3637 +0.0000j, -2.2246 +0.0000j, -2.0000 +0.0000j, + -0.3000 +0.0000j] + + pzkwargs = kwargs.copy() + if setdefaults: + for k in ['plot', 'grid']: + if k in pzkwargs: + v = pzkwargs.pop(k) + config.set_defaults('pzmap', **{k: v}) + + P, Z = pzmap(T, **pzkwargs) + + np.testing.assert_allclose(P, Pref, rtol=1e-3) + np.testing.assert_allclose(Z, Zref, rtol=1e-3) + + if kwargs.get('plot', True): + ax = plt.gca() + + assert ax.get_title() == kwargs.get('title', 'Pole Zero Map') + + # FIXME: This won't work when zgrid and sgrid are unified + children = ax.get_children() + has_zgrid = False + for c in children: + if isinstance(c, matplotlib.text.Annotation): + if r'\pi' in c.get_text(): + has_zgrid = True + has_sgrid = isinstance(ax, mpltAxes) + + if kwargs.get('grid', False): + assert dt == has_zgrid + assert dt != has_sgrid + else: + assert not has_zgrid + assert not has_sgrid + else: + assert not plt.get_fignums() + + +def test_pzmap_warns(): + with pytest.warns(FutureWarning): + pzmap(TransferFunction([1], [1, 2]), Plot=True) + + +def test_pzmap_raises(): + with pytest.raises(TypeError): + # not an LTI system + pzmap(([1], [1,2]))