Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Use matplotlibs streamplot function for phase_plane_plot #1112

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

Merged
merged 15 commits into from
Feb 16, 2025

Conversation

lkies
Copy link
Contributor

@lkies lkies commented Feb 1, 2025

WORK IN PROGRESS

As the title says, phase_plane_plot currently creates streamlines by integrating a set of initial conditions forwards or backwards in time, while this allows for quite some flexibility it takes more work to get good looking results. Matplotlibs streamplot is basically made for things like this an produces good looking streamline plots from vector field data, it also makes it very easy to visualize the flows magnitude using varying line thickness or color.

I added a subfunction phaseplot.streamplot and the corresponding argument plot_streamplot to phase_plane_plot.
There are two options vary_linewidth and vary_color which will vary the linewidth or color depending on the magnitude, they both default to false. You can also pass a colormap or matplotlib norm to make the magnitude to the colors and thicknesses

Some things of note that I am not quite happy with yet:

  • Most of phase_plane_plot is built around the points to evaluate being specified in list, matplotlibs streamplot requires a rectangular grid however so the original grid needs to be recovered from the list of points which is a bit hacky.
  • per documentation phase_plane_plot returns the artist from the individual subfunctions into an array of size 3, adding a fourth element to return the streamplot is technically a breaking change because the array could be used in a destructuring bind. Because of this I didn't add any means of returning the artist yet.
  • by default the zorder of the separatrices is below the streamplot which looks a bit bad, but i didnt want to hardcode zorders and getting the zorder from one to set the other might be a bit overkill.

Whats your opinion on these problems and what i need to do to get this merged?

@lkies lkies marked this pull request as draft February 1, 2025 20:31
@coveralls
Copy link

coveralls commented Feb 1, 2025

Coverage Status

coverage: 94.744% (-0.008%) from 94.752%
when pulling fc1854e on lkies:streamplot
into f73e893 on python-control:main.

@murrayrm
Copy link
Member

murrayrm commented Feb 1, 2025

