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

Regarding animation with SymPy #4

Open
Davide-sd opened this issue Nov 13, 2024 · 3 comments
Open

Regarding animation with SymPy #4

Davide-sd opened this issue Nov 13, 2024 · 3 comments

Comments

@Davide-sd
Copy link

Hello Matthias,

I read the email you sent to the sympy mailing list. I'm the person who attempted to modernize the sympy plotting module. Unfortunately, that attempt failed midway through it, leaving stuffs in undesirable conditions, thought in working condition according to the test implemented on that submodule.

I took the time to look at your notebook and what you did back then was incorrect. I guess you would like the notebook to work as before, but instead I'm going to explain what was wrong and what could be improved.

Wrong approach

p = sp.plot(0, (x, 0, 2 * sp.pi), show=False)
backend = MatplotlibBackend(p)
plt.close(backend.fig)  # Avoid showing empty plot here

def func(frame):
    backend.ax.clear()
    p[0].expr = sp.sin(x - (frame / 5))
    # If there are multiple plots, also change p[1], p[2] etc.
    backend.process_series()

ani = FuncAnimation(backend.fig, func, frames=10)
  • You created a plot object, p, which only serves the purpose of accessing the underlying data series with p[0].
  • With backend = MatplotlibBackend(p) you constructed another object (repetitive, because it was already available with p._backend if only it was ever documented).
  • Inside func:
    • With p[0].expr = sp.sin(x - (frame / 5)) you assumed it is safe to change the expression of the data series. It is a very strong assumption: all other sympy objects operates on the concept of immutability.
    • backend.process_series(): you handled the process of generating numerical data and add it to the plot to the backend object. The main problem is that you have to clear the axes at each time frame, thus rebuilding every matplotlib renderer at each time frame. Even for a few line expressions, this would quickly become a performance bottleneck.

Back in the day, I did exactly the same mistakes. Why?

  1. sympy plotting module was/is criminally undocumented.
  2. it requires fewer lines of code with respect to the correct approach.

Correct approach

This requires more lines of code, a lot of book-keeping with matplotlib handles, drowning yourself into matplotlib's documentation in order to find the correct update method for each handle (line, scatter, surface, contour, ...). But, it is the safest choice, and the one that always work.

import sympy as sp
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
import numpy as np
plt.rcParams['animation.html'] = 'jshtml'

# create a symbolic expression
sp.var("x, frame")
expr = sp.sin(x - (frame / 5))
# convert it to a numerical function
f = sp.lambdify([x, frame], expr)
# evaluate the function to get numerical data
xn = np.linspace(0, 2*np.pi)
yn = f(xn, 0)

# create the figure, axes, handles
fig, ax = plt.subplots()
# without this, two pictures would be shown on the notebook
plt.close(fig)

line, = ax.plot(xn, yn)
ax.set_xlabel("x")
ax.set_ylabel("y")

def func(frame):
    # update the handles without deleting whatever was plotted previously.
    yn = f(xn, frame)
    line.set_data(xn, yn)

ani = FuncAnimation(fig, func, frames=10)
ani

Easiest way to animate sympy expressions

If you, like me, hate the repetitive process of book-keeping matplotlib's handles in order to create animations, maybe you would enjoy a simpler api. I created an improved plotting module for symbolic expression, which supports animations. This module follows the procedure illustrated in the above section Correct approach, so performance should not be an issue.

I'm going to show a few examples taken from your notebook. The above example becomes:

%matplotlib widget
from sympy import *
from spb import *
var("x, t")
p = plot(
    sin(x - t/5), (x, 0, 2 * pi),
    params={t: (0, 10)},   # t is the time parameter (frame in your code example)
    animation=True
)

Note the %matplotlib widget command. In order to support multiple plotting libraries, spb animations are based on ipywidgets. Without this command, the animation won't show on the screen (more likely, you will get some error). Instead, if you want to show the FuncAnimation object:

from sympy import *
from spb import *
import matplotlib.pyplot as plt
plt.rcParams['animation.html'] = 'jshtml'

