As I mentioned in closing the last post, I am going to try to add the rotating wheels that are creating the spirograph curve to the animation.

The Numbers We Need

Let’s start by looking for the numbers I am going to need to create the rotating circles. I will draw a radial line in the circles that will make the rotation of each more visible. Here’s the parametric equation we are using for the three circles:

$$f(t) = x(t) + i*y(t) = e^{it} + \frac{1}{2}\ e^{7it} + \frac{i}{3}\ e^{-17it}$$

Each of the elements of the sum represent one of the three circles. Each element specifies a moving/rotating point on the circumference of its circle. The first rotates about the plot center. Each subsequent circle is centered on that moving point on the circumference of the preceding circle. Each circle has its own rotational frequency and radius.

The component for each circle takes the form \({radius} * e^{freqit}\).

The first circle, \(p_1(t) = e^{it}\), has a radius of 1 and frequency of 1. So as far as our animation goes, it will make one complete rotation during the animation. And of course remain centered on (0, 0). At the start, \(t=0\), it is equal to \(e^{0} + 0j\), which translates to (1, 0) in the 2-dimensional space of our plot. The value of this component is also the center for the second circle. The line representing the moving radius of the circle will be given by [0, 0] - [real(p1(t)), imag(p1(t))].

The second circle, \(p_2(t) = \frac{1}{2}\ e^{7it}\), has a radius of \(\frac{1}{2}\), a frequency of \(7\) and is centered on the point defined by the first compoment. I.E. (real(p1(t)), imag(p1(t))). It’s value represents a moving point on the circumference of that circle. That point, on our plot, is given by \(p1(t) + p2(t)\). The coordinates for the radial line are [real(p1(t)), imag(p1(t))] - [real(p2(t)), imag(p2(t))].

And, finallly, the third circle, \(p_3(t) = \frac{i}{3}\ e^{-17it}\), has a radius of \(\frac{i}{3}\), a frequency of \(17\) and is centered on the point defined by the first two compoments. I.E. (real(p1(t)+p2(t)), imag(p1(t)+p2(t))). In our case, the radius is also being multipled by imaginary \(i\) and the frequency is multiplied by \(-1\). This has the circle rotating in the opposite direction (clockwise?) and out of phase. The coordinates for the radial line are [real(p2(t)), imag(p2(t))] - [real(p3(t)), imag(p3(t))].

And, of course, the point on the curve is being drawn at (real(p1(t)+p2(t)+p3(t)), imag(p1(t)+p2(t)+p3(t))).

Blit=

But before we get to coding the updated animation, there is one thing I thought I should have a look at.

