-
Notifications
You must be signed in to change notification settings - Fork 453
Expand file tree
/
Copy pathsisotool.py
More file actions
427 lines (364 loc) · 17.9 KB
/
sisotool.py
File metadata and controls
427 lines (364 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# sisotool.py - interactive tool for SISO control design
"""Interactive tool for SISO control design."""
__all__ = ['sisotool', 'rootlocus_pid_designer']
import warnings
from functools import partial
import matplotlib.pyplot as plt
import numpy as np
from control.exception import ControlMIMONotImplemented
from control.statesp import _convert_to_statespace
from . import config
from .bdalg import append, connect
from .freqplot import bode_plot
from .iosys import common_timebase, isctime, isdtime
from .lti import frequency_response
from .nlsys import interconnect
from .statesp import ss, summing_junction
from .timeresp import step_response
from .xferfcn import tf
from .rlocus import add_loci_recalculate
_sisotool_defaults = {
'sisotool.initial_gain': 1
}
def sisotool(sys, initial_gain=None, xlim_rlocus=None, ylim_rlocus=None,
plotstr_rlocus='C0', rlocus_grid=False, omega=None, dB=None,
Hz=None, deg=None, omega_limits=None, omega_num=None,
margins_bode=True, tvect=None, kvect=None):
"""Collection of plots inspired by MATLAB's sisotool.
The left two plots contain the bode magnitude and phase diagrams.
The top right plot is a clickable root locus plot, clicking on the
root locus will change the gain of the system. The bottom left plot
shows a closed loop time response.
Parameters
----------
sys : LTI object
Linear input/output systems. If `sys` is SISO, use the same system
for the root locus and step response. If it is desired to see a
different step response than ``feedback(K*sys, 1)``, such as a
disturbance response, `sys` can be provided as a two-input,
two-output system. For two-input, two-output system, sisotool
inserts the negative of the selected gain `K` between the first
output and first input and uses the second input and output for
computing the step response. To see the disturbance response,
configure your plant to have as its second input the disturbance
input. To view the step response with a feedforward controller,
give your plant two identical inputs, and sum your feedback
controller and your feedforward controller and multiply them into
your plant's second input. It is also possible to accommodate a
system with a gain in the feedback.
initial_gain : float, optional
Initial gain to use for plotting root locus. Defaults to 1
(`config.defaults['sisotool.initial_gain']`).
xlim_rlocus : tuple or list, optional
Control of x-axis range (see `matplotlib.axes.Axes.set_xlim`).
ylim_rlocus : tuple or list, optional
Control of y-axis range (see `matplotlib.axes.Axes.set_ylim`).
plotstr_rlocus : `matplotlib.pyplot.plot` format string, optional
Plotting style for the root locus plot(color, linestyle, etc).
rlocus_grid : boolean (default = False)
If True, plot s- or z-plane grid.
omega : array_like
List of frequencies in rad/sec to be used for bode plot.
dB : boolean
If True, plot result in dB for the bode plot.
Hz : boolean
If True, plot frequency in Hz for the bode plot (omega must be
provided in rad/sec).
deg : boolean
If True, plot phase in degrees for the bode plot (else radians).
omega_limits : array_like of two values
Limits of the to generate frequency vector. If Hz=True the limits
are in Hz otherwise in rad/s. Ignored if omega is provided, and
auto-generated if omitted.
omega_num : int
Number of samples to plot. Defaults to
`config.defaults['freqplot.number_of_samples']`.
margins_bode : boolean
If True, plot gain and phase margin in the bode plot.
tvect : list or ndarray, optional
List of time steps to use for closed loop step response.
Examples
--------
>>> G = ct.tf([1000], [1, 25, 100, 0])
>>> ct.sisotool(G) # doctest: +SKIP
"""
from .rlocus import root_locus_map
# sys as loop transfer function if SISO
if not sys.issiso():
if not (sys.ninputs == 2 and sys.noutputs == 2):
raise ControlMIMONotImplemented(
'sys must be SISO or 2-input, 2-output')
# Setup sisotool figure or superimpose if one is already present
fig = plt.gcf()
if fig.canvas.manager.get_window_title() != 'Sisotool':
plt.close(fig)
fig, axes = plt.subplots(2, 2)
fig.canvas.manager.set_window_title('Sisotool')
else:
axes = np.array(fig.get_axes()).reshape(2, 2)
# Extract bode plot parameters
bode_plot_params = {
'omega': omega,
'dB': dB,
'Hz': Hz,
'deg': deg,
'omega_limits': omega_limits,
'omega_num' : omega_num,
'ax': axes[:, 0:1],
'display_margins': 'overlay' if margins_bode else False,
}
# Check to see if legacy 'PrintGain' keyword was used
if kvect is not None:
warnings.warn("'kvect' keyword is deprecated in sisotool; "
"use 'initial_gain' instead", FutureWarning)
initial_gain = np.atleast_1d(kvect)[0]
initial_gain = config._get_param('sisotool', 'initial_gain',
initial_gain, _sisotool_defaults)
# First time call to setup the Bode and step response plots
_SisotoolUpdate(sys, fig, initial_gain, bode_plot_params)
# root_locus(
# sys[0, 0], initial_gain=initial_gain, xlim=xlim_rlocus,
# ylim=ylim_rlocus, plotstr=plotstr_rlocus, grid=rlocus_grid,
# ax=fig.axes[1])
ax_rlocus = axes[0,1] # fig.axes[1]
cplt = root_locus_map(sys[0, 0]).plot(
xlim=xlim_rlocus, ylim=ylim_rlocus,
initial_gain=initial_gain, ax=ax_rlocus)
if rlocus_grid is False:
# Need to generate grid manually, since root_locus_plot() won't
from .grid import nogrid
nogrid(sys.dt, ax=ax_rlocus)
# install a zoom callback on the root-locus axis
add_loci_recalculate(sys, cplt, ax_rlocus)
# Reset the button release callback so that we can update all plots
fig.canvas.mpl_connect(
'button_release_event', partial(
_click_dispatcher, sys=sys, ax=fig.axes[1],
bode_plot_params=bode_plot_params, tvect=tvect))
def _click_dispatcher(event, sys, ax, bode_plot_params, tvect):
# Zoom handled by specialized callback in rlocus, only handle gain plot
if event.inaxes == ax.axes:
fig = ax.figure
# if a point is clicked on the rootlocus plot visually emphasize it
# K = _RLFeedbackClicksPoint(
# event, sys, fig, ax_rlocus, show_clicked=True)
from .pzmap import _create_root_locus_label, _find_root_locus_gain, \
_mark_root_locus_gain
K, s = _find_root_locus_gain(event, sys, ax)
if K is not None:
_mark_root_locus_gain(ax, sys, K)
fig.suptitle(_create_root_locus_label(sys, K, s), fontsize=10)
_SisotoolUpdate(sys, fig, K, bode_plot_params, tvect)
# Update the canvas
fig.canvas.draw()
def _SisotoolUpdate(sys, fig, K, bode_plot_params, tvect=None):
title_font_size = 10
label_font_size = 8
# Get the subaxes and clear them
ax_mag, ax_rlocus, ax_phase, ax_step = \
fig.axes[0], fig.axes[1], fig.axes[2], fig.axes[3]
# Catch matplotlib 2.1.x and higher userwarnings when clearing a log axis
with warnings.catch_warnings():
warnings.simplefilter("ignore")
ax_step.clear(), ax_mag.clear(), ax_phase.clear()
sys_loop = sys if sys.issiso() else sys[0,0]
# Update the Bode plot
bode_plot_params['data'] = frequency_response(sys_loop*K.real)
bode_plot(**bode_plot_params, title=False)
# Set the titles and labels
ax_mag.set_title('Bode magnitude',fontsize = title_font_size)
ax_mag.set_ylabel(ax_mag.get_ylabel(), fontsize=label_font_size)
ax_mag.tick_params(axis='both', which='major', labelsize=label_font_size)
ax_phase.set_title('Bode phase',fontsize=title_font_size)
ax_phase.set_xlabel(ax_phase.get_xlabel(),fontsize=label_font_size)
ax_phase.set_ylabel(ax_phase.get_ylabel(),fontsize=label_font_size)
ax_phase.get_xaxis().set_label_coords(0.5, -0.15)
ax_phase.tick_params(axis='both', which='major', labelsize=label_font_size)
if not ax_phase.get_shared_x_axes().joined(ax_phase, ax_mag):
ax_phase.sharex(ax_mag)
ax_step.set_title('Step response',fontsize = title_font_size)
ax_step.set_xlabel('Time (seconds)',fontsize=label_font_size)
ax_step.set_ylabel('Output',fontsize=label_font_size)
ax_step.get_xaxis().set_label_coords(0.5, -0.15)
ax_step.get_yaxis().set_label_coords(-0.15, 0.5)
ax_step.tick_params(axis='both', which='major', labelsize=label_font_size)
ax_rlocus.set_title('Root locus',fontsize = title_font_size)
ax_rlocus.set_ylabel('Imag', fontsize=label_font_size)
ax_rlocus.set_xlabel('Real', fontsize=label_font_size)
ax_rlocus.get_xaxis().set_label_coords(0.5, -0.15)
ax_rlocus.get_yaxis().set_label_coords(-0.15, 0.5)
ax_rlocus.tick_params(axis='both', which='major',labelsize=label_font_size)
# Generate the step response and plot it
if sys.issiso():
sys_closed = (K*sys).feedback(1)
else:
sys_closed = append(sys, -K)
connects = [[1, 3],
[3, 1]]
# Filter out known warning due to use of connect
with warnings.catch_warnings():
warnings.filterwarnings(
'ignore', message="`connect`", category=DeprecationWarning)
sys_closed = connect(sys_closed, connects, 2, 2)
if tvect is None:
tvect, yout = step_response(sys_closed, T_num=100)
else:
tvect, yout = step_response(sys_closed, tvect)
if isdtime(sys_closed, strict=True):
ax_step.plot(tvect, yout, '.')
else:
ax_step.plot(tvect, yout)
ax_step.axhline(1.,linestyle=':',color='k',zorder=-20)
# Manually adjust the spacing and draw the canvas
fig.subplots_adjust(top=0.9,wspace = 0.3,hspace=0.35)
fig.canvas.draw()
# contributed by Sawyer Fuller, minster@uw.edu 2021.11.02, based on
# an implementation in Matlab by Martin Berg.
def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r',
Kp0=0, Ki0=0, Kd0=0, deltaK=0.001, tau=0.01,
C_ff=0, derivative_in_feedback_path=False,
plot=True):
"""Manual PID controller design based on root locus using Sisotool.
Uses `sisotool` to investigate the effect of adding or subtracting an
amount `deltaK` to the proportional, integral, or derivative (PID) gains
of a controller. One of the PID gains, `Kp`, `Ki`, or `Kd`, respectively,
can be modified at a time. `sisotool` plots the step response, frequency
response, and root locus of the closed-loop system controlling the
dynamical system specified by `plant`. Can be used with either non-
interactive plots (e.g. in a Jupyter Notebook), or interactive plots.
To use non-interactively, choose starting-point PID gains `Kp0`, `Ki0`,
and `Kd0` (you might want to start with all zeros to begin with),
select which gain you would like to vary (e.g. `gain` = 'P', 'I',
or 'D'), and choose a value of `deltaK` (default 0.001) to specify
by how much you would like to change that gain. Repeatedly run
`rootlocus_pid_designer` with different values of `deltaK` until you
are satisfied with the performance for that gain. Then, to tune a
different gain, e.g. 'I', make sure to add your chosen `deltaK` to
the previous gain you you were tuning.
Example: to examine the effect of varying `Kp` starting from an initial
value of 10, use the arguments ``gain='P', Kp0=10`` and try varying values
of `deltaK`. Suppose a `deltaK` of 5 gives satisfactory performance. Then,
to tune the derivative gain, add your selected `deltaK` to `Kp0` in the
next call using the arguments ``gain='D', Kp0=15``, to see how adding
different values of `deltaK` to your derivative gain affects performance.
To use with interactive plots, you will need to enable interactive mode
if you are in a Jupyter Notebook, e.g. using ``%matplotlib``. See
`Interactive Plots <https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.ion.html>`_
for more information. Click on a branch of the root locus plot to try
different values of `deltaK`. Each click updates plots and prints the
corresponding `deltaK`. It may be helpful to zoom in using the magnifying
glass on the plot to get more locations to click. Just make sure to
deactivate magnification mode when you are done by clicking the magnifying
glass. Otherwise you will not be able to be able to choose a gain on the
root locus plot. When you are done, ``%matplotlib inline`` returns to
inline, non-interactive plotting.
By default, all three PID terms are in the forward path C_f in the
diagram shown below, that is,
C_f = Kp + Ki/s + Kd*s/(tau*s + 1).
::
------> C_ff ------ d
| | |
r | e V V u y
------->O---> C_f --->O--->O---> plant --->
^- ^- |
| | |
| ----- C_b <-------|
---------------------------------
If `plant` is a discrete-time system, then the proportional, integral,
and derivative terms are given instead by Kp, Ki*dt/2*(z+1)/(z-1), and
Kd/dt*(z-1)/z, respectively.
It is also possible to move the derivative term into the feedback path
`C_b` using `derivative_in_feedback_path` = True. This may be desired to
avoid that the plant is subject to an impulse function when the reference
`r` is a step input. `C_b` is otherwise set to zero.
If `plant` is a 2-input system, the disturbance `d` is fed directly into
its second input rather than being added to `u`.
Parameters
----------
plant : `LTI` (`TransferFunction` or `StateSpace` system)
The dynamical system to be controlled.
gain : string, optional
Which gain to vary by `deltaK`. Must be one of 'P', 'I', or 'D'
(proportional, integral, or derivative).
sign : int, optional
The sign of deltaK gain perturbation.
input_signal : string, optional
The input used for the step response; must be 'r' (reference) or
'd' (disturbance) (see figure above).
Kp0, Ki0, Kd0 : float, optional
Initial values for proportional, integral, and derivative gains,
respectively.
deltaK : float, optional
Perturbation value for gain specified by the `gain` keyword.
tau : float, optional
The time constant associated with the pole in the continuous-time
derivative term. This is required to make the derivative transfer
function proper.
C_ff : float or `LTI` system, optional
Feedforward controller. If `LTI`, must have timebase that is
compatible with plant.
derivative_in_feedback_path : bool, optional
Whether to place the derivative term in feedback transfer function
`C_b` instead of the forward transfer function `C_f`.
plot : bool, optional
Whether to create Sisotool interactive plot.
Returns
-------
closedloop : `StateSpace` system
The closed-loop system using initial gains.
Notes
-----
When running using iPython or Jupyter, use ``%matplotlib`` to configure
the session for interactive support.
"""
if plant.ninputs == 1:
plant = ss(plant, inputs='u', outputs='y')
elif plant.ninputs == 2:
plant = ss(plant, inputs=['u', 'd'], outputs='y')
else:
raise ValueError("plant must have one or two inputs")
C_ff = ss(_convert_to_statespace(C_ff), inputs='r', outputs='uff')
dt = common_timebase(plant, C_ff)
# create systems used for interconnections
e_summer = summing_junction(['r', '-y'], 'e')
if plant.ninputs == 2:
u_summer = summing_junction(['ufb', 'uff'], 'u')
else:
u_summer = summing_junction(['ufb', 'uff', 'd'], 'u')
if isctime(plant):
prop = tf(1, 1, inputs='e', outputs='prop_e')
integ = tf(1, [1, 0], inputs='e', outputs='int_e')
deriv = tf([1, 0], [tau, 1], inputs='y', outputs='deriv')
else: # discrete time
prop = tf(1, 1, dt, inputs='e', outputs='prop_e')
integ = tf([dt/2, dt/2], [1, -1], dt, inputs='e', outputs='int_e')
deriv = tf([1, -1], [dt, 0], dt, inputs='y', outputs='deriv')
if derivative_in_feedback_path:
deriv = -deriv
deriv.input_labels = 'e'
# create gain blocks
Kpgain = tf(Kp0, 1, inputs='prop_e', outputs='ufb')
Kigain = tf(Ki0, 1, inputs='int_e', outputs='ufb')
Kdgain = tf(Kd0, 1, inputs='deriv', outputs='ufb')
# for the gain that is varied, replace gain block with a special block
# that has an 'input' and an 'output' that creates loop transfer function
if gain in ('P', 'p'):
Kpgain = ss([],[],[],[[0, 1], [-sign, Kp0]],
inputs=['input', 'prop_e'], outputs=['output', 'ufb'])
elif gain in ('I', 'i'):
Kigain = ss([],[],[],[[0, 1], [-sign, Ki0]],
inputs=['input', 'int_e'], outputs=['output', 'ufb'])
elif gain in ('D', 'd'):
Kdgain = ss([],[],[],[[0, 1], [-sign, Kd0]],
inputs=['input', 'deriv'], outputs=['output', 'ufb'])
else:
raise ValueError(gain + ' gain not recognized.')
# the second input and output are used by sisotool to plot step response
loop = interconnect((plant, Kpgain, Kigain, Kdgain, prop, integ, deriv,
C_ff, e_summer, u_summer),
inplist=['input', input_signal],
outlist=['output', 'y'], check_unused=False)
if plot:
sisotool(loop, initial_gain=deltaK)
cl = loop[1, 1] # closed loop transfer function with initial gains
return ss(cl.A, cl.B, cl.C, cl.D, cl.dt)