Skip to content

Commit ee46ac6

Browse files
committed
adjust root locus spacing and add scaling keyword
1 parent 57dac87 commit ee46ac6

File tree

2 files changed

+68
-44
lines changed

2 files changed

+68
-44
lines changed

control/grid.py

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
# grid.py - code to add gridlines to root locus and pole-zero diagrams
2+
#
3+
# This code generates grids for pole-zero diagrams (including root locus
4+
# diagrams). Rather than just draw a grid in place, it uses the AxisArtist
5+
# package to generate a custom grid that will scale with the figure.
6+
#
7+
18
import numpy as np
29
from numpy import cos, sin, sqrt, linspace, pi, exp
310
import matplotlib.pyplot as plt
11+
412
from mpl_toolkits.axisartist import SubplotHost
513
from mpl_toolkits.axisartist.grid_helper_curvelinear \
614
import GridHelperCurveLinear
@@ -67,14 +75,15 @@ def __call__(self, transform_xy, x1, y1, x2, y2):
6775
return lon_min, lon_max, lat_min, lat_max
6876

6977

70-
def sgrid():
78+
def sgrid(scaling=None):
7179
# From matplotlib demos:
7280
# https://matplotlib.org/gallery/axisartist/demo_curvelinear_grid.html
7381
# https://matplotlib.org/gallery/axisartist/demo_floating_axis.html
7482

7583
# PolarAxes.PolarTransform takes radian. However, we want our coordinate
76-
# system in degree
84+
# system in degrees
7785
tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform()
86+
7887
# polar projection, which involves cycle, and also has limits in
7988
# its coordinates, needs a special method to find the extremes
8089
# (min, max of the coordinate within the view).
@@ -91,6 +100,7 @@ def sgrid():
91100
tr, extreme_finder=extreme_finder, grid_locator1=grid_locator1,
92101
tick_formatter1=tick_formatter1)
93102

103+
# Set up an axes with a specialized grid helper
94104
fig = plt.gcf()
95105
ax = SubplotHost(fig, 1, 1, 1, grid_helper=grid_helper)
96106

@@ -101,6 +111,7 @@ def sgrid():
101111
ax.axis[:].invert_ticklabel_direction()
102112
ax.axis[:].major_ticklabels.set_color('gray')
103113

114+
# Set up internal tickmarks and labels along the real/imag axes
104115
ax.axis["wnxneg"] = axis = ax.new_floating_axis(0, 180)
105116
axis.set_ticklabel_direction("-")
106117
axis.label.set_visible(False)
@@ -125,35 +136,26 @@ def sgrid():
125136
ax.axis["bottom"].get_helper().nth_coord_ticks = 0
126137

127138
fig.add_subplot(ax)
128-
129-
# RECTANGULAR X Y AXES WITH SCALE
130-
# par2 = ax.twiny()
131-
# par2.axis["top"].toggle(all=False)
132-
# par2.axis["right"].toggle(all=False)
133-
# new_fixed_axis = par2.get_grid_helper().new_fixed_axis
134-
# par2.axis["left"] = new_fixed_axis(loc="left",
135-
# axes=par2,
136-
# offset=(0, 0))
137-
# par2.axis["bottom"] = new_fixed_axis(loc="bottom",
138-
# axes=par2,
139-
# offset=(0, 0))
140-
# FINISH RECTANGULAR
141-
142139
ax.grid(True, zorder=0, linestyle='dotted')
143140

144-
_final_setup(ax)
141+
_final_setup(ax, scaling=scaling)
145142
return ax, fig
146143

147144

148-
def _final_setup(ax):
145+
# Utility function used by all grid code
146+
def _final_setup(ax, scaling=None):
149147
ax.set_xlabel('Real')
150148
ax.set_ylabel('Imaginary')
151149
ax.axhline(y=0, color='black', lw=0.5)
152150
ax.axvline(x=0, color='black', lw=0.5)
153-
plt.axis('equal')
151+
152+
# Set up the scaling for the axes
153+
scaling = 'equal' if scaling is None else scaling
154+
plt.axis(scaling)
154155

155156

156-
def nogrid(dt=None, ax=None):
157+
# If not grid is given, at least separate stable/unstable regions
158+
def nogrid(dt=None, ax=None, scaling=None):
157159
fig = plt.gcf()
158160
if ax is None:
159161
ax = fig.gca()
@@ -163,11 +165,12 @@ def nogrid(dt=None, ax=None):
163165
s = np.linspace(0, 2*pi, 100)
164166
ax.plot(np.cos(s), np.sin(s), 'k--', lw=0.5, dashes=(5, 5))
165167

166-
_final_setup(ax)
168+
_final_setup(ax, scaling=scaling)
167169
return ax, fig
168170

169-
170-
def zgrid(zetas=None, wns=None, ax=None):
171+
# Grid for discrete time system (drawn, not rendered by AxisArtist)
172+
# TODO (at some point): think about using customized grid generator?
173+
def zgrid(zetas=None, wns=None, ax=None, scaling=None):
171174
"""Draws discrete damping and frequency grid"""
172175

173176
fig = plt.gcf()
@@ -218,5 +221,9 @@ def zgrid(zetas=None, wns=None, ax=None):
218221
ax.annotate(r"$\frac{"+num+r"\pi}{T}$", xy=(an_x, an_y),
219222
xytext=(an_x, an_y), size=9)
220223

221-
_final_setup(ax)
224+
# Set default axes to allow some room around the unit circle
225+
ax.set_xlim([-1.1, 1.1])
226+
ax.set_ylim([-1.1, 1.1])
227+
228+
_final_setup(ax, scaling=scaling)
222229
return ax, fig

