Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update frequency response plots to use _response/_plot pattern #924

Merged
merged 17 commits into from
Sep 16, 2023

Conversation

murrayrm
Copy link
Member

@murrayrm murrayrm commented Jul 22, 2023

This PR updates frequency response plots to use the _response/_plot calling pattern described in #645. For Bode, Nichols, and singular values plots, the following patterns will work:

response = frequency_response(syslist, response_options)
lines = response.plot(plot_options)

lines = name_plot(response, plot_options)

lines = name_plot(syslist, response_options, plot_options)

There are also updates to gangof4_response/plot and nyquist_response/plot.

Everything is mostly backwards compatible except that the outputs from a _plot function are now an array of Line2D objects instead of other information (mag/phase/frequency, counts, etc). You can still get the original data using the _response function and there is some legacy processing if you used the plot keyword (eg, plot=False) to try let some code work without changes.

The changes to Nyquist plots are illustrative of where code might break. Before, you could do this:

count = nyquist_plot(sys)

to get both a Nyquist plot and a count of the number of encirclements. That will no longer work, since nyquist_plot returns an array of lines. Instead, you need to do this

response = nyquist_response(sys)
count = response.count
lines = response.plot()

There are also some changes when you pass a list of systems. Before, you would say

counts = nyquist_plot([sys1, sys2])

This no longer works because nyquist_response returns a list of responses (one response for each system). In the new version, you say

responses = nyquist_response([sys1, sys2])
counts = [response.count for response in responses]
lines = responses.plot()

Note that even though nyquist_response is returning a list of responses, you can still say responses.plot() to get the (single) Nyquist plot for the list of systems (with different systems in different colors). This (and similar functionality for frequency_response / bode_plot) works through returning a special NyquistResponseList object (FrequencyResponseList for frequency_response) that extends the Python list data type and adds a plot method).

Summary of changes:

  • All frequency response plots now have a _response functions and a _plot function. You can access the latter via the .plot() method on the response.
  • Frequency response plots accept either the output of the _response function or a list of systems (in which case the _response function is called internally). This allows the common pattern of bode_plot(sys), nyquist_plot(sys) to work as expected.
  • For a frequency response, you can set the type of plot that you want using the plot_type keyword in the plot method (so ct.frequency_response(sys).plot(plot_type='nichols') will work).
  • Default plot types are set up so that you get what you expect (eg, ct.singular_values_plot(sys).plot() generates a singular values plot, not a Bode plot).
  • The short version bode, nyquist, and nichols are still there.
  • The control.matlab version of bode returns mag, phase, freq (compatible with MATLAB)
  • Added unit tests plus user documentation.
  • Some other small fixes, code streamlining, etc along the way.
  • Removed deprecated functionality in frequency plotting code (e.g. Plot and labelFreq keywords).

This PR is going to break existing code. It would be great if a few people could try this out so that we can make sure we are OK with the changes here. There are still a few things I am implementing (see top of freqplot.py) so I'll leave this in draft mode for a bit, but wanted to start getting feedback on the changes, since they are pretty substantial.

Examples (from the user documentation):

Linear time invariant (LTI) systems can be analyzed in terms of their frequency response and python-control provides a variety of tools for carrying out frequency response analysis. The most basic of these is the frequency_response function, which will compute the frequency response for one or more linear systems:

sys1 = ct.tf([1], [1, 2, 1], name='sys1')
sys2 = ct.tf([1, 0.2], [1, 1, 3, 1, 1], name='sys2')
response = ct.frequency_response([sys1, sys2])

A Bode plot provide a graphical view of the response an LTI system and can be generated using the bode_plot function:

ct.bode_plot(response, initial_phase=0)

freqplot-siso_bode-default

Computing the response for multiple systems at the same time yields a common frequency range that covers the features of all listed systems.

Bode plots can also be created directly using the FrequencyResponseData.plot method:

sys_mimo = ct.tf(
    [[[1], [0.1]], [[0.2], [1]]],
    [[[1, 0.6, 1], [1, 1, 1]], [[1, 0.4, 1], [1, 2, 1]]], name="sys_mimo")
ct.frequency_response(sys_mimo).plot()

freqplot-mimo_bode-default

A variety of options are available for customizing Bode plots, for example allowing the display of the phase to be turned off or overlaying the inputs or outputs:

ct.frequency_response(sys_mimo).plot(
    plot_phase=False, overlay_inputs=True, overlay_outputs=True)

freqplot-mimo_bode-magonly

The singular_values_response function can be used to generate Bode plots that show the singular values of a transfer function:

ct.singular_values_response(sys_mimo).plot()

freqplot-mimo_svplot-default

Different types of plots can also be specified for a given frequency response. For example, to plot the frequency response using a a Nichols plot, use plot_type='nichols':

response.plot(plot_type='nichols')

freqplot-siso_nichols-default

Another response function that can be used to generate Bode plots is the :func:gangof4 function, which computes the four primary sensitivity functions for a feedback control system in standard form:

  proc = ct.tf([1], [1, 1, 1], name="process")
  ctrl = ct.tf([100], [1, 5], name="control")
  response = rect.gangof4_response(proc, ctrl)
  ct.bode_plot(response)	# or response.plot()

freqplot-gangof4

@murrayrm murrayrm marked this pull request as draft July 22, 2023 16:56
@coveralls
Copy link

coveralls commented Jul 22, 2023

Coverage Status

coverage: 95.01% (+0.04%) from 94.969% when pulling 8e84d00 on murrayrm:freq_plots-30Jun2023 into 0a6146b on python-control:main.

@sawyerbfuller
Copy link
Contributor

Just a few quick thoughts/comments, I may have more late if/when I have a chance to test it.

Overall this seems to be very much in the spirit of the original discussion and will fix some oddities as well as making mimo plots easier to work with.

I think breaking code that does counts = ct.nyquist_plot(sys) seems reasonable because we probably shouldn't mix plotting and computation functionality; a plot function should return references to plotting objects (as it will with this PR).

Clever idea for how to handle lists of responses. Is this considered a legit pythonic way to do that?

@murrayrm murrayrm marked this pull request as ready for review August 3, 2023 05:42
plot=True, omega_limits=None, omega_num=None,
margins=None, method='best', *args, **kwargs):
def bode_plot(
data, omega=None, *fmt, ax=None, omega_limits=None, omega_num=None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be the time to deprecate omega, omega_limits, and omega_num in favor of the more general frequency,frequency_limits, and frequency_num given that omega suggests rad/s even though these are specified in either rad/s or hz depending on the defaults.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: looking elsewhere, sticking with omega may make more sense for nyquist and nichols. not sure about bode yet. Would be nice to be able to specify ranges in Hz for the bode plot though - was this a feature before?

@sawyerbfuller
Copy link
Contributor

This is looking good and fixes some long-standing bugs/cobwebs. A few comments/questions, some of which I might be able to answer by deeply looking into the code, but I a quick reply giving the high level thinking might be very informative:

  • does the response know what kind of plot it is intended for? is it different for a nyquist plot than for a bode plot? in other words, response = nyquist_response(sys); response.plot() will create a nyquist plot and response=frequency_response(sys); response.plot() will create a bode plot? are the frequency points and ranges actually the same?
  • and you can override the default type using the plot_type argument?
  • seems like the _plot functions should be the ones that are strongy emphasized in the docs becase of the aforementioned subtleties
  • relatedly, in the discussion of in nyquist plots, draw contour at specified magnitude if threshold is exceeded #671, you mentioned in this post that you had created a branch that included enhancements to computing points near poles. Not sure if that ever made it in, but I am including it here for reference.

@murrayrm
Copy link
Member Author

Clever idea for how to handle lists of responses. Is this considered a legit pythonic way to do that?

I think so. It is allows to use list as the base class of a new object (since Python 2.2) and presumably the reason is to add functionality to the list class, which is what was done here. Others may know more.

This seems to be the time to deprecate omega, omega_limits, and omega_num in favor of the more general frequency, frequency_limits, and frequency_num given that omega suggests rad/s even though these are specified in either rad/s or hz depending on the defaults.

Edit: looking elsewhere, sticking with omega may make more sense for nyquist and nichols. not sure about bode yet. Would be nice to be able to specify ranges in Hz for the bode plot though - was this a feature before?

This is worth thinking about. I find the omega_limits and omega_num to be a bit odd and it would be nice to have a consistent way of dealing with this. This is not directly related to the new plotting paradigm, so perhaps for a separate PR.

  • does the response know what kind of plot it is intended for? is it different for a nyquist plot than for a bode plot? in other words, response = nyquist_response(sys); response.plot() will create a nyquist plot and response=frequency_response(sys); response.plot() will create a bode plot? are the frequency points and ranges actually the same?

Yes. All of the plots keep track of the type of command that generated them and then call the appropriate plot function. This also happens in the time response plots, where the default output for a step response is different than a forced response (the former shows output only, by default, the latter shows outputs and inputs).

Regarding the frequency points/ranges: these are different for Bode and Nyquist. A Bode plot uses the frequency response, which evaluates an LTI system on the positive imaginary axis. A Nyquist plot evaluates the response along the Nyquist "D" contour. So these are not exactly interchangeable (more below).

and you can override the default type using the plot_type argument?

Yes, but not for Bode and Nyquist, since those are actually different responses. Here's a summary of various things that work:

  • The singular_value_response function generates a frequency response and, by default, plots the singular values (magnitude only, all outputs on a single graph). You can change that using plot_type='bode', which will then show that response as standard Bode plot. You can do the same in the reverse direction: if you call plot_type='svplot' on a frequency response, it will attempt to generate a singular value plot. (Usually that is the wrong thing to do since singular values are real and so a warning is issued if the imaginary part of a frequency response is nonzero for a singular value plot.)
  • The types of plots supported by FrequencyResponseData.plot() using the plot_type keyword are 'bode', 'svplot', and 'nichols' (for a Nichols chart).
  • There is no plot_type='nyquist' functionality because these are different responses.
  • In principle, we could set things up to generate a Nyquist plot from a frequency response object by passing the response to the nyquist_response (or nyquist_plot) function. However, this could generate odd responses since we don't know how to complete the Nyquist curve around poles, at the origin (which is a real value), or at infinity (if the transfer function is not strictly proper).

seems like the _plot functions should be the ones that are strongy emphasized in the docs becase of the aforementioned subtleties

Agree.

relatedly, in the discussion of in nyquist plots, draw contour at specified magnitude if threshold is exceeded #671, you mentioned in this post that you had created a branch that included enhancements to computing points near poles. Not sure if that ever made it in, but I am including it here for reference.

Those changes are all in the main branch now (via #534).

I'll leave this open for a bit longer in case others have comments, but looks to me like this is OK to merge, with a later PR for thinking through improving omega_{limits,num}.

@murrayrm murrayrm merged commit a11d3be into python-control:main Sep 16, 2023
14 checks passed
@murrayrm murrayrm deleted the freq_plots-30Jun2023 branch September 16, 2023 17:30
@murrayrm murrayrm added this to the 0.10.0 milestone Mar 31, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants