77# Nyquist plots and other frequency response plots. The code for Nichols
88# charts is in nichols.py. The code for pole-zero diagrams is in pzmap.py
99# 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']
1810
1911import numpy as np
2012import matplotlib as mpl
@@ -103,7 +95,7 @@ def bode_plot(
10395 overlay_outputs = None , overlay_inputs = None , phase_label = None ,
10496 magnitude_label = None , display_margins = None ,
10597 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 ):
10799 """Bode plot for a system.
108100
109101 Plot the magnitude and phase of the frequency response over a
@@ -116,7 +108,8 @@ def bode_plot(
116108 single system or frequency response can also be passed.
117109 omega : array_like, optoinal
118110 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.
120113 *fmt : :func:`matplotlib.pyplot.plot` format string, optional
121114 Passed to `matplotlib` as the format string for all lines in the plot.
122115 The `omega` parameter must be present (use omega=None if needed).
@@ -147,16 +140,6 @@ def bode_plot(
147140
148141 Other Parameters
149142 ----------------
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'].
160143 grid : bool
161144 If True, plot grid lines on gain and phase plots. Default is set by
162145 `config.defaults['freqplot.grid']`.
@@ -166,6 +149,20 @@ def bode_plot(
166149 value specified. Units are in either degrees or radians, depending on
167150 the `deg` parameter. Default is -180 if wrap_phase is False, 0 if
168151 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'].
169166 wrap_phase : bool or float
170167 If wrap_phase is `False` (default), then the phase will be unwrapped
171168 so that it is continuously increasing or decreasing. If wrap_phase is
@@ -220,7 +217,6 @@ def bode_plot(
220217 'freqplot' , 'wrap_phase' , kwargs , _freqplot_defaults , pop = True )
221218 initial_phase = config ._get_param (
222219 'freqplot' , 'initial_phase' , kwargs , None , pop = True )
223- omega_num = config ._get_param ('freqplot' , 'number_of_samples' , omega_num )
224220 freqplot_rcParams = config ._get_param (
225221 'freqplot' , 'rcParams' , kwargs , _freqplot_defaults , pop = True )
226222
@@ -262,7 +258,7 @@ def bode_plot(
262258 data = [data ]
263259
264260 #
265- # Pre-process the data to be plotted (unwrap phase)
261+ # Pre-process the data to be plotted (unwrap phase, limit frequencies )
266262 #
267263 # To maintain compatibility with legacy uses of bode_plot(), we do some
268264 # initial processing on the data, specifically phase unwrapping and
@@ -277,6 +273,17 @@ def bode_plot(
277273 data = frequency_response (
278274 data , omega = omega , omega_limits = omega_limits ,
279275 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 = } " )
280287
281288 # If plot_phase is not specified, check the data first, otherwise true
282289 if plot_phase is None :
@@ -288,7 +295,6 @@ def bode_plot(
288295
289296 mag_data , phase_data , omega_data = [], [], []
290297 for response in data :
291- phase = response .phase .copy ()
292298 noutputs , ninputs = response .noutputs , response .ninputs
293299
294300 if initial_phase is None :
@@ -306,9 +312,9 @@ def bode_plot(
306312 raise ValueError ("initial_phase must be a number." )
307313
308314 # Reshape the phase to allow standard indexing
309- phase = phase .reshape ((noutputs , ninputs , - 1 ))
315+ phase = response . phase . copy () .reshape ((noutputs , ninputs , - 1 ))
310316
311- # Shift and wrap
317+ # Shift and wrap the phase
312318 for i , j in itertools .product (range (noutputs ), range (ninputs )):
313319 # Shift the phase if needed
314320 if abs (phase [i , j , 0 ] - initial_phase_value ) > math .pi :
@@ -641,7 +647,7 @@ def _make_line_label(response, output_index, input_index):
641647 # Get the (pre-processed) data in fully indexed form
642648 mag = mag_data [index ].reshape ((noutputs , ninputs , - 1 ))
643649 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
645651
646652 for i , j in itertools .product (range (noutputs ), range (ninputs )):
647653 # Get the axes to use for magnitude and phase
@@ -831,21 +837,17 @@ def _make_line_label(response, output_index, input_index):
831837
832838 for i in range (noutputs ):
833839 for j in range (ninputs ):
840+ # Utility function to generate phase labels
834841 def gen_zero_centered_series (val_min , val_max , period ):
835842 v1 = np .ceil (val_min / period - 0.2 )
836843 v2 = np .floor (val_max / period + 0.2 )
837844 return np .arange (v1 , v2 + 1 ) * period
838845
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
844847 if plot_phase :
845848 ax_phase = ax_array [phase_map [i , j ]]
846849
847850 # Set the labels
848- # TODO: tighten up code
849851 if deg :
850852 ylim = ax_phase .get_ylim ()
851853 num = np .floor ((ylim [1 ] - ylim [0 ]) / 45 )
@@ -877,6 +879,11 @@ def gen_zero_centered_series(val_min, val_max, period):
877879 if share_frequency in [True , 'all' , 'col' ]:
878880 ax_array [i , j ].tick_params (labelbottom = False )
879881
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+
880887 #
881888 # Update the plot title (= figure suptitle)
882889 #
@@ -895,7 +902,7 @@ def gen_zero_centered_series(val_min, val_max, period):
895902 else :
896903 title = data [0 ].title
897904
898- if fig is not None and title is not None :
905+ if fig is not None and isinstance ( title , str ) :
899906 # Get the current title, if it exists
900907 old_title = None if fig ._suptitle is None else fig ._suptitle ._text
901908 new_title = title
@@ -1294,11 +1301,11 @@ def nyquist_response(
12941301 # Determine the contour used to evaluate the Nyquist curve
12951302 if sys .isdtime (strict = True ):
12961303 # Restrict frequencies for discrete-time systems
1297- nyquistfrq = math .pi / sys .dt
1304+ nyq_freq = math .pi / sys .dt
12981305 if not omega_range_given :
12991306 # limit up to and including Nyquist frequency
13001307 omega_sys = np .hstack ((
1301- omega_sys [omega_sys < nyquistfrq ], nyquistfrq ))
1308+ omega_sys [omega_sys < nyq_freq ], nyq_freq ))
13021309
13031310 # Issue a warning if we are sampling above Nyquist
13041311 if np .any (omega_sys * sys .dt > np .pi ) and warn_nyquist :
@@ -1817,7 +1824,7 @@ def _parse_linestyle(style_name, allow_false=False):
18171824 x , y = resp .real .copy (), resp .imag .copy ()
18181825 x [reg_mask ] *= (1 + curve_offset [reg_mask ])
18191826 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 )
18211828
18221829 # Add arrows
18231830 ax = plt .gca ()
@@ -2210,10 +2217,6 @@ def singular_values_plot(
22102217 Hz : bool
22112218 If True, plot frequency in Hz (omega must be provided in rad/sec).
22122219 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.
22172220 legend_loc : str, optional
22182221 For plots with multiple lines, a legend will be included in the
22192222 given location. Default is 'center right'. Use False to supress.
@@ -2233,6 +2236,26 @@ def singular_values_plot(
22332236 omega : ndarray (or list of ndarray if len(data) > 1))
22342237 If plot=False, frequency in rad/sec (deprecated).
22352238
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+
22362259 """
22372260 # Keyword processing
22382261 dB = config ._get_param (
@@ -2241,7 +2264,6 @@ def singular_values_plot(
22412264 'freqplot' , 'Hz' , kwargs , _freqplot_defaults , pop = True )
22422265 grid = config ._get_param (
22432266 'freqplot' , 'grid' , kwargs , _freqplot_defaults , pop = True )
2244- omega_num = config ._get_param ('freqplot' , 'number_of_samples' , omega_num )
22452267 freqplot_rcParams = config ._get_param (
22462268 'freqplot' , 'rcParams' , kwargs , _freqplot_defaults , pop = True )
22472269
@@ -2255,6 +2277,17 @@ def singular_values_plot(
22552277 data , omega = omega , omega_limits = omega_limits ,
22562278 omega_num = omega_num )
22572279 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+
22582291 responses = data
22592292
22602293 # Process (legacy) plot keyword
@@ -2308,48 +2341,49 @@ def singular_values_plot(
23082341 # Create a list of lines for the output
23092342 out = np .empty (len (data ), dtype = object )
23102343
2344+ # Plot the singular values for each response
23112345 for idx_sys , response in enumerate (responses ):
23122346 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+
23142349 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 )
23162351 else :
2317- nyquistfrq = None
2352+ nyq_freq = None
23182353
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
23282359 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 )]}
23332362
23342363 # Decide on the system name
23352364 sysname = response .sysname if response .sysname is not None \
23362365 else f"Unknown-{ idx_sys } "
23372366
2367+ # Plot the data
23382368 if dB :
23392369 with plt .rc_context (freqplot_rcParams ):
23402370 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 )
23432373 else :
23442374 with plt .rc_context (freqplot_rcParams ):
23452375 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 )
23482377
2349- if nyquistfrq_plot is not None :
2378+ # Plot the Nyquist frequency
2379+ if nyq_freq is not None :
23502380 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 )
23532387
23542388 # Add a grid to the plot + labeling
23552389 if grid :
0 commit comments