So, I added , blit=true to the call to FuncAnimation().

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_1.py
Traceback (most recent call last):
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\cbook\__init__.py", line 224, in process
    func(*args, **kwargs)
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\animation.py", line 975, in _start
    self._init_draw()
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\animation.py", line 1719, in _init_draw
    self._draw_frame(next(self.new_frame_seq()))
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\animation.py", line 1747, in _draw_frame
    self._drawn_artists = sorted(self._drawn_artists,
TypeError: 'Line2D' object is not iterable
Traceback (most recent call last):
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\cbook\__init__.py", line 224, in process
    func(*args, **kwargs)
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\animation.py", line 1275, in _on_resize
    self._init_draw()
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\animation.py", line 1719, in _init_draw
    self._draw_frame(next(self.new_frame_seq()))
  File "E:\appDev\Miniconda3\envs\ds-3.9\lib\site-packages\matplotlib\animation.py", line 1747, in _draw_frame
    self._drawn_artists = sorted(self._drawn_artists,
TypeError: 'Line2D' object is not iterable

Needless to say, didn’t mean a lot to me. But, had to have something to do with the attempt to blit. Turns out that with blitting FuncAnimation() behaves differently. As written, update() returns an object, the animation function wants an iterable. So, I had to modify the return statement to be return ln_sp,. Note the trailing comma. I.E, return a tuple with one element, ln_sp. That worked just fine.

Adding the Circles

Parametric Function

As a first step, let’s modify the parametric function to return the 3 numbers we need to plot our circles. For now, I am leaving the radii and frequencies hard coded to match that initial parametric function in the article. That is, Wheels on Wheels on Wheels-Surprising Symmetry.

def f(t):
  r1 = np.exp(1j*t)
  r2 = r1 +  np.exp(7j*t)/2
  r3 = r2 +  1j*np.exp(-17j*t)/3
  return r1, r2, r3

And a quick test.

curve generated after modifying update function to return one value for each circle

And, not what I expected. Do you know what I did wrong? I failed to modify the update function to only use the last of the values returned from the parametric function, f(t). But a somewhat interesting result. Might be something to play with in future. Well not might, definitely something to play with in future. I modified the update function, and things worked as expected.

def update(i):
  r1, r2, r3 = f(i)
  x_sp.append(np.real(r3))  
  y_sp.append(np.imag(r3))
  ln_sp.set_data(x_sp, y_sp)
  return ln_sp,

Let’s Add One Circle with a Radial Line

Start with the radial line. We need to add another variable for the radial line. And, I am adding a variable to define the transparency value, alpha=, to use when drawing the circles and radial lines. Don’t want them to dominate the animation, but want them to be visible.

Near the top of the module I added:

# Misc vars
# value of alpha to use for circles and radial lines
c_alpha = 0.3

And, after the initialization code for the t array, I added:

# add a line for the radial portions
ln_rs, = plt.plot([], [], color='k', alpha=c_alpha)

Then to test things out I modified the update() function as follows:

def update(i):
  r1, r2, r3 = f(i)
  x_sp.append(np.real(r3))  
  y_sp.append(np.imag(r3))
  ln_sp.set_data(x_sp, y_sp)
  ln_rs.set_data([0, np.real(r1)], [0, np.imag(r1)])
  return ln_sp, ln_rs

That seemed to work just fine. But we will likely need to do something nicer with the code for ln_rs as we add more radial lines.

Now circles are a bit of a different story. Circles aren’t plotted the same way as lines. We need to use patches: “Patches are Artists with a face color and an edge color”. More specifically, we will use patches.Circle.

Since, as we progress, we will be creating multiple circles I am going to put the circle creation code in a function, create_circles(). We will also have to take into account the fact that the radii may be imaginary. I also don’t want to mess, for now, with passing arguments to be used by the update() function to funcAnimation(). So, I am going to add new variables, one for the circle radii and one for their frequencies. For now, I am going to add these in that # Misc vars section of the module.

# Misc vars
# value of alpha to use for circles and radial lines
c_alpha = 0.3
sp_rds = [1, 1/2, 1j/3]
sp_frq = [1, 7, -17]

And, the circle creation function looks like this for one circle. Since the radii will be either real or imaginary only, not complex, I used max() to get the number I wanted. Might be a better way, but this seems to work just fine for this situation.

def create_circles():
  rs = f(0)
  # convert radii to real numbers
  r_rad = [max(np.real(rd), np.imag(rd)) for rd in sp_rds]
  circles = []
  circles.append(plt.Circle((0, 0), r_rad[0], fill=False, alpha=c_alpha))
  return circles

Now, modify update() to draw the circle and test it out. First I need to create a globally accessible variable for the circle. Recall this will be an array. We will use that array to add each circle to the plot after updating their location. But, as the first circle doesn’t move, nothing to update. In order to display the circle on the plot, the circle patch must be added to axes object.

def update(i):
  r1, r2, r3 = f(i)
  x_sp.append(np.real(r3))  
  y_sp.append(np.imag(r3))
  ln_sp.set_data(x_sp, y_sp)
  ln_rs.set_data([0, np.real(r1)], [0, np.imag(r1)])
  circs = create_circles()
  for cir in circs:
    ax.add_patch(cir)
  return ln_sp, ln_rs, *circs


# create first set of circles
circs = create_circles()

Not including that animation in the post, but it worked as planned. Lovely! Now, let’s get all the circles and radial lines going. Let’s update create_circles first.

Plot All the Circles and Radial Lines

Okay, let’s modify create_circles() to create all three circles with their initial locations and radii.

def create_circles():
  rs = f(0)
  # convert radii to real numbers
  r_rad = [max(np.real(rd), np.imag(rd)) for rd in sp_rds]
  circles = []
  circles.append(plt.Circle((0, 0), r_rad[0], fill=False, alpha=c_alpha))
  for i in range(1, nbr_c):
    circles.append(plt.Circle((np.real(rs[i-1]), np.imag(rs[i-1])), r_rad[i], fill=False, alpha=c_alpha))
  return circles

Now add the extra lines/circles to the animation update function. But, I am going to change the call to f(t). Want the whole iterable rather than the individual point values. Makes the rest of the code tidier. That allow us to take advantage of Numpy’s array operations to get the values for the plot of the radial lines. And, notice the use of a negative list index to get the last item of the iterable returned by f(t).

We also have to move all but the first circle to their new locations. We do so by setting each circles center property to the new location.

def update(i):
  r_pts = f(i)
  x_sp.append(np.real(r_pts[-1]))  
  y_sp.append(np.imag(r_pts[-1]))
  ln_sp.set_data(x_sp, y_sp)
  rs_x = [0] + np.real(r_pts)
  rs_y = [0] + np.imag(r_pts)
  ln_rs.set_data(rs_x, rs_y)
  circs = create_circles()
  for i in range(1, nbr_c):
    circs[i].center = np.real(r_pts[i-1]), np.imag(r_pts[i-1])
  for cir in circs:
    ax.add_patch(cir)
  return ln_sp, ln_rs, *circs

And, bingo. That worked (after fixing a few typos). Again no animated gif. The circles and radial lines remain on the plot when all is said and done. I’d rather they didn’t. And, I thought it might be fun to use different colours for the various circles. So, let’s give that a go.

A new variable # Misc vars section to define some colours.

# Misc vars
# value of alpha to use for circles and radial lines
c_alpha = 0.3
sp_rds = [1, 1/2, 1j/3]
sp_frq = [1, 7, -17]
# number of circles initialized one time, rather than in multiple functions
nbr_c = len(sp_rds)
# a few colur to use for drawing circles and radial lines
clrs = ['b', 'C1', 'g', 'r', 'c']

A minor change to create_circles() to specify a colour for each circle.

def create_circles():
  rs = f(0)
  # convert radii to real numbers
  r_rad = [max(np.real(rd), np.imag(rd)) for rd in sp_rds]
  circles = []
  circles.append(plt.Circle((0, 0), r_rad[0], color=clrs[0], fill=False, alpha=c_alpha))
  for i in range(1, nbr_c):
    circles.append(plt.Circle((np.real(rs[i-1]), np.imag(rs[i-1])), r_rad[i], color=clrs[i], fill=False, alpha=c_alpha))
  return circles

A quick test shows the above works as planned. Now to get rid of the unwanted circles and lines at the end of the animation. Took me a moment or two, but decided to use and if/else block that checks whether or not the value for i has reached np.pi * 2. If it has don’t plot the circles or radial lines. Well sort of anyway.

def update(i):
  r_pts = f(i)
  x_sp.append(np.real(r_pts[-1]))  
  y_sp.append(np.imag(r_pts[-1]))
  ln_sp.set_data(x_sp, y_sp)
  if i == 2*np.pi:
    ln_rs.set_data([], [])
    circs = [plt.Circle((0, 0), .1, fill=False, alpha=0) for i in range(nbr_c)]
  else:
    rs_x = [0] + np.real(r_pts)
    rs_y = [0] + np.imag(r_pts)
    ln_rs.set_data(rs_x, rs_y)
    circs = create_circles()
    for i in range(1, nbr_c):
      circs[i].center = np.real(r_pts[i-1]), np.imag(r_pts[i-1])
  for cir in circs:
    ax.add_patch(cir)
  return ln_sp, ln_rs, *circs

That seemed to work. So, let’s save to .gif for inclusion in this post. Well problems. .gif was taking forever to generate. So, I modified the t array to only have 100 frames. And, though still large, the gif was generated in a reasonable amount of time. When viewing the matplotlib generated animation, the circles displayed as desired, because I was only seeing one frame at a time. But, when saving the animation, each frame is added on top of the previous ones. So, all the circles and radial lines, in all their locations, were being saved to the gif. (You can download an mp4 version for a look if you wish.)

So, if I wish to save the animations to files, I will need to remove the previous frame’s circles from the plot. Since a patch is an artist, we can use matplotlib.artist.Artist.remove to get the job done. I will iterate over all the patches in the current plot (Axes) and remove them. For fun, this will be done in a list comprehension rather than a loop. Same thing right?

And, I did remember to reset the number of frames back to 500.

def update(i):
  # remove all the circles from the previous frame
  [p.remove() for p in reversed(ax.patches)]
  r_pts = f(i)
  x_sp.append(np.real(r_pts[-1]))  
  y_sp.append(np.imag(r_pts[-1]))
  ln_sp.set_data(x_sp, y_sp)
  if i == 2*np.pi:
    ln_rs.set_data([], [])
    circs = [plt.Circle((0, 0), .1, fill=False, alpha=0) for i in range(nbr_c)]
  else:
    rs_x = [0] + np.real(r_pts)
    rs_y = [0] + np.imag(r_pts)
    ln_rs.set_data(rs_x, rs_y)
    circs = create_circles()
    for i in range(1, nbr_c):
      circs[i].center = np.real(r_pts[i-1]), np.imag(r_pts[i-1])
  for cir in circs:
    ax.add_patch(cir)
  return ln_sp, ln_rs, *circs

The gif is a bit large at ~7.25 MB. So, I am not including it in the post, but feel free to download and have a look.

Done!

That’s it for another one. Hope you are having as much fun as I am.

For the next one, I am going to try to modify all the code developed in this post to work with from 3 to 8 wheels. Maybe figure out a way to pass the radii and frequences to the update function via the animation function. Would likely make things a bit tidier. And, the animation function allows for the passing in of an intitialization function. May look at putting that to use as well.

Until then, be safe, be happy and have fun coding.

Resources