diff --git a/control/grid.py b/control/grid.py new file mode 100644 index 000000000..33fc9e975 --- /dev/null +++ b/control/grid.py @@ -0,0 +1,182 @@ +import numpy as np +from numpy import cos, sin, sqrt, linspace, pi, exp +import matplotlib.pyplot as plt +from mpl_toolkits.axisartist import SubplotHost +from mpl_toolkits.axisartist.grid_helper_curvelinear import GridHelperCurveLinear +import mpl_toolkits.axisartist.angle_helper as angle_helper +from matplotlib.projections import PolarAxes +from matplotlib.transforms import Affine2D + +class FormatterDMS(object): + '''Transforms angle ticks to damping ratios''' + def __call__(self,direction,factor,values): + angles_deg = 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''' + 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_) + lon, lat = transform_xy(np.ravel(x), np.ravel(y)) + + with np.errstate(invalid='ignore'): + if self.lon_cycle is not None: + lon0 = np.nanmin(lon) + lon -= 360. * ((lon - lon0) > 360.) # Changed from 180 to 360 to be able to span only 90-270 (left hand side) + if self.lat_cycle is not None: + lat0 = np.nanmin(lat) + lat -= 360. * ((lat - lat0) > 360.) # Changed from 180 to 360 to be able to span only 90-270 (left hand side) + + 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) + + return lon_min, lon_max, lat_min, lat_max + +def sgrid(): + # From matplotlib demos: + # https://matplotlib.org/gallery/axisartist/demo_curvelinear_grid.html + # https://matplotlib.org/gallery/axisartist/demo_floating_axis.html + + # PolarAxes.PolarTransform takes radian. However, we want our coordinate + # system in degree + tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform() + # polar projection, which involves cycle, and also has limits in + # its coordinates, needs a special method to find the extremes + # (min, max of the coordinate within the view). + + # 20, 20 : number of sampling points along x, y direction + sampling_points = 20 + extreme_finder = ModifiedExtremeFinderCycle(sampling_points, sampling_points, + lon_cycle=360, + lat_cycle=None, + lon_minmax=(90,270), + lat_minmax=(0, np.inf),) + + grid_locator1 = angle_helper.LocatorDMS(15) + tick_formatter1 = FormatterDMS() + grid_helper = GridHelperCurveLinear(tr, + extreme_finder=extreme_finder, + grid_locator1=grid_locator1, + tick_formatter1=tick_formatter1 + ) + + fig = plt.figure() + ax = SubplotHost(fig, 1, 1, 1, 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_ticks.set_visible(False) + ax.axis[:].invert_ticklabel_direction() + + ax.axis["wnxneg"] = axis = ax.new_floating_axis(0, 180) + axis.set_ticklabel_direction("-") + axis.label.set_visible(False) + ax.axis["wnxpos"] = axis = ax.new_floating_axis(0, 0) + axis.label.set_visible(False) + ax.axis["wnypos"] = axis = ax.new_floating_axis(0, 90) + axis.label.set_visible(False) + axis.set_axis_direction("left") + ax.axis["wnyneg"] = axis = ax.new_floating_axis(0, 270) + axis.label.set_visible(False) + axis.set_axis_direction("left") + axis.invert_ticklabel_direction() + axis.set_ticklabel_direction("-") + + # let left axis shows ticklabels for 1st coordinate (angle) + ax.axis["left"].get_helper().nth_coord_ticks = 0 + ax.axis["right"].get_helper().nth_coord_ticks = 0 + ax.axis["left"].get_helper().nth_coord_ticks = 0 + ax.axis["bottom"].get_helper().nth_coord_ticks = 0 + + fig.add_subplot(ax) + + ### RECTANGULAR X Y AXES WITH SCALE + #par2 = ax.twiny() + #par2.axis["top"].toggle(all=False) + #par2.axis["right"].toggle(all=False) + #new_fixed_axis = par2.get_grid_helper().new_fixed_axis + #par2.axis["left"] = new_fixed_axis(loc="left", + # axes=par2, + # offset=(0, 0)) + #par2.axis["bottom"] = new_fixed_axis(loc="bottom", + # axes=par2, + # offset=(0, 0)) + ### FINISH RECTANGULAR + + ax.grid(True, zorder=0,linestyle='dotted') + + _final_setup(ax) + return ax, fig + +def _final_setup(ax): + ax.set_xlabel('Real') + ax.set_ylabel('Imaginary') + ax.axhline(y=0, color='black', lw=1) + ax.axvline(x=0, color='black', lw=1) + plt.axis('equal') + +def nogrid(): + f = plt.figure() + ax = plt.axes() + + _final_setup(ax) + return ax, f + +def zgrid(zetas=None, wns=None): + '''Draws discrete damping and frequency grid''' + + fig = plt.figure() + ax = fig.gca() + + # Constant damping lines + if zetas is None: + zetas = linspace(0, 0.9, 10) + for zeta in zetas: + # Calculate in polar coordinates + factor = zeta/sqrt(1-zeta**2) + x = linspace(0, sqrt(1-zeta**2),200) + ang = pi*x + mag = exp(-pi*factor*x) + # Draw upper part in retangular coordinates + xret = mag*cos(ang) + yret = mag*sin(ang) + ax.plot(xret,yret, 'k:', lw=1) + # Draw lower part in retangular coordinates + xret = mag*cos(-ang) + yret = mag*sin(-ang) + ax.plot(xret,yret,'k:', lw=1) + # Annotation + an_i = int(len(xret)/2.5) + an_x = xret[an_i] + an_y = yret[an_i] + ax.annotate(str(round(zeta,2)), xy=(an_x, an_y), xytext=(an_x, an_y), size=7) + + # Constant natural frequency lines + if wns is None: + wns = linspace(0, 1, 10) + for a in wns: + # Calculate in polar coordinates + x = linspace(-pi/2,pi/2,200) + ang = pi*a*sin(x) + mag = exp(-pi*a*cos(x)) + # Draw in retangular coordinates + xret = mag*cos(ang) + yret = mag*sin(ang) + ax.plot(xret,yret,'k:', lw=1) + # Annotation + an_i = -1 + an_x = xret[an_i] + an_y = yret[an_i] + num = '{:1.1f}'.format(a) + ax.annotate("$\\frac{"+num+"\pi}{T}$", xy=(an_x, an_y), xytext=(an_x, an_y), size=9) + + _final_setup(ax) + return ax, fig + diff --git a/control/pzmap.py b/control/pzmap.py index ee91ec9b9..252d10011 100644 --- a/control/pzmap.py +++ b/control/pzmap.py @@ -40,15 +40,17 @@ # # $Id:pzmap.py 819 2009-05-29 21:28:07Z murray $ -from numpy import real, imag -from .lti import LTI +from numpy import real, imag, linspace, exp, cos, sin, sqrt +from math import pi +from .lti import LTI, isdtime, isctime +from .grid import sgrid, zgrid, nogrid __all__ = ['pzmap'] # 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, title='Pole Zero Map'): +def pzmap(sys, Plot=True, grid=False, title='Pole Zero Map'): """ Plot a pole/zero map for a linear system. @@ -59,6 +61,8 @@ def pzmap(sys, Plot=True, title='Pole Zero Map'): Plot: bool If ``True`` a graph is generated with Matplotlib, otherwise the poles and zeros are only computed and returned. + grid: boolean (default = False) + If True plot omega-damping grid. Returns ------- @@ -75,18 +79,22 @@ def pzmap(sys, Plot=True, title='Pole Zero Map'): if (Plot): import matplotlib.pyplot as plt + + if grid: + if isdtime(sys, strict=True): + ax, fig = zgrid() + else: + ax, fig = sgrid() + else: + ax, fig = nogrid() + # Plot the locations of the poles and zeros if len(poles) > 0: - plt.scatter(real(poles), imag(poles), s=50, marker='x') + ax.scatter(real(poles), imag(poles), s=50, marker='x', facecolors='k') if len(zeros) > 0: - plt.scatter(real(zeros), imag(zeros), s=50, marker='o', - facecolors='none') - # Add axes - #Somewhat silly workaround - plt.axhline(y=0, color='black') - plt.axvline(x=0, color='black') - plt.xlabel('Re') - plt.ylabel('Im') + ax.scatter(real(zeros), imag(zeros), s=50, marker='o', + facecolors='none', edgecolors='k') + plt.title(title) diff --git a/control/rlocus.py b/control/rlocus.py index e22a31c25..487ca3863 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -47,12 +47,15 @@ # Packages used by this module import numpy as np -from scipy import array, poly1d, row_stack, zeros_like, real, imag +from scipy import array, poly1d, row_stack, zeros_like, real, imag, exp, sin, cos, linspace, sqrt +from math import pi import scipy.signal # signal processing toolbox import pylab # plotting routines from .xferfcn import _convertToTransferFunction from .exception import ControlMIMONotImplemented from functools import partial +from .lti import isdtime +from .grid import sgrid, zgrid, nogrid __all__ = ['root_locus', 'rlocus'] @@ -82,7 +85,7 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, plotstr='-', Plot=True, If True, report mouse clicks when close to the root-locus branches, calculate gain, damping and print grid: boolean (default = False) - If True plot s-plane grid. + If True plot omega-damping grid. Returns ------- @@ -110,12 +113,18 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, plotstr='-', Plot=True, while new_figure_name in figure_title: new_figure_name = "Root Locus " + str(rloc_num) rloc_num += 1 - f = pylab.figure(new_figure_name) + if grid: + if isdtime(sys, strict=True): + ax, f = zgrid() + else: + ax, f = sgrid() + else: + ax, f = nogrid() + pylab.title(new_figure_name) if PrintGain: f.canvas.mpl_connect( 'button_release_event', partial(_RLFeedbackClicks, sys=sys)) - ax = pylab.axes() # plot open loop poles poles = array(denp.r) @@ -128,17 +137,13 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, plotstr='-', Plot=True, # Now plot the loci for col in mymat.T: - ax.plot(real(col), imag(col), plotstr) + ax.plot(real(col), imag(col), plotstr, lw=3) # Set up plot axes and labels if xlim: ax.set_xlim(xlim) if ylim: ax.set_ylim(ylim) - ax.set_xlabel('Real') - ax.set_ylabel('Imaginary') - if grid: - _sgrid_func() return mymat, kvect @@ -350,88 +355,4 @@ def _RLFeedbackClicks(event, sys): print("Clicked at %10.4g%+10.4gj gain %10.4g damp %10.4g" % (s.real, s.imag, K.real, -1 * s.real / abs(s))) - -def _sgrid_func(fig=None, zeta=None, wn=None): - if fig is None: - fig = pylab.gcf() - ax = fig.gca() - xlocator = ax.get_xaxis().get_major_locator() - - ylim = ax.get_ylim() - ytext_pos_lim = ylim[1] - (ylim[1] - ylim[0]) * 0.03 - xlim = ax.get_xlim() - xtext_pos_lim = xlim[0] + (xlim[1] - xlim[0]) * 0.0 - - if zeta is None: - zeta = _default_zetas(xlim, ylim) - - angules = [] - for z in zeta: - if (z >= 1e-4) and (z <= 1): - angules.append(np.pi/2 + np.arcsin(z)) - else: - zeta.remove(z) - y_over_x = np.tan(angules) - - # zeta-constant lines - - index = 0 - - for yp in y_over_x: - ax.plot([0, xlocator()[0]], [0, yp*xlocator()[0]], color='gray', - linestyle='dashed', linewidth=0.5) - ax.plot([0, xlocator()[0]], [0, -yp * xlocator()[0]], color='gray', - linestyle='dashed', linewidth=0.5) - an = "%.2f" % zeta[index] - if yp < 0: - xtext_pos = 1/yp * ylim[1] - ytext_pos = yp * xtext_pos_lim - if np.abs(xtext_pos) > np.abs(xtext_pos_lim): - xtext_pos = xtext_pos_lim - else: - ytext_pos = ytext_pos_lim - ax.annotate(an, textcoords='data', xy=[xtext_pos, ytext_pos], fontsize=8) - index += 1 - ax.plot([0, 0], [ylim[0], ylim[1]], color='gray', linestyle='dashed', linewidth=0.5) - - angules = np.linspace(-90, 90, 20)*np.pi/180 - if wn is None: - wn = _default_wn(xlocator(), ylim) - - for om in wn: - if om < 0: - yp = np.sin(angules)*np.abs(om) - xp = -np.cos(angules)*np.abs(om) - ax.plot(xp, yp, color='gray', - linestyle='dashed', linewidth=0.5) - an = "%.2f" % -om - ax.annotate(an, textcoords='data', xy=[om, 0], fontsize=8) - - -def _default_zetas(xlim, ylim): - """Return default list of dumps coefficients""" - sep1 = -xlim[0]/4 - ang1 = [np.arctan((sep1*i)/ylim[1]) for i in np.arange(1, 4, 1)] - sep2 = ylim[1] / 3 - ang2 = [np.arctan(-xlim[0]/(ylim[1]-sep2*i)) for i in np.arange(1, 3, 1)] - - angules = np.concatenate((ang1, ang2)) - angules = np.insert(angules, len(angules), np.pi/2) - zeta = np.sin(angules) - return zeta.tolist() - - -def _default_wn(xloc, ylim): - """Return default wn for root locus plot""" - - wn = xloc - sep = xloc[1]-xloc[0] - while np.abs(wn[0]) < ylim[1]: - wn = np.insert(wn, 0, wn[0]-sep) - - while len(wn) > 7: - wn = wn[0:-1:2] - - return wn - rlocus = root_locus
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: