Okay, let’s have a look at the possibilities of that plotting “error” I made some time back.

The image in the post referenced above was the final frame of an animation. I don’t really want to generate animations just to get one frame. Too much time involved in generating those animations. So, I am going to look at just plotting a range of the extra lines returned by the curve parametric function we refactored the other day. I will start a new module for the code for this post, spiro_mln.test.py. I.E. multi-line.

I may also be refactoring those packages I created for the last post.

First Attempt

Module Basics

Let’s set up some basics. A module description, the imports and some variables of potential use.

""" r:\learn\py_play\spirograph\spiro_mln.py:
  Use matplotlib to generate a plot of continuous curves using the parametric technique covered in :

  FARRIS, Frank A. "Wheels on Wheels on Wheels-Surprising Symmetry.
  Mathematics Magazine 69, No. 3 (1996): 185-189.

  This module will allow for the drawing of curves based on 3 to 8 wheels.
  Radii and rotational frequencies will be specified in 'globally accessible' variables.

  But unlike previous code, one or more of the 'lines' retuned by f() will be
  plotted rather than just the final curve. An attempt at something more artistic.

  Version 0.1.0, 2022.02.10
"""

import argparse, pathlib
import matplotlib.pyplot as plt
import numpy as np
from spiro_get_rand import get_radii, get_freqs
import spiro_plotlib as splt

# Misc vars

# Defaults
# how many of the f(t) lines to plot
r_keep = None
# number of points (t for f(t)) to generate
t_pts = 100

Number of Lines to Keep

I was going to add a command line parameter for this value. But it depends on the number of wheels used to generate the curve, so I decided to add a function to generate a somewhat random value. Nothing fancy, but should make my life easier.

# functions


def get_ln_keep(nw):
  # nw: number of wheels, use to generate random number of lines to keep on plot
  r_keep = []
  if nw == 3:
    r_keep = [2]
  elif nw <= 5:
    # lower wheel numbers keep from 2 to all radial lines
    r_keep = [np.random.choice(list(range(2, nw-1)))]
  else:
    # higher wheel numbers keep from 3 to all radial lines
    r_keep = [np.random.choice(list(range(3, nw-2)))]
  return r_keep

Note: as I review this draft before publishing, I really don’t recall why I put that number in a list!?

Generate Random Curve

Covered this in the previous post. I will also get the number of lines to keep in our plot.

# generate a random curve
# number of circles initialized one time, rather than in multiple functions
nbr_c = np.random.randint(3, 9)
k_f = np.random.randint(2, nbr_c+1)
c_val = np.random.randint(1, k_f)

sp_rds = get_radii(nbr_c)
print(f"get_radii({nbr_c}) -> {sp_rds}")
cgv, sp_frq = get_freqs(nbr_w=nbr_c, kf=k_f, mcg=c_val)
print(f"get_freqs(nbr_w={nbr_c}, kf={k_f}, mcg={c_val}) -> {sp_frq}")

# convert all radii to real, again one time globablly accessible
r_rad = [max(np.real(rd), np.imag(rd)) for rd in sp_rds]

# how many lines to keep/plot
# plan to do this often and want to vary based on number of wheels
# so wrote function
r_keep = get_ln_keep(nbr_c)
print(f"r_keep: {r_keep}")

Set Up the Basic Figure

Again nothing new here

# create figure
fig, ax = plt.subplots(figsize=(8,8))  
plt.axis('off')
# leave room for title
ax.set_position([0, 0, .9, 1], which='both')
# set the aspect ratio to square
ax.set_aspect('equal')

# an array of the t values to use to plot the curve
# 500 should keep the curve smooth enough at this plot size
# and this will also determine the number of frames in the animation
t = np.linspace(0, 2*np.pi, t_pts)

# set up the plotting package
splt.set_spiro(sp_frq, sp_rds, t_pts)

Generate the Plot

This took a little experimentation, but I finally settled on something that worked for me. Well after some enhancement we will get into as the post progresses. You might want to try other things to see what happens and whether you get something better overall.

Note, starting loop at line number 2. We really don’t want to plot the final curve.

# now let's give it a try
r_pts = splt.f(t)
for ln in range(2, r_keep[0]+1):
  plt.plot(np.real(r_pts[-ln:]), np.imag(r_pts[-ln:]))

plt.show()

Give It a Go

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(7) -> [1, 0.7384578971550536, 0.6409857326410648, 0.3251521811464025j, 0.23446122516595472, 0.19944844679141316j, 0.1284437317461046j]
get_freqs(nbr_w=7, kf=6, mcg=4) -> [4, 28, -14, -14, -2, 4, 22]
r_keep: [4]
plot of multiple lines from output of f(t) for random curve

Enhancements

Random Line Widths

The first thing I decided to do was add random line widths for each line. Again, I felt this called for a function. But this one I decided to add to the spiro_plotlib package. That said, here it is. This is the final version, I went through a fair number of refactorings. I also, in the end, decided that I might want to use this for other plots. In that case, I might just want to use the default width in some cases, so I added a parameter (is_fancy=) to allow for that choice.

def get_ln_wd(is_fancy=False, max_wd=23):
  if is_fancy:
    ln_w = min(max_wd, (13*np.random.randint(1, 4)) % 23)
    return ln_w
  # None is the default for matplotlib's plot function
  return None

Okay, let’s add that to our code. I may add a command line argument to control the behaviour, so let’s also add a new default variable.

# use randon line widths if True, default otherwise
lw_fancy = True

Let’s add getting a line width to the plotting code. And, give it a go. For testing I added printing out the line width. Of course, this will be a random curve so won’t look like the first one.

# now let's give it a try
r_pts = splt.f(t)
for ln in range(2, r_keep[0]+1):
  ln_w = splt.get_ln_wd(lw_fancy)
  print(f"ln: {ln}, width: {ln_w}")
  plt.plot(np.real(r_pts[-ln:]), np.imag(r_pts[-ln:]), lw=ln_w)

plt.show()
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(7) -> [1, 0.512160243095295j, 0.45062679518786897j, 0.42936433656507733, 0.2699843790305321, 0.14667633598326682, 0.13725879567309957]
get_freqs(nbr_w=7, kf=7, mcg=5) -> [5, 19, 19, 19, -16, -16, 12]
r_keep: [3]
ln: 2, width: 13
ln: 3, width: 3
plot of multiple lines from output of f(t) for random curve with random line widths

Add Transparency

Next, I thought I’d look at adding a random transparency value to each line. So a new function in the spirograph plotting package. An extra bit of code to generate the plots. Since I will control this on a plot by plot basis, I did not add a variable for a default value in this module.

In spiro_plotlib I added the following function.

def get_alpha(is_fancy=True):
  # return a random alpha value between .2 and .9
  if is_fancy:
    min_a, max_a = 0.2, 0.9
    return (max_a - min_a) * np.random.random_sample() + min_a
  return None

Our refactored plotting code. Again printing extra information for testing purposes.

# now let's give it a try
r_pts = splt.f(t)
print(f"len r_pts[-2:] --> {r_pts[-2:][:10]}")
for ln in range(2, r_keep[0]+1):
  ln_w = splt.get_ln_wd(lw_fancy)
  ln_alpha = splt.get_alpha()
  print(f"ln: {ln}, width: {ln_w}, alpha: {ln_alpha}")
  plt.plot(np.real(r_pts[-ln:]), np.imag(r_pts[-ln:]), lw=ln_w, alpha=ln_alpha)

plt.show()
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(7) -> [1, 0.6207763119477836j, 0.371842842745497j, 0.19443675907899535, 0.125, 0.125, 0.125]
get_freqs(nbr_w=7, kf=7, mcg=3) -> [-4, 3, 17, -18, -4, -11, -4]
r_keep: [3]
ln: 2, width: 16, alpha: 0.5234753796542497
ln: 3, width: 3, alpha: 0.8536331376890738
plot of multiple lines from output of f(t) for random curve with random line widths and transparencies

That does seem to add a bit more interest.

But still not what I was hoping for.

Changing the Number of Points

Since I want to try a number of different values, I am going to add a command line parameter for this one. You may have noted that I had already included the appropriate import at the top of the module.

Somewhere near the top of the file, but after the default variable declarations, add the following.

parser = argparse.ArgumentParser(description='Generate spirograph')
parser.add_argument('-p', '--points', action='store', dest='pi_pts',
                    type=int, help="Specify number of points in plot between 0 and 2pi, Examples: -p 500, default 100")

args = parser.parse_args()

if args.pi_pts:
  t_pts = args.pi_pts

Now let’s run a few variations.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py -p 200
get_radii(8) -> [1, 0.7075718214043143, 0.37036292661994863j, 0.29366510325307305, 0.1599804186121314j, 0.1334220139705753j, 0.125j, 0.125]
get_freqs(nbr_w=8, kf=2, mcg=1) -> [3, -1, -1, -3, -7, 5, 9, 3]
r_keep: [3]
ln: 2, width: 16, alpha: 0.817879309231679
ln: 3, width: 16, alpha: 0.3878679098857327
plot of multiple lines from output of f(t) for random curve with random line widths and transparencies with 200 plotting points
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py -p 300
get_radii(4) -> [1, 0.5052149552378209j, 0.41756740230620826, 0.24796265093848163]
get_freqs(nbr_w=4, kf=3, mcg=1) -> [-2, -5, 1, 7]
r_keep: [2]
ln: 2, width: 16, alpha: 0.3835277739651046
plot of multiple lines from output of f(t) for random curve with random line widths and transparencies with 300 plotting points
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py -p 400
get_radii(6) -> [1, 0.5634774234971028j, 0.4687476312184444, 0.37007625464568283, 0.3157314549960819j, 0.26907634873778147]
get_freqs(nbr_w=6, kf=5, mcg=1) -> [6, -14, 6, -14, 21, -9]
r_keep: [3]
ln: 2, width: 13, alpha: 0.3041912266002757
ln: 3, width: 3, alpha: 0.7507449342902335
plot of multiple lines from output of f(t) for random curve with random line widths and transparencies with 400 plotting points

Looks to me like 100 points is not enough and more generally looks better with higher values. Though I expect the best value depends on the parameters of the curve being used. But, I am going to set the default value of t_pts to 500. And, run a number of repeated tests to see how things look. Having run those tests, I will leave it at 500 for now.

Different Colour Maps

So far I have been going with the default color map. But, I know there are options. So let’s try a few. Another function and some new knowledge regarding matplotlib.

The property cycle controls the style properties such as color, marker and linestyle of future plot commands.

Most important for us, matplotlib cycles through the colours in the axis prop_cycle for each line plotted. So, I am going to write a function to randomly assign one of a few colour maps I selected from those on the choosing colormaps page in the matplotlib documentation.

For now this function is defined in this module. But I will likely add it to the spirograph plotting package. I expect it will find use in future spirograph efforts. In addition to the colour cycle values, I am returning the colour map name so that I can print/track the colour map being used.

def set_colour_map(ax, n_vals=None):
  clr_opt = ['default', 'plt.cm.GnBu_r', 'plt.cm.PuBu_r', 'plt.cm.viridis', 'plt.cm.hot', 'plt.cm.twilight_shifted']
  # if random int == 0, leave default color cycle
  u_cmap = np.random.randint(0,5)
  c_cycle = []
  c_rng = 8
  if n_vals:
    c_rng = n_vals
  if u_cmap == 1:
    ax.set_prop_cycle('color',[plt.cm.GnBu_r(i) for i in np.linspace(0, 1, c_rng)])   # like this one
  elif u_cmap == 2:
    ax.set_prop_cycle('color',[plt.cm.PuBu_r(i) for i in np.linspace(0, 1, c_rng)])   # like this one
  elif u_cmap == 3:
    ax.set_prop_cycle('color',[plt.cm.viridis(i) for i in np.linspace(0, 1, c_rng)])   # like this one
  elif u_cmap == 4:
    ax.set_prop_cycle('color',[plt.cm.hot(i) for i in np.linspace(0, 1, c_rng)])   # like this one
  c_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color']
  return clr_opt[u_cmap], c_cycle

And, in the section where we define the plot, let’s

# set up colour cycler for circle colours
clr_opt = ['default', 'plt.cm.GnBu_r', 'plt.cm.PuBu_r', 'plt.cm.viridis', 'plt.cm.hot', 'plt.cm.twilight_shifted']
rcm, cycle = set_colour_map(ax)
print(f"colour map: {rcm}")

Let’s give that a few test runs. I will likely only include one or two of them in this post.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(3) -> [1, 0.5250133430376055, 0.3157645803705394j]
get_freqs(nbr_w=3, kf=3, mcg=2) -> [2, -1, 5]
r_keep: [2]
colour map: plt.cm.GnBu_r
ln: 2, width: 13, alpha: 0.8678237685480206
plot of multiple lines from output of f(t) with random color map
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(8) -> [1, 0.6509768897649546j, 0.47337052694828874, 0.3215469628508561j, 0.32100518948946305j, 0.19950037537691814, 0.125, 0.125j]
get_freqs(nbr_w=8, kf=8, mcg=5) -> [13, -27, -19, 21, -19, -27, 29, -3]
r_keep: [5]
colour map: plt.cm.PuBu_r
ln: 2, width: 13, alpha: 0.8629568280659374
ln: 3, width: 13, alpha: 0.671619422370225
ln: 4, width: 13, alpha: 0.7116661153719805
ln: 5, width: 3, alpha: 0.8752312611331701
plot of multiple lines from output of f(t) with random color map

One last one.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(8) -> [1, 0.6906133506316641, 0.4178441336172453, 0.2370265642758671, 0.2202021023532734j, 0.13940350467488896j, 0.12596935712880847, 0.125]
get_freqs(nbr_w=8, kf=7, mcg=6) -> [13, 27, 34, -15, 6, 34, 27, 20]
r_keep: [4]
colour map: plt.cm.hot
ln: 2, width: 13, alpha: 0.8147736723699797
ln: 3, width: 3, alpha: 0.20973050046741595
ln: 4, width: 13, alpha: 0.24341190718041333
plot of multiple lines from output of f(t) with random color map

Done

I have more things I’d like to try, but I think we have covered enough material in this one. Next time I will look at generating a figure showing these kinds of plot with fixed frequencies or radii and the non-fixed parameter changing for each of 8 subplots. Similar to previous work we’ve done.

I may also look at what happens with changing the line style for each plotted line. I will likely, at that time, also add a post with the updated code for spiro_plotlib.

Until then, have fun coding.

Resources