Skip to content

Commit 3adc610

Browse files
authored
Merge pull request #531 from sawyerbfuller/sisotooling
sisotool small visual cleanup, new feature to show step response of different input-output than loop
2 parents d75c395 + abca69d commit 3adc610

File tree

4 files changed

+153
-48
lines changed

4 files changed

+153
-48
lines changed

control/bdalg.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,8 @@ def connect(sys, Q, inputv, outputv):
334334
interconnecting multiple systems.
335335
336336
"""
337-
inputv, outputv, Q = np.asarray(inputv), np.asarray(outputv), np.asarray(Q)
337+
inputv, outputv, Q = \
338+
np.atleast_1d(inputv), np.atleast_1d(outputv), np.atleast_1d(Q)
338339
# check indices
339340
index_errors = (inputv - 1 > sys.ninputs) | (inputv < 1)
340341
if np.any(index_errors):

control/rlocus.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,10 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None,
137137
print_gain = config._get_param(
138138
'rlocus', 'print_gain', print_gain, _rlocus_defaults)
139139

140+
sys_loop = sys if sys.issiso() else sys[0,0]
141+
140142
# Convert numerator and denominator to polynomials if they aren't
141-
(nump, denp) = _systopoly1d(sys)
143+
(nump, denp) = _systopoly1d(sys_loop)
142144

143145
# if discrete-time system and if xlim and ylim are not given,
144146
# that we a view of the unit circle
@@ -241,12 +243,12 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None,
241243
else:
242244
_sgrid_func()
243245
else:
244-
ax.axhline(0., linestyle=':', color='k', zorder=-20)
245-
ax.axvline(0., linestyle=':', color='k', zorder=-20)
246+
ax.axhline(0., linestyle=':', color='k', linewidth=.75, zorder=-20)
247+
ax.axvline(0., linestyle=':', color='k', linewidth=.75, zorder=-20)
246248
if isdtime(sys, strict=True):
247249
ax.add_patch(plt.Circle(
248250
(0, 0), radius=1.0, linestyle=':', edgecolor='k',
249-
linewidth=1.5, fill=False, zorder=-20))
251+
linewidth=0.75, fill=False, zorder=-20))
250252

251253
return mymat, kvect
252254

@@ -540,8 +542,9 @@ def _RLSortRoots(mymat):
540542

541543
def _RLZoomDispatcher(event, sys, ax_rlocus, plotstr):
542544
"""Rootlocus plot zoom dispatcher"""
545+
sys_loop = sys if sys.issiso() else sys[0,0]
543546

544-
nump, denp = _systopoly1d(sys)
547+
nump, denp = _systopoly1d(sys_loop)
545548
xlim, ylim = ax_rlocus.get_xlim(), ax_rlocus.get_ylim()
546549

547550
kvect, mymat, xlim, ylim = _default_gains(
@@ -573,21 +576,23 @@ def _RLClickDispatcher(event, sys, fig, ax_rlocus, plotstr, sisotool=False,
573576

574577
def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False):
575578
"""Display root-locus gain feedback point for clicks on root-locus plot"""
576-
(nump, denp) = _systopoly1d(sys)
579+
sys_loop = sys if sys.issiso() else sys[0,0]
580+
581+
(nump, denp) = _systopoly1d(sys_loop)
577582

578583
xlim = ax_rlocus.get_xlim()
579584
ylim = ax_rlocus.get_ylim()
580-
x_tolerance = 0.05 * abs((xlim[1] - xlim[0]))
581-
y_tolerance = 0.05 * abs((ylim[1] - ylim[0]))
585+
x_tolerance = 0.1 * abs((xlim[1] - xlim[0]))
586+
y_tolerance = 0.1 * abs((ylim[1] - ylim[0]))
582587
gain_tolerance = np.mean([x_tolerance, y_tolerance])*0.1
583588

584589
# Catch type error when event click is in the figure but not in an axis
585590
try:
586591
s = complex(event.xdata, event.ydata)
587-
K = -1. / sys(s)
588-
K_xlim = -1. / sys(
592+
K = -1. / sys_loop(s)
593+
K_xlim = -1. / sys_loop(
589594
complex(event.xdata + 0.05 * abs(xlim[1] - xlim[0]), event.ydata))
590-
K_ylim = -1. / sys(
595+
K_ylim = -1. / sys_loop(
591596
complex(event.xdata, event.ydata + 0.05 * abs(ylim[1] - ylim[0])))
592597

593598
except TypeError:

control/sisotool.py

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
__all__ = ['sisotool']
22

3+
from control.exception import ControlMIMONotImplemented
34
from .freqplot import bode_plot
45
from .timeresp import step_response
56
from .lti import issiso, isdtime
7+
from .xferfcn import TransferFunction
8+
from .bdalg import append, connect
69
import matplotlib
710
import matplotlib.pyplot as plt
811
import warnings
912

10-
def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None,
11-
plotstr_rlocus = 'b' if int(matplotlib.__version__[0]) == 1 else 'C0',
12-
rlocus_grid = False, omega = None, dB = None, Hz = None,
13-
deg = None, omega_limits = None, omega_num = None,
14-
margins_bode = True, tvect=None):
13+
def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None,
14+
plotstr_rlocus='C0', rlocus_grid=False, omega=None, dB=None,
15+
Hz=None, deg=None, omega_limits=None, omega_num=None,
16+
margins_bode=True, tvect=None):
1517
"""
1618
Sisotool style collection of plots inspired by MATLAB's sisotool.
1719
The left two plots contain the bode magnitude and phase diagrams.
@@ -22,7 +24,16 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None,
2224
Parameters
2325
----------
2426
sys : LTI object
25-
Linear input/output systems (SISO only)
27+
Linear input/output systems. If sys is SISO, use the same
28+
system for the root locus and step response. If it is desired to
29+
see a different step response than feedback(K*loop,1), sys can be
30+
provided as a two-input, two-output system (e.g. by using
31+
:func:`bdgalg.connect' or :func:`iosys.interconnect`). Sisotool
32+
inserts the negative of the selected gain K between the first output
33+
and first input and uses the second input and output for computing
34+
the step response. This allows you to see the step responses of more
35+
complex systems, for example, systems with a feedforward path into the
36+
plant or in which the gain appears in the feedback path.
2637
kvect : list or ndarray, optional
2738
List of gains to use for plotting root locus
2839
xlim_rlocus : tuple or list, optional
@@ -32,21 +43,23 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None,
3243
control of y-axis range
3344
plotstr_rlocus : :func:`matplotlib.pyplot.plot` format string, optional
3445
plotting style for the root locus plot(color, linestyle, etc)
35-
rlocus_grid: boolean (default = False)
36-
If True plot s-plane grid.
37-
omega : freq_range
38-
Range of frequencies in rad/sec for the bode plot
46+
rlocus_grid : boolean (default = False)
47+
If True plot s- or z-plane grid.
48+
omega : array_like
49+
List of frequencies in rad/sec to be used for bode plot
3950
dB : boolean
4051
If True, plot result in dB for the bode plot
4152
Hz : boolean
4253
If True, plot frequency in Hz for the bode plot (omega must be provided in rad/sec)
4354
deg : boolean
4455
If True, plot phase in degrees for the bode plot (else radians)
45-
omega_limits: tuple, list, ... of two values
56+
omega_limits : array_like of two values
4657
Limits of the to generate frequency vector.
47-
If Hz=True the limits are in Hz otherwise in rad/s.
48-
omega_num: int
49-
number of samples
58+
If Hz=True the limits are in Hz otherwise in rad/s. Ignored if omega
59+
is provided, and auto-generated if omitted.
60+
omega_num : int
61+
Number of samples to plot. Defaults to
62+
config.defaults['freqplot.number_of_samples'].
5063
margins_bode : boolean
5164
If True, plot gain and phase margin in the bode plot
5265
tvect : list or ndarray, optional
@@ -60,8 +73,11 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None,
6073
"""
6174
from .rlocus import root_locus
6275

63-
# Check if it is a single SISO system
64-
issiso(sys,strict=True)
76+
# sys as loop transfer function if SISO
77+
if not sys.issiso():
78+
if not (sys.ninputs == 2 and sys.noutputs == 2):
79+
raise ControlMIMONotImplemented(
80+
'sys must be SISO or 2-input, 2-output')
6581

6682
# Setup sisotool figure or superimpose if one is already present
6783
fig = plt.gcf()
@@ -84,64 +100,74 @@ def sisotool(sys, kvect = None, xlim_rlocus = None, ylim_rlocus = None,
84100
}
85101

86102
# First time call to setup the bode and step response plots
87-
_SisotoolUpdate(sys, fig,1 if kvect is None else kvect[0],bode_plot_params)
103+
_SisotoolUpdate(sys, fig,
104+
1 if kvect is None else kvect[0], bode_plot_params)
88105

89106
# Setup the root-locus plot window
90-
root_locus(sys,kvect=kvect,xlim=xlim_rlocus,ylim = ylim_rlocus,plotstr=plotstr_rlocus,grid = rlocus_grid,fig=fig,bode_plot_params=bode_plot_params,tvect=tvect,sisotool=True)
107+
root_locus(sys, kvect=kvect, xlim=xlim_rlocus,
108+
ylim=ylim_rlocus, plotstr=plotstr_rlocus, grid=rlocus_grid,
109+
fig=fig, bode_plot_params=bode_plot_params, tvect=tvect, sisotool=True)
91110

92-
def _SisotoolUpdate(sys,fig,K,bode_plot_params,tvect=None):
111+
def _SisotoolUpdate(sys, fig, K, bode_plot_params, tvect=None):
93112

94-
if int(matplotlib.__version__[0]) == 1:
95-
title_font_size = 12
96-
label_font_size = 10
97-
else:
98-
title_font_size = 10
99-
label_font_size = 8
113+
title_font_size = 10
114+
label_font_size = 8
100115

101116
# Get the subaxes and clear them
102-
ax_mag,ax_rlocus,ax_phase,ax_step = fig.axes[0],fig.axes[1],fig.axes[2],fig.axes[3]
117+
ax_mag, ax_rlocus, ax_phase, ax_step = \
118+
fig.axes[0], fig.axes[1], fig.axes[2], fig.axes[3]
103119

104120
# Catch matplotlib 2.1.x and higher userwarnings when clearing a log axis
105121
with warnings.catch_warnings():
106122
warnings.simplefilter("ignore")
107123
ax_step.clear(), ax_mag.clear(), ax_phase.clear()
108124

125+
sys_loop = sys if sys.issiso() else sys[0,0]
126+
109127
# Update the bodeplot
110-
bode_plot_params['syslist'] = sys*K.real
128+
bode_plot_params['syslist'] = sys_loop*K.real
111129
bode_plot(**bode_plot_params)
112130

113131
# Set the titles and labels
114132
ax_mag.set_title('Bode magnitude',fontsize = title_font_size)
115133
ax_mag.set_ylabel(ax_mag.get_ylabel(), fontsize=label_font_size)
134+
ax_mag.tick_params(axis='both', which='major', labelsize=label_font_size)
116135

117136
ax_phase.set_title('Bode phase',fontsize=title_font_size)
118137
ax_phase.set_xlabel(ax_phase.get_xlabel(),fontsize=label_font_size)
119138
ax_phase.set_ylabel(ax_phase.get_ylabel(),fontsize=label_font_size)
120139
ax_phase.get_xaxis().set_label_coords(0.5, -0.15)
121140
ax_phase.get_shared_x_axes().join(ax_phase, ax_mag)
141+
ax_phase.tick_params(axis='both', which='major', labelsize=label_font_size)
122142

123143
ax_step.set_title('Step response',fontsize = title_font_size)
124144
ax_step.set_xlabel('Time (seconds)',fontsize=label_font_size)
125-
ax_step.set_ylabel('Amplitude',fontsize=label_font_size)
145+
ax_step.set_ylabel('Output',fontsize=label_font_size)
126146
ax_step.get_xaxis().set_label_coords(0.5, -0.15)
127147
ax_step.get_yaxis().set_label_coords(-0.15, 0.5)
148+
ax_step.tick_params(axis='both', which='major', labelsize=label_font_size)
128149

129150
ax_rlocus.set_title('Root locus',fontsize = title_font_size)
130151
ax_rlocus.set_ylabel('Imag', fontsize=label_font_size)
131152
ax_rlocus.set_xlabel('Real', fontsize=label_font_size)
132153
ax_rlocus.get_xaxis().set_label_coords(0.5, -0.15)
133154
ax_rlocus.get_yaxis().set_label_coords(-0.15, 0.5)
134-
135-
155+
ax_rlocus.tick_params(axis='both', which='major',labelsize=label_font_size)
136156

137157
# Generate the step response and plot it
138-
sys_closed = (K*sys).feedback(1)
158+
if sys.issiso():
159+
sys_closed = (K*sys).feedback(1)
160+
else:
161+
sys_closed = append(sys, -K)
162+
connects = [[1, 3],
163+
[3, 1]]
164+
sys_closed = connect(sys_closed, connects, 2, 2)
139165
if tvect is None:
140166
tvect, yout = step_response(sys_closed, T_num=100)
141167
else:
142-
tvect, yout = step_response(sys_closed,tvect)
168+
tvect, yout = step_response(sys_closed, tvect)
143169
if isdtime(sys_closed, strict=True):
144-
ax_step.plot(tvect, yout, 'o')
170+
ax_step.plot(tvect, yout, '.')
145171
else:
146172
ax_step.plot(tvect, yout)
147173
ax_step.axhline(1.,linestyle=':',color='k',zorder=-20)

control/tests/sisotool_test.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""sisotool_test.py"""
22

3+
from control.exception import ControlMIMONotImplemented
34
import matplotlib.pyplot as plt
45
import numpy as np
56
from numpy.testing import assert_array_almost_equal
@@ -8,6 +9,7 @@
89
from control.sisotool import sisotool
910
from control.rlocus import _RLClickDispatcher
1011
from control.xferfcn import TransferFunction
12+
from control.statesp import StateSpace
1113

1214

1315
@pytest.mark.usefixtures("mplcleanup")
@@ -19,6 +21,35 @@ def sys(self):
1921
"""Return a generic SISO transfer function"""
2022
return TransferFunction([1000], [1, 25, 100, 0])
2123

24+
@pytest.fixture
25+
def sysdt(self):
26+
"""Return a generic SISO transfer function"""
27+
return TransferFunction([1000], [1, 25, 100, 0], True)
28+
29+
@pytest.fixture
30+
def sys222(self):
31+
"""2-states square system (2 inputs x 2 outputs)"""
32+
A222 = [[4., 1.],
33+
[2., -3]]
34+
B222 = [[5., 2.],
35+
[-3., -3.]]
36+
C222 = [[2., -4],
37+
[0., 1.]]
38+
D222 = [[3., 2.],
39+
[1., -1.]]
40+
return StateSpace(A222, B222, C222, D222)
41+
42+
@pytest.fixture
43+
def sys221(self):
44+
"""2-states, 2 inputs x 1 output"""
45+
A222 = [[4., 1.],
46+
[2., -3]]
47+
B222 = [[5., 2.],
48+
[-3., -3.]]
49+
C221 = [[0., 1.]]
50+
D221 = [[1., -1.]]
51+
return StateSpace(A222, B222, C221, D221)
52+
2253
def test_sisotool(self, sys):
2354
sisotool(sys, Hz=False)
2455
fig = plt.gcf()
@@ -27,7 +58,7 @@ def test_sisotool(self, sys):
2758
# Check the initial root locus plot points
2859
initial_point_0 = (np.array([-22.53155977]), np.array([0.]))
2960
initial_point_1 = (np.array([-1.23422011]), np.array([-6.54667031]))
30-
initial_point_2 = (np.array([-1.23422011]), np.array([06.54667031]))
61+
initial_point_2 = (np.array([-1.23422011]), np.array([6.54667031]))
3162
assert_array_almost_equal(ax_rlocus.lines[0].get_data(),
3263
initial_point_0, 4)
3364
assert_array_almost_equal(ax_rlocus.lines[1].get_data(),
@@ -88,7 +119,49 @@ def test_sisotool(self, sys):
88119
step_response_moved = np.array(
89120
[0., 0.0072, 0.0516, 0.1554, 0.3281, 0.5681, 0.8646, 1.1987,
90121
1.5452, 1.875])
91-
# old: array([0., 0.0239, 0.161 , 0.4547, 0.8903, 1.407,
92-
# 1.9121, 2.2989, 2.4686, 2.353])
93122
assert_array_almost_equal(
94123
ax_step.lines[0].get_data()[1][:10], step_response_moved, 4)
124+
125+
def test_sisotool_tvect(self, sys):
126+
# test supply tvect
127+
tvect = np.linspace(0, 1, 10)
128+
sisotool(sys, tvect=tvect)
129+
fig = plt.gcf()
130+
ax_rlocus, ax_step = fig.axes[1], fig.axes[3]
131+
132+
# Move the rootlocus to another point and confirm same tvect
133+
event = type('test', (object,), {'xdata': 2.31206868287,
134+
'ydata': 15.5983051046,
135+
'inaxes': ax_rlocus.axes})()
136+
_RLClickDispatcher(event=event, sys=sys, fig=fig,
137+
ax_rlocus=ax_rlocus, sisotool=True, plotstr='-',
138+
bode_plot_params=dict(), tvect=tvect)
139+
assert_array_almost_equal(tvect, ax_step.lines[0].get_data()[0])
140+
141+
def test_sisotool_tvect_dt(self, sysdt):
142+
# test supply tvect
143+
tvect = np.linspace(0, 1, 10)
144+
sisotool(sysdt, tvect=tvect)
145+
fig = plt.gcf()
146+
ax_rlocus, ax_step = fig.axes[1], fig.axes[3]
147+
148+
# Move the rootlocus to another point and confirm same tvect
149+
event = type('test', (object,), {'xdata': 2.31206868287,
150+
'ydata': 15.5983051046,
151+
'inaxes': ax_rlocus.axes})()
152+
_RLClickDispatcher(event=event, sys=sysdt, fig=fig,
153+
ax_rlocus=ax_rlocus, sisotool=True, plotstr='-',
154+
bode_plot_params=dict(), tvect=tvect)
155+
assert_array_almost_equal(tvect, ax_step.lines[0].get_data()[0])
156+
157+
def test_sisotool_mimo(self, sys222, sys221):
158+
# a 2x2 should not raise an error:
159+
sisotool(sys222)
160+
161+
# but 2 input, 1 output should
162+
with pytest.raises(ControlMIMONotImplemented):
163+
sisotool(sys221)
164+
165+
166+
167+

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy