GeezerVison is showing github.com — New plotting paradigm · python-control/python-control · Discussion #645
New plotting paradigm
#645
Aug 10, 2021
·
22 comments
·
58 replies
Discussion options
-
Uh oh!
There was an error while loading. Please reload this page.
|
@billtubbs posted in billtubbs/python-control-examples and mentioned in #65 (comment):
Plotting Paradigm
This is a proposal to introduce a different paradigm for the way specialised control analyses and plots are created in Python-Control.
It is based on the way plotting is done in Pandas.
For example:
s = pandas.Series(my_data, index=my_index, name="my series") # instantiate object
print(s.dtype) # get an attribute
s.plot(style="o-") # make a plot
plt.show()
avg = s.mean() # do something else
Functions this proposal could affect
root_locus
pzmap
bode_plot
nyquist_plot
gangof4_plot
nichols_plot
sisotool?
Rationale
The current paradigm for these plot functions is based on the way they are done in MATLAB using the functions pzmap, bode, nyquist, etc. These functions can produce a plot and/or the data itself, depending on how you use them. This style is typical of a functional programming language that does not have support for object-oriented programming.
Problem:
- Doing the calculations and making the plot are two separate tasks. Doing both with one function is arguably 'overloading' it. This leads to a larger set of arguments and return values and somewhat restricts the amount of variables that can be returned or accessed after the function is called.
Solution:
- Split the process into two steps:
- Step 1, do the calculations and generate the data
- Step 2, make the plot
- Use a Python object (class) to access the data and plot method.
The benefits of this approach are:
- The inputs, outputs and arguments of each step are separated
- A greater variety of variables and attributes can be accessed
- Easier to get a handle to the plot axis for customizing plots
- Easily extensible — more functionality could be added as methods
- It is familiar to many Python users (e.g. data scientists using Pandas).
To illustrate how this might work, I have outlined some examples below.
Example 1 – Root locus plot
Current method:
from control import rlocus
rlocus(my_sys, **kwargs)
plt.show()
Proposed method:
from control import RootLocus
rl = RootLocus(my_sys, **kwargs) # calculation arguments go here
rl.plot(**kwargs) # plotting arguments here
plt.show()
or:
from control import RootLocus
RootLocus(my_sys).plot(**kwargs)
plt.show()
Calculate the root locus without making the plot
Current method:
rlist, klist = rlocus(my_sys, Plot=False)
Proposed method:
rl = RootLocus(my_sys) # does not make a plot by default
rl.rlist, rl.klist # access the data
Calculate and plot the root locus
Current method:
rlist, klist = rlocus(my_sys)
plt.show()
Proposed method:
rl = RootLocus(my_sys)
rl.rlist, rl.klist # access the data
rl.plot()
plt.show()
Customizing the root locus plot
Current method:
rlocus(my_sys)
ax = plt.gca()
#ax.lines[??].set_linewidth(2) # not easy to do
ax.annotate("label", (-1, 0))
plt.show()
Proposed method:
rl = RootLocus(my_sys)
ax = rl.plot(linewidth=2) # plotting arguments here
ax.annotate("label", (-1, 0))
plt.show()
Adding root locus to a custom plot
Current method:
fig, axes = plt.subplots(2, 1)
ax = axes[0]
rlocus(sys1, ax=axes[0])
ax.set_title("System 1")
ax = axes[1]
rlocus(sys2, ax=axes[1])
ax.set_title("System 2")
plt.tight_layout()
plt.show()
Proposed method:
rl1 = RootLocus(sys1)
rl2 = RootLocus(sys2)
fig, axes = plt.subplots(2, 1)
ax = axes[0]
rl1.plot(ax=ax)
ax.set_title("System 1")
ax = axes[1]
rl2.plot(ax=ax)
ax.set_title("System 2")
plt.show()
Other comments
Introducing this would not require the replacement of the MATLAB-like functions rlocus, nyquist, pzmap, etc. although keeping the current root_locus, bode_plot, and nyquist_plot functions seems a bit redundant.
Functions such as freqresp would be unaffected and would probably be called by FreqResp to do the calculations.
The solution is not going to be as simple as the rlocus example above in the case of all plot functions. Some generate their own axes objects (e.g. radial plots) and some generate more than one axis (i.e. a complete figure), so each function will have to be considered case-by-case.
|
You must be logged in to vote
Replies:
22 comments
·
58 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
I like it very much.
We can keep the root_locus, bode_plot, and nyquist_plot functions by having them call the appropriate calls to the new API internally and deprecate them for removal after 2 or 3 minor version updates. (Or major version updates if we decide to cut version 1.0 eventually)
The MATLAB wrappers could to the same without deprecation.
|
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
For reference, I found the old discussion we were having about this at the back end of #291. I think the main problems encountered back then are now fixed (e.g. functions sgrid and zgrid calling plt.figure), but it's worth a quick read as there are probably some other complexities encountered then that we will have to address when we get into the weeds on this again.
|
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
I also like this proposal quite a lot.
We might think through what we want to do from time response and how this fits it. For example, the various time response functions (forced_response, input_output_response, step_response, etc) could return a TimeSeries object with data elements time, input, output, state (or t, u, y, x?) and we could have a plot method.
Another thing to think about: what happens if you want to combine several plots, either in an array (a la plt.subplots) or as multiple lines on a single set of axes? Just call the plot method multiple times?
|
You must be logged in to vote
1 reply
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
The question about time responses and time series data is a good one. I've been thinking about it too. We don't want to make Python-Control dependent on Pandas but I would argue that the Pandas Series and DataFrame objects are perfectly-designed tools for time series data storage, plotting and analysis (I use them all the time when working with industrial time series data).
If we can find a way to seamlessly use Python-Control and Pandas 'hand-in-glove' so-to-speak, that would be very powerful I think. (This could also save us writing a raft of tools to do all that).
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Found another example of this paradigm from scipy.spatial here:
import matplotlib.pyplot as plt
import numpy as np
from scipy.spatial import ConvexHull, convex_hull_plot_2d
points = np.random.random((30, 2))
hull = ConvexHull(points)
_ = convex_hull_plot_2d(hull)
plt.show()
Here, they use a separate function convex_hull_plot_2d for the plotting step and it returns a hook to the whole figure. Not sure why they didn't give ConvexHull a plot_2d() method.
Same thing for scipy.spatial.Voronoi and scipy.spatial.voronoi_plot_2d.
|
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
I am definitely in favor of separating plots and computation.
Just curious about the advantage to @billtubbs suggestion of creating new types of objects, perhaps built on or inspired by pandas, that include a plot method. I think that would require creation of a series of new classes to hold root locus info, frequency response info, and time response info, to name the new classes I can think of that would be required.
Is there a reason it wouldn't be enough to use the simpler alternative of finishing separating out dual-purpose functions like rlocus into computation, e.g. root_locus and plotting, e.g. root_locus_plot? *
import control as ct
fig, axes = plt.subplots(2, 1)
ax = axes[0]
ct.root_locus_plot(ax=ax)
ax.set_title("System 1")
ax = axes[1]
ct.step_plot(ax=ax)
ax.set_title("System 2")```
* outside of the matlab layer, frequency response plots largely exist: ``nyquist_plot``, ``bode_plot``, ``nichols_plot``, but time response plots don't seem to, e.g. ``step_plot``, ``impulse_plot``
|
You must be logged in to vote
2 replies

Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Thanks, I agree, we need to have a good justification to create any new data objects. One thing with two functions, you need to transfer the data from the calculation function to the plotting function. If it's just a couple of things, like 2 arrays, then I think a tuple or dict is fine and it doesn't merit creating an object.
A benefit of an object would be if there is additional 'meta data' that could be automatically passed to the plotting method, such as axis and legend labels. As an example, df.plot() (with no additional arguments), where df is a Pandas dataframe, makes a complete plot with multiple coloured lines, a legend with the names of each series and an x-axis label all from the metadata in the dataframe object.
The case for a new data object gets stronger if it also has additional uses in its own right (i.e. for things other than simply making a plot). I was also wondering, if we make one FreqResp object, can it be used to make a Bode, Nyquist, and Nichols chart? Is that possible? Then, does the FreqResp object have any other uses beyond making plots? In other words, could it be useful in other analysis workflows?
So, in summary I think we need to really challenge the need for any new object and only do it if it has enough benefits over a simpler solution (like two separate functions).
|

Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
The case for a new data object gets stronger if it also has additional uses in its own right (i.e. for things other than simply making a plot). I was also wondering, if we make one FreqResp object, can it be used to make a Bode, Nyquist, and Nichols chart? Is that possible? Then, does the FreqResp object have any other uses beyond making plots? In other words, could it be useful in other analysis workflows?
I think there already is an object that can store frequency_response data:frd.FrequencyResponseData. It can also be used for system-id applications. (By the way, freqresp is now deprecated and only part of matlab layer).
I think there is a case to be made that a TimeResponseData (Or TimeSeriesData) object, with an optional input field as suggested by @murrayrm could have a similar use.
Then this proposal could include simply adding a plot method to these, and maybe adding one more object for root_locus?
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
FYI, there is a similar concept that has been implemented in JuliaControl PR #529, where they return a result structure for time responses.
|
You must be logged in to vote
5 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
It also says this at the bottom:
I will model the freqresp return structure similarly in a new PR when this has been merged
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Had a quick look at the Julia proposal. Looks like functions like lsim, step, etc. will in future return a SimResult(y, t, x, uout, sys) struct (Julia doesn't have object-oriented programming, i.e. classes) and this struct can then be used as a single argument in a new method of the plot function, so you can do:
sim_result = lsim(my_sys, u, t)
plot(sim_result)
or simply:
For comparison, here is what this could look like in Python Control:
sim_result = input_output_response(my_sys, u, t)
sim_result.plot()
or
input_output_response(my_sys, u, t).plot()
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Perhaps, we could preserve the old usage of returning a tuple for the matlab-like functions lsim, step, etc:
yout, t, xout = lsim(sys, u, t, x0)
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
I've been playing around with some ideas for time responses and I have an implementation that allows the following to work:
response = ct.input_output_response(sys, t, u)
response.plot()
or you can say
t, y = ct.input_output_response(sys, t, u)
plt.plot(t, y)
This is all done by defining a new class InputOutputResponse and then changing the time response functions to return an element of that class. By implementing an __iter__ method in the class, you can allow the object to be "assigned" to a tuple.
So far, everything is backward compatible so that all unit tests run without change. But we get the advantage of having an object returned to which we can add additional methods (like plot).
I'll do a bit more work on it and post a draft PR so that people can take a look. I think it fits the intent of the paradigm here.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
For the input-output response, it would be nice (IMO) if response.plot() produced a figure with two subplots: upper plot of (t, y), lower plot of (t, u), with appropriate titles ('Outputs', 'Inputs') and legends ('y_1', 'y_2', ...).
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Interesting proposal. During the pandemic, I've developed a simple plotting lib on the top of python control for my students. The lib uses the Plotly backend (which is also supported now by Panda) since my students mainly use the web-based interface Jupyter for running python scripts
|
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Here is a summary of the relevant analysis and plotting functions currently in Julia ControlSystems (v0.8.0).
| Function |
Purpose |
bode |
Compute the magnitude and phase of the frequency response |
bodeplot |
Create a Bode plot of the system |
freqresp |
Evaluate the frequency response of a linear system |
gangoffourplot |
Gang-of-Four plot |
margin |
Compute frequencies for gain margins, gain margins, frequencies for phase margins, phase margins |
marginplot |
Plot all the amplitude and phase margins of the system |
nicholsplot |
Create a Nichols plot of the system |
nyquist |
Compute the real and imaginary parts of the frequency response |
nyquistplot |
Create a Nyquist plot of the system |
pzmap |
Create a pole-zero map of the system |
rlocus |
Computes and plots the root locus of the SISO LTISystem (deprecated) |
rlocusplot |
Computes and plots the root locus of the SISO LTISystem |
sigma |
Compute the singular values sv of the frequency response |
sigmaplot |
Plot the singular values of the frequency response of the system |
Looks like the pattern is to make separate functions for calculations and plotting where applicable.
Is there any reason why we didn't name the Python-Control root_locus function root_locus_plot? All our other plot functions are clearly differentiable from their calculation-only equivalents.
|
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Here is a table showing the correspondence between current Julia ControlSystems and Python-Control plot functions and the changes I am proposing to
- separate calculation-only functions from plotting functions,
- make our plot functions more consistent with Julia,
- implement this new paradigm where applicable.
Can some of you please go through these and provide comments and feedback?
| Julia |
Python-Control current |
Proposal |
mag, phase, w = bode(sys) |
mag, phase, w = bode_plot(sys, plot=False) |
fr = freqresp(sys) |
fr = freqresp(sys, w) |
mag, phase, w = control.matlab.freqresp(sys, w) |
fr = freqresp(sys, w) |
| n/a |
n/a |
fr.plot() |
bodeplot(sys) |
bode_plot(sys) |
no change |
fig = bodeplot(sys) |
n/a |
fig, axes = bode_plot(sys) |
gangoffourplot(P, C) |
gangof4_plot(P, C) |
no change |
fig = gangoffourplot(P, C) |
n/a |
fig, axes = gangof4_plot(P, C) |
ωgm, gm, ωpm, pm = margin(sys) |
gm, pm, wg, wp = margin(sys) |
no change |
marginplot(sys) |
n/a |
no change for now |
nicholsplot(sys) |
nichols_plot(sys) |
no change |
fig = nicholsplot(sys) |
n/a |
ax = nichols_plot(sys) |
| n/a |
n/a |
nichols_plot(sys, ax=ax) |
re, im, w = nyquist(sys) |
n/a |
no change for now |
nyquistplot(sys) |
nyquist_plot(sys) |
no change |
fig = nyquistplot(sys) |
n/a |
ax = nyquist_plot(sys) |
| n/a |
n/a |
nyquist_plot(sys, ax=ax) |
p, z = pole(sys), tzero(sys) |
p, z = pzmap(sys, plot=False) |
p, z = pole(sys), zero(sys) |
pzmap(sys) |
pzmap(sys) |
pole_zero_map(sys) |
fig = pzmap(fig, sys) |
n/a |
ax = pole_zero_map(sys) |
| n/a |
n/a |
pole_zero_map(sys, ax=ax) |
| n/a |
roots, k_out = root_locus(sys, plot=None) |
roots, k_out = root_locus(sys) |
rlocusplot(sys) |
root_locus(sys) |
root_locus_plot(sys) |
| n/a |
n/a |
ax = root_locus_plot(sys) |
| n/a |
n/a |
root_locus_plot(sys, ax=ax) |
sv, w = sigma(sys) |
sigma, w = singular_values_plot(sys, plot=False) |
sigma, w = singular_values(sys) |
sigmaplot(sys) |
singular_values_plot(sys) |
no change |
| n/a |
n/a |
ax = singular_values_plot(sys) |
| n/a |
n/a |
singular_values_plot(ax=ax) |
y, t, x = step(sys) |
T, yout = step_response(sys) |
response = step_response(sys)* |
y, t, x = impulse(sys) |
T, yout = impulse_response(sys) |
response = impulse_response(sys)* |
y, t, x = lsim(sys, u, t) |
T, yout = forced_response(sys, T, u) |
response = forced_response(sys, T, u)* |
Plots.plot(t, y) |
plt.plot(T, yout) |
response.plot()* |
| n/a |
n/a |
ax = response.plot()* |
| n/a |
n/a |
response.plot(ax=ax)* |
* - these changes are part of a different pull request by Richard.
|
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Hi @billtubbs,
- an alternative to
freqresp(sys, w) is the more pythonic mag, phase, w = sys.frequency_response(w) Remarks: 1. It occurs to me there should probably also be a function frequency_response(sys,w). 2. A todo is to change its output to be simply a complex array eg x = sys.frequency_response(w) as in matlab ).
- also new and not fully documented is the more concise complex frequency response:
x = sys(s)
- there is also
stability_margin That returns the stability margin in addition to gain and phase margins. I have also often wanted a margin_plot Function.
- I think there might now be a
singular_values_plot Function.
- there are already
pole And zero Functions so that is already done.
|
You must be logged in to vote
2 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Thanks for the feedback @sawyerbfuller.
an alternative to freqresp(sys, w) is the more pythonic mag, phase, w = sys.frequency_response(w)
I agree that providing the most commonly-used functions as methods of sys is a good idea. The question is where to draw the line. Here are some other things that could be done (which doesn't mean they should be!):
sys.frequency_response(w)
sys.margin()
sys.pzmap()
sys.root_locus()
sys.bode_plot()
sys.nyquist_plot()
- ...etc.
Or we could have some kind of catch-all plot method (similar to Pandas):
or
I don't plan to do anything with the sys object at the moment unless others think it's important. We can always add these later once the basic plotting functions are sorted out.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
I think there might now be a singular_values_plot Function.
I see that now. I'll add this to the table above. Thanks.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
A few comments:
-
Regarding root_locus versus root_locus_plot: I don't think there was a specific reason we did this. Using _plot for anything that generates a plot makes sense as a pattern (similar to Julia, but using our underscore convention).
-
Another function to think about is pzmap: should this be pole_zero_plot? Or perhaps pole_zero_map? (Note that we can always set pzmap = pole_zero_plot to allow the old usage and we should make sure that the MATLAB compatibility module maintains MATLAB names and signatures. I like pole_zero_plot in terms of consistency, but pzmap should probably still work.
-
I like the idea of having all plotting routines return an axis (or tuple of axes for bode_plot?).
-
I like the idea that all "response" functions return an object and you can then call the plot method to get something useful, with the kind keyword to generate some standard variants (e.g., bode, nichols, nyquist for frequency responses, perhaps output-only, input-output, output-input for time responses?).
-
I like frequency_response over freqresp, especially since we deprecated the latter in REBASE: use __call__ instead of evalfr in lti system classes #499.
-
Perhaps we should (eventually) allow either ct.method(sys) or sys.method() to work, similar to NumPy. Calling ct.method(sys) would then just call the appropriate method.
-
For the pzmap() function: perhaps this should accept a tuple as its input argument (in addition to a system). So you could say pole_zero_plot((poles, zeros)) or pole_zero_plot(sys).
|
You must be logged in to vote
8 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
@billtubbs i am also in favor of everything suggested by @murrayrm above.
In regards to the frd Class, it would be nice if we could re-use it to hold data coming out of eg frequency_response, but it does a lot of other things eg can be multiplied by a transfer function. Maybe it would become ungainly?
@billtubbs in regards to frequency_response, my recollection was that there was consensus that it was an accident that it outputs mag, phase, w. It should change to output response as complex number. If you want mag, phase, w, maybe you could use the new plot-free bode command.
In regards to bode vs. nyquist plots, it turns out they both kind of want similar frequency points( eg both need lots of frequency points around resonant peaks) so it might be possible to do as you suggest and use the same data for each. This is how it works now.
|

Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
For the return value for sys.frequency_response, you can set things up so that this is a FrequencyResponseData object and then set up an __iter__ method that returns either mag, phase, omega (default) or s, omega. Similarly, you can use the property decorator to allow you to have attributes that convert data types. So something like the following should be possible:
fr = sys.frequency_response()
d, w = fr.d, fr.w # d is complex, w is real
mag, phase, w = fr # implement via __iter__
mag, phase, w = fr.mag, fr.phase, fr.w # implement via @property
d, w = fr(return_complex=True) # implement via __iter__
fr.plot(kind='bode')
ct.bode_plot(sys) # same plot
ct.bode_plot(fr) # same plot
fr.plot(kind='nyquist')
ct.nyquist_plot(sys) # same plot
ct.nyquist_plot(fr) # same plot
Doing things this way will make everything backward compatible with the current code but also alow us to be consistent with the new plotting paradigm. (This is also compatible with the way that TimeResponseData works and you can look there for an example of how to implement.)
Regarding Bode vs Nyquist: there are two differences that I can think of that will need some thought:
- For Nyquist plots, we want to capture the value of the transfer function at (and near) s = 0, but for Bode plots this point is not shown, so should be omitted when plotting.
- For Nyquist plots, we add indentations around poles on the imaginary axis. These aren't really part of the frequency response, and so we would need to think through how they would be represented in the
FrequencyResponseData object (perhaps a separate attribute?).
Finally, the current code for nyquist_plot returns the number of encirclements. If we change this to return handles to the figure and axis, we should think about how/where to return the number of encirclements.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Thanks for the feedback. This looks like a good path forward. Quick question:
d, w = fr(return_complex=True) # implement via __iter__
Why not
d, w = sys.frequency_response(return_complex=True)
d, w = frequency_response(syslist, return_complex=True)
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Oh, I see that TimeResponseData uses this approach with return_x for backwards compatibility. Okay, not a bad idea.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
I agree that those variants should also work. The main thing is that the __iter__ method needs to know how many (and what type) of values to return. So we can keep the default as mag, phase, w for backward compatibility, but implement other options as well (with keywords).
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
I see now that MATLAB has a frequency response data model object which can be legitimately used to store FR data simply for analysis:
>> load AnalyzerData
>> sys = frd(resp,freq);
>> sys
sys =
Frequency(rad/s) Response
---------------- --------
0.1000 2.384e-01 - 1.951e-03i
0.1027 2.540e-01 - 3.511e-03i
0.1056 2.430e-01 + 1.807e-03i
0.1085 2.573e-01 - 4.672e-03i
0.1114 2.525e-01 - 7.747e-03i
...
sys is an frd model object, which is a data container for representing frequency response data.
You can use frd models with many frequency-domain analysis commands. For example, visualize the frequency response data using bode.
|
You must be logged in to vote
6 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
They allow bode, nyquist and nichols plots on FRD sys objects. I tested them and they look okay with the example data above.
For a system with imaginary poles I'm not sure. I tried this:
>> sys = tf(1, [1 2*0.2 1]);
>> [re,im,wout] = nyquist(sys);
>> fr_data = squeeze(re) + squeeze(im)*i;
>> fr_sys = frd(fr_data, wout);
>> subplot(1, 2, 1)
>> nyquist(sys)
>> subplot(1, 2, 2)
>> nyquist(fr_sys)
and the two plots are identical. But when I generated some random FRD data from this system and tried to make the nyquist plot it had to think for a very long time and the resulting plot looked odd. Is this what you meant?
>> fr_data2 = squeeze(re) + 0.01*randn(size(re)) + (squeeze(im) + 0.01*randn(size(im)))*i;
>> fr_sys2 = frd(fr_data2, wout);
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Oh wait, you said pure imaginary. Sorry.
I tested this system:
>> sys = tf(1, conv([-0.5i 1],[0.5i 1]));
>> figure(1)
>> subplot(1,2,1)
>> nyquist(sys)
>> [re,im,wout] = nyquist(sys);
>> fr_data = squeeze(re) + squeeze(im)*i;
>> fr_sys = frd(fr_data, wout);
>> subplot(1,2,2)
>> nyquist(fr_sys)
and get the following:

|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Ah, note the scaling on the axes above (10^-8, 10^7). When you re-scale the MATLAB nyquist plot it looks like this:

Here is Python control Nyquist plot with same axis scaling:

What is going on here?
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
That's the infinite circle feature (#534), see also #671
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Ah right. Neat. To represent "to infinity and back" is it?
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
I just noticed this comment in bode_plot in freqplot.py:
|
# Get the dimensions of the current axis, which we will divide up |
|
# TODO: Not current implemented; just use subplot for now |
This hadn't occurred to me actually—that you could take an existing subplot axis and divide it into two to make the mag and phase plots. As discussed, my plan was to return a complete new figure and axes:
fig, axes = bode_plot(sys)
Are there scenarios where we would want to embed a complete (mag + phase) Bode plot inside an existing figure?
I think it might be simpler to provide the ability to make individual bode_mag and bode_phase plots (returning the ax only) so the user can construct whatever they want with those.
|
You must be logged in to vote
1 reply
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
How about this solution:
bode_mag_plot(syslist) # quick plot
bode_mag_plot(syslist, ax=ax) # adding to existing figure
ax = bode_mag_plot(syslist) # returning axis handle
and same for bode_phase_plot()
And then in the new paradigm:
fr = frequency_response(syslist)
# quick plot
fr.plot(kind='bode')
fr.plot(kind='bode_mag')
fr.plot(kind='bode_phase')
# or
fig, axes = fr.plot(kind='bode')
fr.plot(kind='bode_mag', ax=ax)
ax = fr.plot(kind='bode_phase')
... etc
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Can anyone think of a good name for this function to provide the calculations we are proposing to remove from nyquist_plot? Does this even make sense? Do we need the contours and counts other than in the context of having made a plot? What are they used for?
counts, contours = ________(syslist, omega=None, omega_limits=None, omega_num=None,
warn_nyquist=True, *args, **kwargs)
Docstring:
"""Nyquist countour and number of encirclements for one or more systems.
Calculates the Nyquist contour over a (optional) frequency range and the
number of encirclements of the critical point (-1, 0). The curve is computed by
evaluating the Nyqist segment along the positive imaginary axis, with a
mirror image generated to reflect the negative imaginary axis. Poles on
or near the imaginary axis are avoided using a small indentation. The
portion of the Nyquist contour at infinity is not explicitly computed
(since it maps to a constant value for any system with a proper transfer
function).
Parameters
----------
syslist : list of LTI
List of linear input/output systems (single system is OK). Nyquist
curves for each system are plotted on the same graph.
omega : array_like
Set of frequencies to be evaluated, in rad/sec.
omega_limits : array_like of two values
Limits to the range of frequencies. Ignored if omega is provided, and
auto-generated if omitted.
omega_num : int
Number of frequency samples to plot. Defaults to
config.defaults['freqplot.number_of_samples'].
indent_radius : float
Amount to indent the Nyquist contour around poles that are at or near
the imaginary axis.
indent_direction : str
For poles on the imaginary axis, set the direction of indentation to
be 'right' (default), 'left', or 'none'.
warn_nyquist : bool, optional
If set to 'False', turn off warnings about frequencies above Nyquist.
Returns
-------
count : int (or list of int if len(syslist) > 1)
Number of encirclements of the point -1 by the Nyquist curve. If
multiple systems are given, an array of counts is returned.
contour : ndarray (or list of ndarray if len(syslist) > 1)), optional
The contour used to create the primary Nyquist curve segment. To
obtain the Nyquist curve values, evaluate system(s) along contour.
"""
I'm not entirely sure we will need to keep this function when the new plotting paradigm is complete. Then we only need to return counts and contours as attributes (properties) of the FrequencyResponseData object. Still, we might need better names for them. "counts" is a bit vague when out of context, but something like number_of_encirclements is obviously way too much of a mouthful. nyquist_counts, nyquist_contours would be the simplest.
Other thoughts: n_circs
Or we could return a dictionary called nyquist_criterion,nyquist_stability_criteria or something, and put the two arguments inside this with appropriate names.
(I checked my French control course notes and there it is "le nombre de tours" which is much more concise!)
|
You must be logged in to vote
1 reply
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Some ideas:
count[, contour] = nyquist_analysis(sys, return_contour=True/False)
count[, contour] = nyquist_criterion(sys, return_contour=True/False)
contour[, count] = nyquist_curve(sys, return_count=True/False)
Also, we should keep in mind that for a MIMO system, the Nyquist criterion is different (one looks at net encirclements of the origin for $\det(I + L(s))$. This isn't currently implemented, but we should make sure that we don't get in the way of that.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Progress update on my work on this so far. I'm about 75% through step 1 right now. @murrayrm would you prefer me to submit a pull request now or at the end (it passes all the tests including new ones I added). Or I can wait until State 1 is complete:
Stage 1 - separate plotting and frequency analysis calculations into separate functions
- New Bode plot function:
fig, axes = bode_plot(syslist, ax=ax, ...) - complete
- New (possibly temporary)
frequency_response_bode(syslist, ...) function (equivalent to the previous mag, phase, omega = bode_plot(syslist, plot=False)) - complete
- New Nyquist plot function:
ax = nyquist_plot(sys, ax=ax) - complete
- New (possibly temporary)
counts, contours = nyquist_stability_criterion(syslist, ...) function - complete
- New gangof4_plot function:
fig, axes = gangof4_plot(sys, axes=axes) - complete
- New
ax = pole_zero_plot(sys, ax=ax) function - complete
- New
ax = root_locus_plot(sys, ax=ax) function
- New
ax = nichols_plot(sys, ax=ax) function
- New
ax = singular_values_plot(sys) function - complete
- New
sigma, w = singular_values(sys) function. - complete
Stage 2 - Introduce new plotting paradigm for frequency response
- Add new properties to
FrequencyResponseData object (mag, phase, nyquist_analysis, etc.)
- Modify
frequency_response function to return FrequencyResponseData object
- Add
plot methods to FrequencyResponseData object
- Possibly make a
pole_zero_plot method for LTI systems.
- Possibly change filenames and re-organize a bit e.g.
freqplot.py -> freqresp.py
|
You must be logged in to vote
0 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
@murrayrm would you prefer me to submit a pull request now or at the end
If you want comments along the way, post as a draft PR and we can take a look at the code and post thoughts. Otherwise, fine to wait until you have something working. Don't forget unit tests (coverage should not go down) and doc/ updates (adding functions, perhaps a new section describing the basic framework).
|
You must be logged in to vote
5 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
How do I post as a draft PR? Just give you a link to my fork? All the unit tests have been updated so it's a working branch.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Yes I need to write the documentation. And I am working on a Jupyter notebook as a tutorial to all the plotting commands.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Like a normal PR, but there will be a point in the process where you can mark it as draft. Once it is there, everyone can see and comment, but can't merge. As you update your branch on GitHub, the PR gets updated.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Sounds good. I should probably merge my branch with my master first I guess, and then submit my master for the draft pull request.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
It's usually better to work in a branch and then submit the PR from the branch (in your fork). Just make sure to build (or rebase) on top of the latest (upstream) master.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Just to confirm. I'm finding code like this in some of the current plotting functions, E.g. from singular_values_plot:
fig = plt.gcf()
ax_sigma = None
# Get the current axes if they already exist
for ax in fig.axes:
if ax.get_label() == 'control-sigma':
ax_sigma = ax
# If no axes present, create them from scratch
if ax_sigma is None:
plt.clf()
ax_sigma = plt.subplot(111, label='control-sigma')
As I understand it, what this is doing is getting the current figure from pyplot and looking for an axes with the right label and plotting the output to that.
Under the changes here, there is the option to pass the axes you want to plot into as an argument and so none of this axes-labelling and searching is needed.
Or am I missing something?
(I know sisotool and gangof4 plots also use this but I think these functions will be able to use the new approach using the axes argument).
|
You must be logged in to vote
5 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
For this case (singular_values_plot) I think it can probably be removed. But for something like a Bode plot, there are two axes => you need labels (I think) in order to get the magnitude and phase axes (subplots).
More generally, think the question is what we want the behavior to be if you do something like this:
ct.bode_plot(sys1)
ct.bode_plot(sys2)
versus something like this:
ct.bode_plot(sys1)
ct.nyquist_plot(sys1)
I think the behavior in the first case should be to plot the second system on the same axes as the first. But for the second case it seems like we should get a new set of axes (probably on the same figure, clearing the old one?).
|

Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
I recommend we follow the conventions of Matplotlib and Pandas and not try to make our plot functions too 'clever'.
The convention when plotting in pyplot mode is that it will always try to add the new plot to whatever is in the current axis, regardless of what that is or if it makes sense. The only time a plot function creates a new figure is when none exists.
I checked in Pandas and it does indeed try to add anything to the current plot, even when this makes no sense:

This is in fact what happens in my current branch:

When something like this happens, the user should think "oh, I should have opened a new figure before making the second plot" which should lead them to the solution (which is more explicit):
sys = tf(1, [1, 2, 1])
ct.bode_plot(sys)
plt.figure()
ct.nyquist_plot(sys)
I get why it was being handled differently before (with a hidden labelling system) to replicate MATLAB behaviour but to me that's not a fool-proof method and it's a bit confusing for the user who may not know what determines whether a plot is added to an existing axes or when it is erased first or when a new figure is created.
The nice thing about the changes we're introducing now is that if they want, the user can take full control of where the plot goes using the new arguments and returned objects:
fig, axes = plt.subplots(2, 2)
ct.bode_plot(sys, axes=axes[:, 0])
ct.nyquist_plot(sys, ax=axes[0, 1])
ct.step_response(sys, ax=axes[1, 1])
plt.show()
But I'm happy to be out-voted on this one and re-introduce the 'smart' replacement of any existing plots/lines.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
B.t.w. I can't see any reason why you would want to clear any existing content in an axis or figure and replace it. In matplotlib, plots aren't shown until the end (unlike Matlab), so the content that was wiped would never be seen. After a plot is rendered, I don't think you can change it can you?
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Seems fine to take the matplotlib/pandas approach. But I do think that most people would expect that calling bode_plot twice in a row would do the usual thing of overlapping the plots without extra arguments. So
bode_plot(sys1)
bode_plot(sys2)
rather than
fig, ax = bode_plot(sys1)
bode_plot(sys2, ax=ax)
(though the latter should also work, of course).
Happy to be overridden and you are writing the code, so you get to choose -:).
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Yes, no problem with that. That is what it does (if no axes argument is given, it gets the current figure and adds the bode plots to the first two axes).
The only debate was what to do if someone tries to add a nyquist plot to a bode plot, or some other strange combination.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
matplotlib's recommended function signature returns a list of Artists. This is useful when one wants to change linestyles, add labels for a legend, etc. The axes can easily be recovered from the handles as property axes. The Artists can be recovered from an axes too via the get_children method, but if one wanted to set Artist properties while doing multiple calls to a plot function, one would have to figure out which are the new Artists each time.
|
You must be logged in to vote
4 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Hi @roryyorke, I'm currently working on this. If you can go through the table above which is a list of the proposed changes we're making (also compares to the latest Julia ControlSystems equivalents) and see if you think these make sense. As well as having every plot function return a fig and/or axes, we have to come up with alternative ways to calculate the data that these functions previously returned. This is perhaps the trickier part of this proposal. (Although we will always have the original MATLAB equivalents which do plotting and return data in one function).
|

Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
HI @billtubbs, thanks for working on this.
The changes on the whole make sense; splitting computation from presentation is a good idea.
I'm not sure about using custom classes to represent results; it does look tidy, and as you mentioned it could be handy if one needs metadata.
I love using pandas, but it's a fairly heavy dependency. A pandas-compatibility module, separate from plotting, could be useful, to generate DataFrames from time series and frequency response data.
FWIW, one of my favourite Matlab plotting functions, bodemag, is missing; however, we could add it later (it's hardly complicated if one has bode).
To expand on my suggestion of returning a list of Artists instead of the figure or axes, using this would look something like:
h1 = nyquist_plot(sys1)
ax = h1[0].axes
h2 = nyquist_plot(sys2, ax=ax)
h1[0].set_label('System 1')
h2[0].set(label='System 2', linewidth=5, alpha=0.5, linestyle='--')
ax.legend()
The equivalent using a fig&axes return isn't any more complicated (see below), but I don't know if there's any guarantee on the ordering of Artists in the list returned by Axes.get_children; I googled but could find no discussion of this.
plt.close('all')
fig, ax = nyquist_plot2(sys1)
nyquist_plot2(sys2, ax=ax)
h1, h2 = ax.get_children()[:2]
h1.set_label('System 1')
h2.set(label='System 2', linewidth=5, alpha=0.5, linestyle='--')
ax.legend()
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
@roryyorke I agree with your point about bode_mag. In this new paradigm we should not loose any functionality and I think we should keep the underlying functional approach. Here's one way we could achieve the bode_mag plot functionality in the new paradigm:
fr = freqresp(sys, w)
plt.figure(1)
fr.plot() # make a Bode plot (by default, kind='bode'), returns fig
plt.figure(2)
fr.plot(kind='mag') # make the mag plot, returns ax
plt.figure(3)
fr.plot(kind='phase') # make the phase plot, returns ax
However, we could retain:
plt.figure(1)
ct.bode_plot(sys) # returns (fig, axes) not (mag, phase, w) as currently
plt.figure(2)
ct.mag_plot(sys) # returns ax
plt.figure(3)
ct.phase_plot(sys) # returns ax
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
@billtubbs I have put this feature on the list for possible release in 0.10.0 (see #917). Are you still working on this/interested in working on this/have the time to work on this?
I have some ideas of how to do all of this for time series data (using the TimeResponseData class), following some of the suggestions above. I'm thinking I might take that on as a summer project. Let me know the status on your end so that we can try to find a comment set of patterns for implementing all of this.
|
You must be logged in to vote
7 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
I added some temporary files that I was using to guide the code development that might be useful to understand what I was doing:
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
B.t.w. I tried to contact someone on the Julia ControlSystems project (@murrayrm you gave me the name) but they never replied. I was hoping to collaborate and co-ordinate the changes a bit—see table above. I think this would still be worthwhile as some consistency between the projects would be good (but not essential).
I think it's only the last 6 rows of that table (in plotting_readme.md) that I didn't complete. Although there will be more work to do to integrate the stale fork now.
|

Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
I recall one of the biggest challenges I encountered but I can't remember if I overcame it or not:
- In the Pandas plotting paradigm, the user can create and prepare a figure and then individual subplots may be added by the user using a keyword (
ax=..). However, all Pandas plots use a cartesian grid system (i.e. axis projection). The challenge we have is that in some cases we need a polar grid. Changing an existing axis that the user has created from cartesian to polar is difficult. But I think @bnavigator found a solution and I can't remember if I got it working, sorry!
In matplotlib, you're supposed to specify the projection type when you create the figure. E.g.:
fig, ax = plt.subplots(subplot_kw=dict(projection='polar'))
But if you look in the notebook linked above, I have some code as follows, which suggests I did get it working (almost, look at the plots, there are still some bugs) using a position keyword argument or in some cases the desired ax keyword:
# Create figure with subplots
fig = plt.figure(figsize=(9,8))
sgrid(position=(2, 2, 1))
ax2 = fig.add_subplot(2, 2, 2)
zgrid(ax=ax2) # alternative for zgrids
#zgrid(position=(2, 2, 2))
sgrid(position=(2, 2, 3))
ax4 = fig.add_subplot(2, 2, 4)
zgrid(ax=ax4) # alternative for zgrids
plt.tight_layout()
savefig('grids_subplots')
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
This is probably where I got bogged down and should have called for help!
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
I've taken a first crack at implementing the new plotting paradigm (#645) for the time response functions. The following code
sys = ct.rss(4, 2, 2)
ct.step_response(sys).plot()
now produces:

You can still do old style plotting using this code
response = ct.step_response(sys)
t, y = response
plt.plot(t, y[0, 0], t, y[1, 0])
(note that y has two indices because a MIMO step response has multiple traces. So y[1, 0] refers to y[1] for the step response corresponding to a step in u[0]).
Or, if you prefer, you can call the plot function explicitly:
ct.time_response_plot(response)
There are lots of options for changing around the way things plot, allowing multiple calls (which plot on the same axes), and more. Some preliminary documentation is here.
I'll post this as a PR as soon as #916 gets merged into the main branch (since this set of changes builds on that one).
|
You must be logged in to vote
11 replies
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Another question: If you define a system with named input and output signals, do the names appear on the plot axes?
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Yup.
sys = ct.rss(4, inputs=['velocity', 'steering'], outputs=['xpos', 'ypos'], name='car'])
ct.step_response(sys).plot()

|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
Final thing for me, would be to pass unrecognized keyword arguments on to the plot function.
E.g.
sys = ct.rss(4, 2, 2)
ct.step_response(sys).plot(linewidth=2, linestyle='--')
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
-
Uh oh!
There was an error while loading. Please reload this page.
|
Good point. I've got that on my list to implement prior to the non-draft PR, but haven't done anything on it yet. One question is going to be how to handle multi-signal and/or multi-trace plots. So in your example above, I would assume that all lines are width 2 and dashed. But what if I want to combine outputs onto a single axes (using combine_outputs=True) and then specify different colors/width/styles for each? Perhaps
ct.step_response(sys).plot(
output_color=['k', 'b'], trace_linestyle=['-', --'], combine_signals=True, combine_traces=True)
which would produce something like this (created by updating the linestyles manually, using the returned lines):

(the trailing 'sys[65]' in the legend will go away when implemented).
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
See PR #920 for the changes that implement the time response functions above.
Moving on to frequency response functions (Bode, Nyquist) next.
|
Comment options
-
Uh oh!
There was an error while loading. Please reload this page.
|
See PR #924 for frequency response implementation.
Moving on to pole-zero maps (pzmap, rlocus).
|
You must be logged in to vote
0 replies
|