Skip to content

Commit 4b3597f

Browse files
committed
allowed Bode/Nyquist for mixed FRD systems
1 parent f181fb0 commit 4b3597f

File tree

4 files changed

+118
-45
lines changed

4 files changed

+118
-45
lines changed

control/freqplot.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ def bode_plot(
284284
#
285285

286286
# If we were passed a list of systems, convert to data
287-
if all([isinstance(
287+
if any([isinstance(
288288
sys, (StateSpace, TransferFunction)) for sys in data]):
289289
data = frequency_response(
290290
data, omega=omega, omega_limits=omega_limits,
@@ -1276,7 +1276,11 @@ def nyquist_response(
12761276
"Nyquist plot currently only supports SISO systems.")
12771277

12781278
# Figure out the frequency range
1279-
omega_sys = np.asarray(omega)
1279+
if isinstance(sys, FrequencyResponseData) and sys.ifunc is None \
1280+
and not omega_range_given:
1281+
omega_sys = sys.omega # use system frequencies
1282+
else:
1283+
omega_sys = np.asarray(omega) # use common omega vector
12801284

12811285
# Determine the contour used to evaluate the Nyquist curve
12821286
if sys.isdtime(strict=True):
@@ -2477,18 +2481,6 @@ def _determine_omega_vector(syslist, omega_in, omega_limits, omega_num,
24772481
and omega_limits are None.
24782482
24792483
"""
2480-
# Special processing for FRD systems
2481-
# TODO: allow different ranges of frequencies
2482-
if omega_in is None:
2483-
for sys in syslist:
2484-
if isinstance(sys, FrequencyResponseData):
2485-
# FRD already has predetermined frequencies
2486-
if omega_in is not None and not np.all(omega_in == sys.omega):
2487-
raise ValueError(
2488-
"List of FrequencyResponseData systems can only have "
2489-
"a single frequency range between them")
2490-
omega_in = sys.omega
2491-
24922484
# Handle the special case of a range of frequencies
24932485
if omega_in is not None and omega_limits is not None:
24942486
warnings.warn(
@@ -2573,6 +2565,15 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
25732565
syslist = (syslist,)
25742566

25752567
for sys in syslist:
2568+
# For FRD systems, just use the response frequencies
2569+
if isinstance(sys, FrequencyResponseData):
2570+
# Add the min and max frequency, minus periphery decades
2571+
# (keeps frequency ranges from artificially expanding)
2572+
features = np.concatenate([features, np.array([
2573+
np.min(sys.omega) * 10**feature_periphery_decades,
2574+
np.max(sys.omega) / 10**feature_periphery_decades])])
2575+
continue
2576+
25762577
try:
25772578
# Add new features to the list
25782579
if sys.isctime():
@@ -2587,7 +2588,8 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
25872588
# TODO: What distance to the Nyquist frequency is appropriate?
25882589
freq_interesting.append(fn * 0.9)
25892590

2590-
features_ = np.concatenate((sys.poles(), sys.zeros()))
2591+
features_ = np.concatenate(
2592+
(np.abs(sys.poles()), np.abs(sys.zeros())))
25912593
# Get rid of poles and zeros on the real axis (imag==0)
25922594
# * origin and real < 0
25932595
# * at 1.: would result in omega=0. (logaritmic plot!)
@@ -2602,8 +2604,9 @@ def _default_frequency_range(syslist, Hz=None, number_of_samples=None,
26022604
# TODO
26032605
raise NotImplementedError(
26042606
"type of system in not implemented now")
2605-
features = np.concatenate((features, features_))
2607+
features = np.concatenate([features, features_])
26062608
except NotImplementedError:
2609+
# Don't add any features for anything we don't understand
26072610
pass
26082611

26092612
# Make sure there is at least one point in the range

control/lti.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ def frequency_response(
475475
#>>> # s = 0.1i, i, 10i.
476476
477477
"""
478+
from .frdata import FrequencyResponseData
478479
from .freqplot import _determine_omega_vector
479480

480481
# Process keyword arguments
@@ -489,13 +490,18 @@ def frequency_response(
489490

490491
responses = []
491492
for sys_ in syslist:
492-
# Add the Nyquist frequency for discrete time systems
493-
omega_sys = omega_syslist.copy()
494-
if sys_.isdtime(strict=True):
495-
nyquistfrq = math.pi / sys_.dt
496-
if not omega_range_given:
497-
# Limit up to the Nyquist frequency
498-
omega_sys = omega_sys[omega_sys < nyquistfrq]
493+
if isinstance(sys_, FrequencyResponseData) and sys_.ifunc is None and \
494+
not omega_range_given:
495+
omega_sys = sys_.omega # use system properties
496+
else:
497+
omega_sys = omega_syslist.copy() # use common omega vector
498+
499+
# Add the Nyquist frequency for discrete time systems
500+
if sys_.isdtime(strict=True):
501+
nyquistfrq = math.pi / sys_.dt
502+
if not omega_range_given:
503+
# Limit up to the Nyquist frequency
504+
omega_sys = omega_sys[omega_sys < nyquistfrq]
499505

500506
# Compute the frequency response
501507
responses.append(sys_.frequency_response(omega_sys, squeeze=squeeze))

control/tests/freqplot_test.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,34 @@ def test_freqplot_ax_keyword(plt_fcn, ninputs, noutputs):
523523
np.testing.assert_equal(ct.get_plot_axes(out1), ct.get_plot_axes(out3))
524524

525525

526+
def test_mixed_systypes():
527+
s = ct.tf('s')
528+
sys_tf = ct.tf(
529+
(0.02 * s**3 - 0.1 * s) / (s**4 + s**3 + s**2 + 0.25 * s + 0.04),
530+
name='tf')
531+
sys_ss = ct.ss(sys_tf * 2, name='ss')
532+
sys_frd1 = ct.frd(sys_tf / 2, np.logspace(-1, 1, 15), name='frd1')
533+
sys_frd2 = ct.frd(sys_tf / 4, np.logspace(-3, 2, 20), name='frd2')
534+
535+
# Simple case: compute responses separately and plot
536+
resp_tf = ct.frequency_response(sys_tf)
537+
resp_ss = ct.frequency_response(sys_ss)
538+
plt.figure()
539+
ct.bode_plot([resp_tf, resp_ss, sys_frd1, sys_frd2], plot_phase=False)
540+
ct.suptitle("bode_plot([resp_tf, resp_ss, sys_frd1, sys_frd2])")
541+
542+
# Same thing, but using frequency response
543+
plt.figure()
544+
resp = ct.frequency_response([sys_tf, sys_ss, sys_frd1, sys_frd2])
545+
resp.plot(plot_phase=False)
546+
ct.suptitle("frequency_response([sys_tf, sys_ss, sys_frd1, sys_frd2])")
547+
548+
# Same thing, but using bode_plot
549+
plt.figure()
550+
resp = ct.bode_plot([sys_tf, sys_ss, sys_frd1, sys_frd2], plot_phase=False)
551+
ct.suptitle("bode_plot([sys_tf, sys_ss, sys_frd1, sys_frd2])")
552+
553+
526554
def test_suptitle():
527555
sys = ct.rss(2, 2, 2)
528556

@@ -623,5 +651,4 @@ def test_freqplot_errors(plt_fcn):
623651
# Run a few more special cases to show off capabilities (and save some
624652
# of them for use in the documentation).
625653
#
626-
627-
pass
654+
test_mixed_systypes()

control/tests/nyquist_test.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -162,35 +162,35 @@ def test_nyquist_fbs_examples():
162162

163163
"""Run through various examples from FBS2e to compare plots"""
164164
plt.figure()
165-
plt.title("Figure 10.4: L(s) = 1.4 e^{-s}/(s+1)^2")
165+
ct.suptitle("Figure 10.4: L(s) = 1.4 e^{-s}/(s+1)^2")
166166
sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4))
167167
response = ct.nyquist_response(sys)
168168
response.plot()
169169
assert _Z(sys) == response.count + _P(sys)
170170

171171
plt.figure()
172-
plt.title("Figure 10.4: L(s) = 1/(s + a)^2 with a = 0.6")
172+
ct.suptitle("Figure 10.4: L(s) = 1/(s + a)^2 with a = 0.6")
173173
sys = 1/(s + 0.6)**3
174174
response = ct.nyquist_response(sys)
175175
response.plot()
176176
assert _Z(sys) == response.count + _P(sys)
177177

178178
plt.figure()
179-
plt.title("Figure 10.6: L(s) = 1/(s (s+1)^2) - pole at the origin")
179+
ct.suptitle("Figure 10.6: L(s) = 1/(s (s+1)^2) - pole at the origin")
180180
sys = 1/(s * (s+1)**2)
181181
response = ct.nyquist_response(sys)
182182
response.plot()
183183
assert _Z(sys) == response.count + _P(sys)
184184

185185
plt.figure()
186-
plt.title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2)")
186+
ct.suptitle("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2)")
187187
sys = 3 * (s+6)**2 / (s * (s+1)**2)
188188
response = ct.nyquist_response(sys)
189189
response.plot()
190190
assert _Z(sys) == response.count + _P(sys)
191191

192192
plt.figure()
193-
plt.title("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2) [zoom]")
193+
ct.suptitle("Figure 10.10: L(s) = 3 (s+6)^2 / (s (s+1)^2) [zoom]")
194194
with pytest.warns(UserWarning, match="encirclements does not match"):
195195
response = ct.nyquist_response(sys, omega_limits=[1.5, 1e3])
196196
response.plot()
@@ -208,7 +208,7 @@ def test_nyquist_fbs_examples():
208208
def test_nyquist_arrows(arrows):
209209
sys = ct.tf([1.4], [1, 2, 1]) * ct.tf(*ct.pade(1, 4))
210210
plt.figure();
211-
plt.title("L(s) = 1.4 e^{-s}/(s+1)^2 / arrows = %s" % arrows)
211+
ct.suptitle("L(s) = 1.4 e^{-s}/(s+1)^2 / arrows = %s" % arrows)
212212
response = ct.nyquist_response(sys)
213213
response.plot(arrows=arrows)
214214
assert _Z(sys) == response.count + _P(sys)
@@ -222,13 +222,13 @@ def test_nyquist_encirclements():
222222
plt.figure();
223223
response = ct.nyquist_response(sys)
224224
response.plot()
225-
plt.title("Stable system; encirclements = %d" % response.count)
225+
ct.suptitle("Stable system; encirclements = %d" % response.count)
226226
assert _Z(sys) == response.count + _P(sys)
227227

228228
plt.figure();
229229
response = ct.nyquist_response(sys * 3)
230230
response.plot()
231-
plt.title("Unstable system; encirclements = %d" %response.count)
231+
ct.suptitle("Unstable system; encirclements = %d" %response.count)
232232
assert _Z(sys * 3) == response.count + _P(sys * 3)
233233

234234
# System with pole at the origin
@@ -237,7 +237,7 @@ def test_nyquist_encirclements():
237237
plt.figure();
238238
response = ct.nyquist_response(sys)
239239
response.plot()
240-
plt.title("Pole at the origin; encirclements = %d" %response.count)
240+
ct.suptitle("Pole at the origin; encirclements = %d" %response.count)
241241
assert _Z(sys) == response.count + _P(sys)
242242

243243
# Non-integer number of encirclements
@@ -251,7 +251,7 @@ def test_nyquist_encirclements():
251251
response = ct.nyquist_response(
252252
sys, omega_limits=[0.5, 1e3], encirclement_threshold=0.2)
253253
response.plot()
254-
plt.title("Non-integer number of encirclements [%g]" %response.count)
254+
ct.suptitle("Non-integer number of encirclements [%g]" %response.count)
255255

256256

257257
@pytest.fixture
@@ -266,7 +266,7 @@ def test_nyquist_indent_default(indentsys):
266266
plt.figure();
267267
response = ct.nyquist_response(indentsys)
268268
response.plot()
269-
plt.title("Pole at origin; indent_radius=default")
269+
ct.suptitle("Pole at origin; indent_radius=default")
270270
assert _Z(indentsys) == response.count + _P(indentsys)
271271

272272

@@ -293,7 +293,7 @@ def test_nyquist_indent_do(indentsys):
293293
indentsys, indent_radius=0.01, return_contour=True)
294294
count, contour = response
295295
response.plot()
296-
plt.title("Pole at origin; indent_radius=0.01; encirclements = %d" % count)
296+
ct.suptitle("Pole at origin; indent_radius=0.01; encirclements = %d" % count)
297297
assert _Z(indentsys) == count + _P(indentsys)
298298
# indent radius is smaller than the start of the default omega vector
299299
# check that a quarter circle around the pole at origin has been added.
@@ -314,7 +314,7 @@ def test_nyquist_indent_left(indentsys):
314314
plt.figure();
315315
response = ct.nyquist_response(indentsys, indent_direction='left')
316316
response.plot()
317-
plt.title(
317+
ct.suptitle(
318318
"Pole at origin; indent_direction='left'; encirclements = %d" %
319319
response.count)
320320
assert _Z(indentsys) == response.count + _P(indentsys, indent='left')
@@ -328,14 +328,14 @@ def test_nyquist_indent_im():
328328
plt.figure();
329329
response = ct.nyquist_response(sys)
330330
response.plot()
331-
plt.title("Imaginary poles; encirclements = %d" % response.count)
331+
ct.suptitle("Imaginary poles; encirclements = %d" % response.count)
332332
assert _Z(sys) == response.count + _P(sys)
333333

334334
# Imaginary poles with indentation to the left
335335
plt.figure();
336336
response = ct.nyquist_response(sys, indent_direction='left')
337337
response.plot(label_freq=300)
338-
plt.title(
338+
ct.suptitle(
339339
"Imaginary poles; indent_direction='left'; encirclements = %d" %
340340
response.count)
341341
assert _Z(sys) == response.count + _P(sys, indent='left')
@@ -346,7 +346,7 @@ def test_nyquist_indent_im():
346346
response = ct.nyquist_response(
347347
sys, np.linspace(0, 1e3, 1000), indent_direction='none')
348348
response.plot()
349-
plt.title(
349+
ct.suptitle(
350350
"Imaginary poles; indent_direction='none'; encirclements = %d" %
351351
response.count)
352352
assert _Z(sys) == response.count + _P(sys)
@@ -465,6 +465,36 @@ def test_freqresp_omega_limits():
465465
np.array([resp0.contour[1], resp0.contour[-1]]))
466466

467467

468+
def test_nyquist_frd():
469+
sys = ct.rss(4, 1, 1)
470+
sys1 = ct.frd(sys, np.logspace(-1, 1, 10), name='sys1')
471+
sys2 = ct.frd(sys, np.logspace(-2, 2, 10), name='sys2')
472+
sys3 = ct.frd(sys, np.logspace(-2, 2, 10), smooth=True, name='sys3')
473+
474+
# Turn off warnings about number of encirclements
475+
warnings.filterwarnings(
476+
'ignore', message="number of encirclements was a non-integer value",
477+
category=UserWarning)
478+
479+
# OK to specify frequency with FRD sys if frequencies match
480+
nyqresp = ct.nyquist_response(sys1, np.logspace(-1, 1, 10))
481+
np.testing.assert_allclose(nyqresp.contour, np.logspace(-1, 1, 10) * 1j)
482+
483+
# If a fixed FRD omega is used, generate an error on mismatch
484+
with pytest.raises(ValueError, match="not all frequencies .* in .* list"):
485+
nyqresp = ct.nyquist_response(sys2, np.logspace(-1, 1, 10))
486+
487+
# OK to specify frequency with FRD sys if interpolating FRD is used
488+
nyqresp = ct.nyquist_response(sys3, np.logspace(-1, 1, 12))
489+
np.testing.assert_allclose(nyqresp.contour, np.logspace(-1, 1, 12) * 1j)
490+
491+
# Computing Nyquist response w/ different frequencies OK if given as a list
492+
nyqresp = ct.nyquist_response([sys1, sys2])
493+
out = nyqresp.plot()
494+
495+
warnings.resetwarnings()
496+
497+
468498
if __name__ == "__main__":
469499
#
470500
# Interactive mode: generate plots for manual viewing
@@ -508,7 +538,7 @@ def test_freqresp_omega_limits():
508538
print("Unusual Nyquist plot")
509539
sys = ct.tf([1], [1, 3, 2]) * ct.tf([1], [1, 0, 1])
510540
plt.figure()
511-
plt.title("Poles: %s" %
541+
ct.suptitle("Poles: %s" %
512542
np.array2string(sys.poles(), precision=2, separator=','))
513543
response = ct.nyquist_response(sys)
514544
response.plot()
@@ -517,10 +547,17 @@ def test_freqresp_omega_limits():
517547
print("Discrete time systems")
518548
sys = ct.c2d(sys, 0.01)
519549
plt.figure()
520-
plt.title("Discrete-time; poles: %s" %
550+
ct.suptitle("Discrete-time; poles: %s" %
521551
np.array2string(sys.poles(), precision=2, separator=','))
522552
response = ct.nyquist_response(sys)
523553
response.plot()
524554

525-
526-
555+
print("Frequency response data (FRD) systems")
556+
sys = ct.tf(
557+
(0.02 * s**3 - 0.1 * s) / (s**4 + s**3 + s**2 + 0.25 * s + 0.04),
558+
name='tf')
559+
sys1 = ct.frd(sys, np.logspace(-1, 1, 15), name='frd1')
560+
sys2 = ct.frd(sys, np.logspace(-2, 2, 20), name='frd2')
561+
plt.figure()
562+
ct.nyquist_plot([sys, sys1, sys2])
563+
ct.suptitle("Mixed FRD, tf data")

0 commit comments

Comments
 (0)