var("x, t")
p = plot(
    sin(x - t/5), (x, 0, 2 * pi),
    params={t: (0, 10)},   # t is the time parameter (frame in your code example)
    animation=True,
    show=False
)
plt.close(p.fig)
p.get_FuncAnimation()

Note that I had to create a hidden animation with show=False. Then, I had to close the figure with plt.close(p.fig), otherwise there would be two figures shown on the notebook cell. Unfortunately, this is standard Jupyter Notebook behavior: it listen to a particular attribute of Matplotlib, and when a new figure is created, it immediately shows it on the screen.

Another example:

p = plot(
    sin(x), prange(x, 0, 2 * pi * (1 + t/5)), # prange stands for parametric range
    params={t: (0, 10)},   # t is the time parameter (frame in your code example)
    animation={"fps": 10},
    show=False
)
plt.close(p.fig)
p.get_FuncAnimation()

Another one, reusing an existing figure/axes:

fig, ax = plt.subplots(2, 1)
ax[0].plot([4, 9, 7, 20, 6, 33, 13, 23, 16, 62, 8])
p = plot(
    sin(x), prange(x, 0, 2 * pi * (1 + t/5)), # prange stands for parametric range
    params={t: (0, 10)},
    animation={"fps": 10},
    show=False,
    ax=ax[1]
)
p.get_FuncAnimation()
@mgeier
Copy link
Owner

mgeier commented Nov 16, 2024

Thanks for reaching out, and thanks for the detailed information, that's really helpful!

I'm wondering, though, what is the advantage of your approach compared to using get_points() as I did there: https://nbviewer.org/github/mgeier/python-audio/blob/master/sympy/sympy-matplotlib-animations.ipynb#Using-get_points()?

Is it more efficient?

And doesn't it loose the ability to do adaptive sampling?

In the simple example with the sine wave this of course isn't a problem, but for more complicated plots I would definitely like to have adaptive sampling, as get_points() gives me.

I don't really like the default look of SymPy plots, so I have always used get_points() to plot SymPy data directly with Matplotlib, but I would still be interested if it would be possible to use actual SymPy plots in Matplotlib animations, as I did in my first few examples.
Or is this simply not possible anymore?

Note that I had to create a hidden animation with show=False. Then, I had to close the figure with plt.close(p.fig), otherwise there would be two figures shown on the notebook cell. Unfortunately, this is standard Jupyter Notebook behavior

Yeah, I've also used this in my examples.

Recently, however, I have stumbled upon a situation where show=False didn't lead to the expected result: sympy/sympy#27242 (comment)

Do you happen to know how to change that code for it to work in a Jupyter notebook?

@Davide-sd
Copy link
Author

I'm wondering, though, what is the advantage of your approach compared to using get_points() as ...

Back in the day, sympy.plotting used adaptive algorithm by default, very often producing low quality representation of functions. That behavior was changed: now it uses uniform sampling (the x-axis gets discretized with 1000 points, then function is evaluated), which produces consistent results between runs and of much better quality. You might need to use ylim to limit the visualization if your function has poles.

If you still want to use adaptive sampling, you'd have to specify plot(some_function, adaptive=True).

Right now, using lambdify or p = plot(some_function); p[0].get_points() is pretty much equivalent.

Recently, however, I have stumbled upon a situation where show=False didn't lead to the expected result:

I'll get back to you if I'll find the time to look at it.

@mgeier
Copy link
Owner

mgeier commented Nov 21, 2024

That behavior was changed: now it uses uniform sampling (the x-axis gets discretized with 1000 points, then function is evaluated), which produces consistent results between runs and of much better quality.

Thanks for this information, I wasn't aware of that!
And the SymPy docs weren't either, apparently: sympy/sympy#27297

I will update my example notebook with that information.
For completeness's sake, I would still be interested whether/how it is possible to use "native" SymPy plots in Matplotlib animations. Do you know how to do that? Or is that not possible anymore?

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

No branches or pull requests

2 participants