7
7
# Nyquist plots and other frequency response plots. The code for Nichols
8
8
# charts is in nichols.py. The code for pole-zero diagrams is in pzmap.py
9
9
# and rlocus.py.
10
- #
11
- # Functionality to add/check (Jul 2023, working list)
12
- # [?] Allow line colors/styles to be set in plot() command (also time plots)
13
- # [ ] Get sisotool working in iPython and document how to make it work
14
- # [ ] Allow use of subplot labels instead of output/input subtitles
15
- # [i] Allow frequency range to be overridden in bode_plot
16
- # [i] Unit tests for discrete time systems with different sample times
17
- # [ ] Add unit tests for ct.config.defaults['freqplot_number_of_samples']
18
10
19
11
import numpy as np
20
12
import matplotlib as mpl
@@ -103,7 +95,7 @@ def bode_plot(
103
95
overlay_outputs = None , overlay_inputs = None , phase_label = None ,
104
96
magnitude_label = None , display_margins = None ,
105
97
margins_method = 'best' , legend_map = None , legend_loc = None ,
106
- sharex = None , sharey = None , title = None , relabel = True , ** kwargs ):
98
+ sharex = None , sharey = None , title = None , ** kwargs ):
107
99
"""Bode plot for a system.
108
100
109
101
Plot the magnitude and phase of the frequency response over a
@@ -116,7 +108,8 @@ def bode_plot(
116
108
single system or frequency response can also be passed.
117
109
omega : array_like, optoinal
118
110
List of frequencies in rad/sec over to plot over. If not specified,
119
- this will be determined from the proporties of the systems.
111
+ this will be determined from the proporties of the systems. Ignored
112
+ if `data` is not a list of systems.
120
113
*fmt : :func:`matplotlib.pyplot.plot` format string, optional
121
114
Passed to `matplotlib` as the format string for all lines in the plot.
122
115
The `omega` parameter must be present (use omega=None if needed).
@@ -147,16 +140,6 @@ def bode_plot(
147
140
148
141
Other Parameters
149
142
----------------
150
- plot : bool, optional
151
- (legacy) If given, `bode_plot` returns the legacy return values
152
- of magnitude, phase, and frequency. If False, just return the
153
- values with no plot.
154
- omega_limits : array_like of two values
155
- Limits of the to generate frequency vector. If Hz=True the limits
156
- are in Hz otherwise in rad/s.
157
- omega_num : int
158
- Number of samples to plot. Defaults to
159
- config.defaults['freqplot.number_of_samples'].
160
143
grid : bool
161
144
If True, plot grid lines on gain and phase plots. Default is set by
162
145
`config.defaults['freqplot.grid']`.
@@ -166,6 +149,20 @@ def bode_plot(
166
149
value specified. Units are in either degrees or radians, depending on
167
150
the `deg` parameter. Default is -180 if wrap_phase is False, 0 if
168
151
wrap_phase is True.
152
+ omega_limits : array_like of two values
153
+ Set limits for plotted frequency range. If Hz=True the limits
154
+ are in Hz otherwise in rad/s.
155
+ omega_num : int
156
+ Number of samples to use for the frequeny range. Defaults to
157
+ config.defaults['freqplot.number_of_samples']. Ignore if data is
158
+ not a list of systems.
159
+ plot : bool, optional
160
+ (legacy) If given, `bode_plot` returns the legacy return values
161
+ of magnitude, phase, and frequency. If False, just return the
162
+ values with no plot.
163
+ rcParams : dict
164
+ Override the default parameters used for generating plots.
165
+ Default is set up config.default['freqplot.rcParams'].
169
166
wrap_phase : bool or float
170
167
If wrap_phase is `False` (default), then the phase will be unwrapped
171
168
so that it is continuously increasing or decreasing. If wrap_phase is
@@ -220,7 +217,6 @@ def bode_plot(
220
217
'freqplot' , 'wrap_phase' , kwargs , _freqplot_defaults , pop = True )
221
218
initial_phase = config ._get_param (
222
219
'freqplot' , 'initial_phase' , kwargs , None , pop = True )
223
- omega_num = config ._get_param ('freqplot' , 'number_of_samples' , omega_num )
224
220
freqplot_rcParams = config ._get_param (
225
221
'freqplot' , 'rcParams' , kwargs , _freqplot_defaults , pop = True )
226
222
@@ -262,7 +258,7 @@ def bode_plot(
262
258
data = [data ]
263
259
264
260
#
265
- # Pre-process the data to be plotted (unwrap phase)
261
+ # Pre-process the data to be plotted (unwrap phase, limit frequencies )
266
262
#
267
263
# To maintain compatibility with legacy uses of bode_plot(), we do some
268
264
# initial processing on the data, specifically phase unwrapping and
@@ -277,6 +273,17 @@ def bode_plot(
277
273
data = frequency_response (
278
274
data , omega = omega , omega_limits = omega_limits ,
279
275
omega_num = omega_num , Hz = Hz )
276
+ else :
277
+ # Generate warnings if frequency keywords were given
278
+ if omega_num is not None :
279
+ warnings .warn ("`omega_num` ignored when passed response data" )
280
+ elif omega is not None :
281
+ warnings .warn ("`omega` ignored when passed response data" )
282
+
283
+ # Check to make sure omega_limits is sensible
284
+ if omega_limits is not None and \
285
+ (len (omega_limits ) != 2 or omega_limits [1 ] <= omega_limits [0 ]):
286
+ raise ValueError (f"invalid limits: { omega_limits = } " )
280
287
281
288
# If plot_phase is not specified, check the data first, otherwise true
282
289
if plot_phase is None :
@@ -288,7 +295,6 @@ def bode_plot(
288
295
289
296
mag_data , phase_data , omega_data = [], [], []
290
297
for response in data :
291
- phase = response .phase .copy ()
292
298
noutputs , ninputs = response .noutputs , response .ninputs
293
299
294
300
if initial_phase is None :
@@ -306,9 +312,9 @@ def bode_plot(
306
312
raise ValueError ("initial_phase must be a number." )
307
313
308
314
# Reshape the phase to allow standard indexing
309
- phase = phase .reshape ((noutputs , ninputs , - 1 ))
315
+ phase = response . phase . copy () .reshape ((noutputs , ninputs , - 1 ))
310
316
311
- # Shift and wrap
317
+ # Shift and wrap the phase
312
318
for i , j in itertools .product (range (noutputs ), range (ninputs )):
313
319
# Shift the phase if needed
314
320
if abs (phase [i , j , 0 ] - initial_phase_value ) > math .pi :
@@ -641,7 +647,7 @@ def _make_line_label(response, output_index, input_index):
641
647
# Get the (pre-processed) data in fully indexed form
642
648
mag = mag_data [index ].reshape ((noutputs , ninputs , - 1 ))
643
649
phase = phase_data [index ].reshape ((noutputs , ninputs , - 1 ))
644
- omega_sys , sysname = response . omega , response .sysname
650
+ omega_sys , sysname = omega_data [ index ] , response .sysname
645
651
646
652
for i , j in itertools .product (range (noutputs ), range (ninputs )):
647
653
# Get the axes to use for magnitude and phase
@@ -831,21 +837,17 @@ def _make_line_label(response, output_index, input_index):
831
837
832
838
for i in range (noutputs ):
833
839
for j in range (ninputs ):
840
+ # Utility function to generate phase labels
834
841
def gen_zero_centered_series (val_min , val_max , period ):
835
842
v1 = np .ceil (val_min / period - 0.2 )
836
843
v2 = np .floor (val_max / period + 0.2 )
837
844
return np .arange (v1 , v2 + 1 ) * period
838
845
839
- # TODO: put Nyquist lines here?
840
-
841
- # TODO: what is going on here
842
- # TODO: fix to use less dense labels, when needed
843
- # TODO: make sure turning sharey on and off makes labels come/go
846
+ # Label the phase axes using multiples of 45 degrees
844
847
if plot_phase :
845
848
ax_phase = ax_array [phase_map [i , j ]]
846
849
847
850
# Set the labels
848
- # TODO: tighten up code
849
851
if deg :
850
852
ylim = ax_phase .get_ylim ()
851
853
num = np .floor ((ylim [1 ] - ylim [0 ]) / 45 )
@@ -877,6 +879,11 @@ def gen_zero_centered_series(val_min, val_max, period):
877
879
if share_frequency in [True , 'all' , 'col' ]:
878
880
ax_array [i , j ].tick_params (labelbottom = False )
879
881
882
+ # If specific omega_limits were given, use them
883
+ if omega_limits is not None :
884
+ for i , j in itertools .product (range (nrows ), range (ncols )):
885
+ ax_array [i , j ].set_xlim (omega_limits )
886
+
880
887
#
881
888
# Update the plot title (= figure suptitle)
882
889
#
@@ -895,7 +902,7 @@ def gen_zero_centered_series(val_min, val_max, period):
895
902
else :
896
903
title = data [0 ].title
897
904
898
- if fig is not None and title is not None :
905
+ if fig is not None and isinstance ( title , str ) :
899
906
# Get the current title, if it exists
900
907
old_title = None if fig ._suptitle is None else fig ._suptitle ._text
901
908
new_title = title
@@ -1294,11 +1301,11 @@ def nyquist_response(
1294
1301
# Determine the contour used to evaluate the Nyquist curve
1295
1302
if sys .isdtime (strict = True ):
1296
1303
# Restrict frequencies for discrete-time systems
1297
- nyquistfrq = math .pi / sys .dt
1304
+ nyq_freq = math .pi / sys .dt
1298
1305
if not omega_range_given :
1299
1306
# limit up to and including Nyquist frequency
1300
1307
omega_sys = np .hstack ((
1301
- omega_sys [omega_sys < nyquistfrq ], nyquistfrq ))
1308
+ omega_sys [omega_sys < nyq_freq ], nyq_freq ))
1302
1309
1303
1310
# Issue a warning if we are sampling above Nyquist
1304
1311
if np .any (omega_sys * sys .dt > np .pi ) and warn_nyquist :
@@ -1817,7 +1824,7 @@ def _parse_linestyle(style_name, allow_false=False):
1817
1824
x , y = resp .real .copy (), resp .imag .copy ()
1818
1825
x [reg_mask ] *= (1 + curve_offset [reg_mask ])
1819
1826
y [reg_mask ] *= (1 + curve_offset [reg_mask ])
1820
- p = plt .plot (x , y , linestyle = 'None' , color = c , ** kwargs )
1827
+ p = plt .plot (x , y , linestyle = 'None' , color = c )
1821
1828
1822
1829
# Add arrows
1823
1830
ax = plt .gca ()
@@ -2210,10 +2217,6 @@ def singular_values_plot(
2210
2217
Hz : bool
2211
2218
If True, plot frequency in Hz (omega must be provided in rad/sec).
2212
2219
Default value (False) set by config.defaults['freqplot.Hz'].
2213
- plot : bool, optional
2214
- (legacy) If given, `singular_values_plot` returns the legacy return
2215
- values of magnitude, phase, and frequency. If False, just return
2216
- the values with no plot.
2217
2220
legend_loc : str, optional
2218
2221
For plots with multiple lines, a legend will be included in the
2219
2222
given location. Default is 'center right'. Use False to supress.
@@ -2233,6 +2236,26 @@ def singular_values_plot(
2233
2236
omega : ndarray (or list of ndarray if len(data) > 1))
2234
2237
If plot=False, frequency in rad/sec (deprecated).
2235
2238
2239
+ Other Parameters
2240
+ ----------------
2241
+ grid : bool
2242
+ If True, plot grid lines on gain and phase plots. Default is set by
2243
+ `config.defaults['freqplot.grid']`.
2244
+ omega_limits : array_like of two values
2245
+ Set limits for plotted frequency range. If Hz=True the limits
2246
+ are in Hz otherwise in rad/s.
2247
+ omega_num : int
2248
+ Number of samples to use for the frequeny range. Defaults to
2249
+ config.defaults['freqplot.number_of_samples']. Ignore if data is
2250
+ not a list of systems.
2251
+ plot : bool, optional
2252
+ (legacy) If given, `singular_values_plot` returns the legacy return
2253
+ values of magnitude, phase, and frequency. If False, just return
2254
+ the values with no plot.
2255
+ rcParams : dict
2256
+ Override the default parameters used for generating plots.
2257
+ Default is set up config.default['freqplot.rcParams'].
2258
+
2236
2259
"""
2237
2260
# Keyword processing
2238
2261
dB = config ._get_param (
@@ -2241,7 +2264,6 @@ def singular_values_plot(
2241
2264
'freqplot' , 'Hz' , kwargs , _freqplot_defaults , pop = True )
2242
2265
grid = config ._get_param (
2243
2266
'freqplot' , 'grid' , kwargs , _freqplot_defaults , pop = True )
2244
- omega_num = config ._get_param ('freqplot' , 'number_of_samples' , omega_num )
2245
2267
freqplot_rcParams = config ._get_param (
2246
2268
'freqplot' , 'rcParams' , kwargs , _freqplot_defaults , pop = True )
2247
2269
@@ -2255,6 +2277,17 @@ def singular_values_plot(
2255
2277
data , omega = omega , omega_limits = omega_limits ,
2256
2278
omega_num = omega_num )
2257
2279
else :
2280
+ # Generate warnings if frequency keywords were given
2281
+ if omega_num is not None :
2282
+ warnings .warn ("`omega_num` ignored when passed response data" )
2283
+ elif omega is not None :
2284
+ warnings .warn ("`omega` ignored when passed response data" )
2285
+
2286
+ # Check to make sure omega_limits is sensible
2287
+ if omega_limits is not None and \
2288
+ (len (omega_limits ) != 2 or omega_limits [1 ] <= omega_limits [0 ]):
2289
+ raise ValueError (f"invalid limits: { omega_limits = } " )
2290
+
2258
2291
responses = data
2259
2292
2260
2293
# Process (legacy) plot keyword
@@ -2308,48 +2341,49 @@ def singular_values_plot(
2308
2341
# Create a list of lines for the output
2309
2342
out = np .empty (len (data ), dtype = object )
2310
2343
2344
+ # Plot the singular values for each response
2311
2345
for idx_sys , response in enumerate (responses ):
2312
2346
sigma = sigmas [idx_sys ].transpose () # frequency first for plotting
2313
- omega_sys = omegas [idx_sys ]
2347
+ omega = omegas [idx_sys ] / (2 * math .pi ) if Hz else omegas [idx_sys ]
2348
+
2314
2349
if response .isdtime (strict = True ):
2315
- nyquistfrq = math .pi / response .dt
2350
+ nyq_freq = ( 0.5 / response . dt ) if Hz else ( math .pi / response .dt )
2316
2351
else :
2317
- nyquistfrq = None
2352
+ nyq_freq = None
2318
2353
2319
- color = color_cycle [(idx_sys + color_offset ) % len (color_cycle )]
2320
- color = kwargs .pop ('color' , color )
2321
-
2322
- # TODO: copy from above
2323
- nyquistfrq_plot = None
2324
- if Hz :
2325
- omega_plot = omega_sys / (2. * math .pi )
2326
- if nyquistfrq :
2327
- nyquistfrq_plot = nyquistfrq / (2. * math .pi )
2354
+ # See if the color was specified, otherwise rotate
2355
+ if kwargs .get ('color' , None ) or any (
2356
+ [isinstance (arg , str ) and
2357
+ any ([c in arg for c in "bgrcmykw#" ]) for arg in fmt ]):
2358
+ color_arg = {} # color set by *fmt, **kwargs
2328
2359
else :
2329
- omega_plot = omega_sys
2330
- if nyquistfrq :
2331
- nyquistfrq_plot = nyquistfrq
2332
- sigma_plot = sigma
2360
+ color_arg = {'color' : color_cycle [
2361
+ (idx_sys + color_offset ) % len (color_cycle )]}
2333
2362
2334
2363
# Decide on the system name
2335
2364
sysname = response .sysname if response .sysname is not None \
2336
2365
else f"Unknown-{ idx_sys } "
2337
2366
2367
+ # Plot the data
2338
2368
if dB :
2339
2369
with plt .rc_context (freqplot_rcParams ):
2340
2370
out [idx_sys ] = ax_sigma .semilogx (
2341
- omega_plot , 20 * np .log10 (sigma_plot ), * fmt , color = color ,
2342
- label = sysname , ** kwargs )
2371
+ omega , 20 * np .log10 (sigma ), * fmt ,
2372
+ label = sysname , ** color_arg , ** kwargs )
2343
2373
else :
2344
2374
with plt .rc_context (freqplot_rcParams ):
2345
2375
out [idx_sys ] = ax_sigma .loglog (
2346
- omega_plot , sigma_plot , color = color , label = sysname ,
2347
- * fmt , ** kwargs )
2376
+ omega , sigma , label = sysname , * fmt , ** color_arg , ** kwargs )
2348
2377
2349
- if nyquistfrq_plot is not None :
2378
+ # Plot the Nyquist frequency
2379
+ if nyq_freq is not None :
2350
2380
ax_sigma .axvline (
2351
- nyquistfrq_plot , color = color , linestyle = '--' ,
2352
- label = '_nyq_freq_' + sysname )
2381
+ nyq_freq , linestyle = '--' , label = '_nyq_freq_' + sysname ,
2382
+ ** color_arg )
2383
+
2384
+ # If specific omega_limits were given, use them
2385
+ if omega_limits is not None :
2386
+ ax_sigma .set_xlim (omega_limits )
2353
2387
2354
2388
# Add a grid to the plot + labeling
2355
2389
if grid :
0 commit comments