control/pzmap.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
'pzmap.grid': False, # Plot omega-damping grid
4343
'pzmap.marker_size': 6, # Size of the markers
4444
'pzmap.marker_width': 1.5, # Width of the markers
45-
'pzmap.expansion_factor': 2, # Amount to scale plots beyond features
45+
'pzmap.expansion_factor': 1.8, # Amount to scale plots beyond features
46+
'pzmap.buffer_factor': 1.05, # Buffer to leave around plot peaks
4647
}
4748

4849
#
@@ -110,7 +111,7 @@ def pole_zero_map(sysdata):
110111
def pole_zero_plot(
111112
data, plot=None, grid=None, title=None, marker_color=None,
112113
marker_size=None, marker_width=None, legend_loc='upper right',
113-
xlim=None, ylim=None, interactive=False, ax=None,
114+
xlim=None, ylim=None, interactive=False, ax=None, scaling=None,
114115
initial_gain=None, **kwargs):
115116
# TODO: update docstring (see other response/plot functions for style)
116117
"""Plot a pole/zero map for a linear system.
@@ -144,7 +145,7 @@ def pole_zero_plot(
144145
(legacy) If the `plot` keyword is given, the system poles and zeros
145146
are returned.
146147
147-
Notes (TODO: update)
148+
Notes (TODO: update, including scaling)
148149
-----
149150
The pzmap function calls matplotlib.pyplot.axis('equal'), which means
150151
that trying to reset the axis limits may not behave as expected. To
@@ -209,14 +210,15 @@ def pole_zero_plot(
209210
if grid:
210211
plt.clf()
211212
if all([isctime(dt=response.dt) for response in data]):
212-
ax, fig = sgrid()
213+
ax, fig = sgrid(scaling=scaling)
213214
elif all([isdtime(dt=response.dt) for response in data]):
214-
ax, fig = zgrid()
215+
ax, fig = zgrid(scaling=scaling)
215216
else:
216217
ValueError(
217218
"incompatible time responses; don't know how to grid")
218219
elif len(axs) == 0:
219-
ax, fig = nogrid(data[0].dt) # use first response timebase
220+
# use first response timebase
221+
ax, fig = nogrid(data[0].dt, scaling=scaling)
220222
else:
221223
# Use the existing axes and any grid that is there
222224
# TODO: allow axis to be overriden via parameter
@@ -270,7 +272,7 @@ def pole_zero_plot(
270272
label=response.sysname)
271273

272274
# Compute the axis limits to use based on the response
273-
resp_xlim, resp_ylim = _compute_root_locus_limits(response.loci)
275+
resp_xlim, resp_ylim = _compute_root_locus_limits(response)
274276

275277
# Keep track of the current limits
276278
xlim = [min(xlim[0], resp_xlim[0]), max(xlim[1], resp_xlim[1])]
@@ -433,11 +435,22 @@ def _create_root_locus_label(sys, K, s):
433435

434436

435437
# Utility function to compute limits for root loci
436-
# TODO: compare to old code and recapture functionality (especially asymptotes)
437438
# TODO: (note that sys is now available => code here may not be needed)
438-
def _compute_root_locus_limits(loci):
439-
# Go through each locus
440-
xlim, ylim = [0, 0], 0
439+
def _compute_root_locus_limits(response):
440+
loci = response.loci
441+
442+
# Start with information about zeros, if present
443+
if response.sys is not None and response.sys.zeros().size > 0:
444+
xlim = [
445+
min(0, np.min(response.sys.zeros().real)),
446+
max(0, np.max(response.sys.zeros().real))
447+
]
448+
ylim = max(0, np.max(response.sys.zeros().imag))
449+
else:
450+
xlim, ylim = [0, 0], 0
451+
452+
# Go through each locus and look for features
453+
rho = config._get_param('pzmap', 'buffer_factor')
441454
for locus in loci.transpose():
442455
# Include all starting points
443456
xlim = [min(xlim[0], locus[0].real), max(xlim[1], locus[0].real)]
@@ -446,18 +459,22 @@ def _compute_root_locus_limits(loci):
446459
# Find the local maxima of root locus curve
447460
xpeaks = np.where(
448461
np.diff(np.abs(locus.real)) < 0, locus.real[0:-1], 0)
449-
xlim = [min(xlim[0], np.min(xpeaks)), max(xlim[1], np.max(xpeaks))]
462+
xlim = [
463+
min(xlim[0], np.min(xpeaks) * rho),
464+
max(xlim[1], np.max(xpeaks) * rho)
465+
]
450466

451467
ypeaks = np.where(
452468
np.diff(np.abs(locus.imag)) < 0, locus.imag[0:-1], 0)
453-
ylim = max(ylim, np.max(ypeaks))
454-
455-
# Adjust the limits to include some space around features
456-
# TODO: use _k_max and project out to max k for all value?
457-
rho = config._get_param('pzmap', 'expansion_factor')
458-
xlim[0] = rho * xlim[0] if xlim[0] < 0 else 0
459-
xlim[1] = rho * xlim[1] if xlim[1] > 0 else 0
460-
ylim = rho * ylim if ylim > 0 else np.max(np.abs(xlim))
469+
ylim = max(ylim, np.max(ypeaks) * rho)
470+
471+
if isctime(dt=response.dt):
472+
# Adjust the limits to include some space around features
473+
# TODO: use _k_max and project out to max k for all value?
474+
rho = config._get_param('pzmap', 'expansion_factor')
475+
xlim[0] = rho * xlim[0] if xlim[0] < 0 else 0
476+
xlim[1] = rho * xlim[1] if xlim[1] > 0 else 0
477+
ylim = rho * ylim if ylim > 0 else np.max(np.abs(xlim))
461478

462479
return xlim, [-ylim, ylim]
463480

0 commit comments

Comments
 (0)