This sounds like a great addition. Can you post a few screenshots that show what the new plots look like (since they will be a bit different than the matplotlib examples?

A few thoughts:

  • Presumably we don't want to turn on streamlines and streamplots at the same time. Perhaps set things up so that if plot_streamlines is not True, then setting plot_streamplots will turn plot_streamlines to False?
  • I don't think it will break cost to have cplt.lines be an array with an extra element. But you could also just put the data into whatever element of the array plot_streamlines uses, since they are sort of the same type of data?
  • There are some new documentation guidelines coming in PR Updated documentation #1094, and it would be good to adhere to those, so that we stay consistent. The main one that I noticed is that you should put objects with documentation (like NonlinearIOSystem) in single backticks, so that they generate a link. You can find the upcoming developer notes here.

@lkies
Copy link
Contributor Author

lkies commented Feb 2, 2025

Regarding the documentation, just to be clear because it will look inconsistent for now: I only change the docs for what i added so it merges properly with the other changes in #1094 ?

Another thing I was not sure about is putting the new parameter to the end of phase_plane_plot because that could potentially be breaking for cases where the arguments are passed as positional arguments instead of as keywords (which is probably pretty unlikely in this case). Maybe it would be good to make more use of * to make a lot of things like these keyword only?

I am not quite sure I understand you correctly but the way you described the exclusion with streamlines does not seem possible since plot_streamlines is true by default. But I also contemplated something similar since it is a bit akward to use this way.
My suggestion would be to default plot_streamlines, plot plot_vectorfiled and plot_streamlines to None.
If all Nones are received the function defaults to plot_streamlines=True in any other case the parameters are evaluated as is. With this, if you pass plot_streamlines=True, plot_streamlines is automatically disabled but it is still possible to plot them both at the same time in case you would want that (maybe to hightligh specific streamlines or something?).

I would also take the opportunity to increase the grid resolution if plot_streamlines and plot_vectorfield are disabled, while streamplot turns out surprisingly good even with a low resolution it still looks better and is computationally acceptable IMO.

Another problem I realized is that the norm kind of assumes that the plot will use an equal aspect ratio. So if the derivatives are differently scaled (and the plot has a strongly "non equal" aspect ratio) the larger state will dominate, although handling this might be too much for a convenience function like this.

Here are two examples:

import control as ct
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['figure.figsize'] = [6, 6]

def dydt(t, y):
    return np.array([y[1], -np.sin(y[0])])

ct.phase_plane_plot(dydt, plot_streamlines=False, plot_streamplot=dict(vary_linewidth=True),
                    pointdata=[-4, 4, -4, 4], gridspec=(20, 20))
plt.gca().set_aspect('equal')

image

The linewidths vary from 0.25 to 2 times the current default linewidth, arguably a matter of opinion but I don't think this should be exposed as arguments.

ct.phase_plane_plot(dydt, plot_streamlines=False, plot_streamplot=dict(vary_color=True),
                    pointdata=[-4, 4, -4, 4])
plt.gca().set_aspect('equal')

image

Here you can see the unfortunate z-ordering with the seperatrices that I talked about. While this looks surprisingly good for only 9 by 6 points, I still think the default resolution should be higher.

But as you can see, it looks pretty good without having to think much about how to distribute the initial conditions.

@murrayrm
Copy link
Member

murrayrm commented Feb 2, 2025

Regarding the documentation, just to be clear because it will look inconsistent for now: I only change the docs for what i added so it merges properly with the other changes in #1094 ?

Sounds good.

Another thing I was not sure about is putting the new parameter to the end of phase_plane_plot because that could potentially be breaking for cases where the arguments are passed as positional arguments instead of as keywords (which is probably pretty unlikely in this case). Maybe it would be good to make more use of * to make a lot of things like these keyword only?

We'll probably wait until v0.11 to starting using * to enforce keyword only arguments, since this could break lots of code. For now, OK to assume that plot_streamlines and plot_streamplot will only be used as keyword arguments.

I am not quite sure I understand you correctly but the way you described the exclusion with streamlines does not seem possible since plot_streamlines is true by default. But I also contemplated something similar since it is a bit akward to use this way. My suggestion would be to default plot_streamlines, plot plot_vectorfiled and plot_streamlines to None. If all Nones are received the function defaults to plot_streamlines=True in any other case the parameters are evaluated as is. With this, if you pass plot_streamlines=True, plot_streamlines is automatically disabled but it is still possible to plot them both at the same time in case you would want that (maybe to hightligh specific streamlines or something?).

This lines up with what I was thinking, though it might also make sense to have streamplot be the default (I think it looks quite good!).

I would also take the opportunity to increase the grid resolution if plot_streamlines and plot_vectorfield are disabled, while streamplot turns out surprisingly good even with a low resolution it still looks better and is computationally acceptable IMO.

Seems fine. Computation is not a major issue here (and if someone has some complicated vector field that takes a long time to compute, they can always change the grid resolution.

Another problem I realized is that the norm kind of assumes that the plot will use an equal aspect ratio. So if the derivatives are differently scaled (and the plot has a strongly "non equal" aspect ratio) the larger state will dominate, although handling this might be too much for a convenience function like this.

Probably OK for now. If needed, we can add some functionality to change the aspect ratio in phase_plane_plot.

@murrayrm
Copy link
Member

murrayrm commented Feb 4, 2025

@lkies The big documentation PR has been merged, so you'll need to rebase this on top of the latest main.

@lkies
Copy link
Contributor Author

lkies commented Feb 4, 2025

I implemented the thing with the Nones but I did not set plot_streamplot as the default yet, even though I also (unsurprisingly) like it better. I didn't do this for two reasons:

  • I didn't want to update all of the examples (I might look into that later this week)
  • It will make control/tests/ctrlplot_test.py::test_plot_linestyle_processing[None-phase_plane_plot] fail for (in my opinion) pretty stupid reasons: The test checks the color of the first line in the flattened ControlPlot structure, but the first one it fines is a line from plot_separatrices which does not respect the default color so it fails. The reasons it did not fail before, was because the streamlines appear earlier in the structure. Even if I could work around this order issue, streamplot returns a Streamplot object and not lines. Except for putting in a dummy line or disabling the test, I do not really see a way to get the test to pass.

I also get 6 other failed tests, but they also fail on main for me so it should be unrelated.

FAILED doc/test_sphinxdocs.py::test_sphinx_functions[control] - Failed: control.ControlPlot not referenced in sphinx docs
FAILED doc/test_sphinxdocs.py::test_sphinx_functions[control.flatsys] - Failed: control.flatsys.BSplineFamily not referenced in sphinx docs
FAILED doc/test_sphinxdocs.py::test_sphinx_functions[control.optimal] - Failed: control.optimal.OptimalControlProblem not referenced in sphinx docs
FAILED doc/test_sphinxdocs.py::test_sphinx_functions[control.phaseplot] - Failed: control.phaseplot.NonlinearIOSystem not referenced in sphinx docs
FAILED doc/test_sphinxdocs.py::test_config_defaults - FileNotFoundError: [Errno 2] No such file or directory: 'config.rst'
FAILED doc/test_sphinxdocs.py::test_sphinx_matlab - Failed: control.matlab.InputOutputSystem not referenced in sphinx docs

So depending on if you actually want to have the default changed, which is technically a breaking change, I would consider this done, so I unmarked it as draft.

@lkies lkies marked this pull request as ready for review February 4, 2025 20:50
@murrayrm
Copy link
Member

murrayrm commented Feb 4, 2025

In terms of updating the documentation: no matter what we need to update the user guide to say something about streamplot, otherwise the only people who will know it exists are people who read the docstrings. So a paragraph needs to be added to doc/phaseplot.rst no matter what. The plots in that section are generated by the code in that section, so "updating" the plots is just a matter of putting in the right testcode (see other examples in that file).

If you want to leave the default as streamlines, then just a simple paragraph with a simple example (along the lines of the ones above) is all that is needed.

In terms of the failing tests: what you are showing above is different that what the CI tests are showing. They show the only problem is that you haven't documented streamplot in the Reference Manual. That is easy to fix: just go to the phaseplot section of functions.rst and add phaseplot.streamplot.

I'll do a more careful review of the code in the coming days.

@lkies
Copy link
Contributor Author

lkies commented Feb 4, 2025

Ok, I will do that. And thank you for the tip regarding the failing CI test, at first glance I was unsure, whether that file was autogenerated by some process.

Do you have a suggestion regarding the test that will fail when changing the default?

@murrayrm
Copy link
Member

murrayrm commented Feb 5, 2025

For the unit tests: all we really need is something that executes all of the lines of your code (for coverage) and that will generate an error if the lines don't plot. So perhaps choose some simple set of settings that are easy to check? (And of course it should not work if somehow plot_streamlines were used instead of plot_streamplot.)

@murrayrm
Copy link
Member

murrayrm commented Feb 5, 2025

One more thought: if we make streamplot the default, how hard would it be to add the ability to set the depth of other objects (eg, separatrices) so that they appear on top of the stream plot? We could make that a common keyword argument for the other plotting functions in phaseplot and then set the defaults so that the plots look right.

@lkies
Copy link
Contributor Author

lkies commented Feb 5, 2025

Ensuring that the separatrices and stationary points are always on top is not too bad, I just tried it and will push it later or tomorrow. I just get the maximum zorder of streamlines, vectorfield or streamplot and then add something to it for seperatrices and equilpoints unless their zorder is already specified. And then the same for equilpoins over separatrices. Although it only requires exposing the zorder for separatrices and equilpoints I would expose it for all 5 functions for consistency.

I am not sure if you just wanted to remind me to write tests, but the thing I said about the tests, was that if I set streamplot as the default it would fail the mentioned test (control/tests/ctrlplot_test.py::test_plot_linestyle_processing[None-phase_plane_plot])
So I will need to exclude streamplot from one of the parametrized tests and write a specialized one that checks for the correct color in the right place. Or do you have a better idea?

@murrayrm
Copy link
Member

murrayrm commented Feb 5, 2025

For unit tests, I would keep all current tests, just restructure so that they test with the relevant options. Then add a new test for streamplot and use that test for the default test. I'm not sure how much work that is. The main thing is that we need to make sure that all lines of code are covered, since otherwise if future changes break the plots, we don't find out until someone reports it.

@lkies
Copy link
Contributor Author

lkies commented Feb 7, 2025

I changed the default plot type to streamplot, implemented the automatic zordering and wrote some tests.
As you can see, the examples currently fail because I have not yet updated them, so they still assume that plot_streamlines is the default.
I will work on that sometime in the next few days.

I also noticed that separatrices seems to also plot the equilibrium points, which I found a bit odd since that should be the job of equilpoints.

@murrayrm
Copy link
Member

murrayrm commented Feb 8, 2025

I agree that phaseplot.separatrices should not be plotting equilibrium points. Feel free to fix that~

As a fix for the examples, I suggest putting the following code at the top of phase_plane_plots:

    # Check for legacy usage of plot_streamlines
    streamline_keywords = [
        'arrows', 'arrow_size', 'arrow_style', 'color', 'dir', 'params']    
    if plot_streamlines is None:
        if any([kw in kwargs for kw in streamline_keywords]):
            warnings.warn(
                "detected streamline keywords; use plot_streamlines to set",
                FutureWarning)
            plot_streamlines = True
        if gridtype not in [None, 'meshgrid']:
            warnings.warn(
                "streamplots only support gridtype='meshgrid'; "
                "falling back to streamlines")
            plot_streamlines = True

Also, something goes wrong in this example that I don't yet understand:

def invpend_update(t, x, u, params):
    m, l, b, g = params['m'], params['l'], params['b'], params['g']
    return [x[1], -b/m * x[1] + (g * l / m) * np.sin(x[0])]
invpend = ct.nlsys(
    invpend_update, states=2, inputs=0,
    params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1})

ct.phase_plane_plot(
    invpend, [-2*np.pi, 2*np.pi, -2, 2], 4, gridspec=[6, 6],
    plot_separatrices={'timedata': 20, 'arrows': 4})

If you look at the plot that is generated, the streamlines cross the separatrices (?):
streamplot-example

@lkies
Copy link
Contributor Author

lkies commented Feb 8, 2025

I am pretty sure the weird behavior is just a resolution problem, it becomes more obvious when plotting the vectorfield:

def invpend_update(t, x, u, params):
    m, l, b, g = params['m'], params['l'], params['b'], params['g']
    return [x[1], -b/m * x[1] + (g * l / m) * np.sin(x[0])]
invpend = ct.nlsys(
    invpend_update, states=2, inputs=0,
    params={'m': 1, 'l': 1, 'b': 0.2, 'g': 1})

ct.phase_plane_plot(
    invpend, [-2*np.pi, 2*np.pi, -2, 2], 4, gridspec=[6, 6],
    plot_separatrices={'timedata': 20, 'arrows': 4},
    plot_vectorfield=True)

image
The vectorfield that you see here is basically the only information that plt.streamplot gets and needs to construct the streamlines from. And as you can see, it really is not a lot of data, if you increase the resolution the lines will match up again:

ct.phase_plane_plot(
    invpend, [-2*np.pi, 2*np.pi, -2, 2], 4, gridspec=[30, 20],
    plot_separatrices={'timedata': 20, 'arrows': 4})

image

I guess this might be somewhat of a "sharp" edge, that even with very low resolution data plt.streamplot will always produce "good" looking plots but due to the low resolution they might be inaccuare.

The somewhat jagged seperatrices are also a resolution problem, 20 units is quite a long time to spread the 50 points for which it will calculate the streamline for.

@lkies
Copy link
Contributor Author

lkies commented Feb 9, 2025

I added the legacy warning / fallback and updated the guide with plot_streamplot as the new default and plot_streamlines for advanced usage. I did not include color and params in the list of keyword arguments as they are also used and supported by the other functions. I also explicitly added plot_streamlines=True for all calls in examples/phase_plane_plots.py.

Is there anything still missing?

@murrayrm
Copy link
Member

@lkies I created a PR into your repository with a few modifications so that the code and documentation style is consistent with usage throughout the rest of the package. I also updated some of the example scripts to use the new functionality in the appropriate places.

If the PR in your repository looks OK, go head and merge it. Once it shows up here, I'll merge this PR into the main branch.

Great work overall, and I'm looking forward to having this be the default functionality!

lkies and others added 2 commits February 16, 2025 12:06
Update streamplot codestyle, documentation, and figures
@lkies
Copy link
Contributor Author

lkies commented Feb 16, 2025

@murrayrm No problem, happy to have contributed!
Thank you for your fixes, there was one slight error in the documentation, though that seems to have been my fault originally.

@murrayrm murrayrm merged commit 2a919cc into python-control:main Feb 16, 2025
24 checks passed
@murrayrm murrayrm added this to the 0.10.2 milestone Feb 19, 2025
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.

3 participants