Not too sure exactly what to tackle next. But, I think I am going to start by taking a set of radii, then mess with the frequencies to see how that affects the curve. Then maybe do the reverse, for a given set of frequencies try a variety of associated radii.

I am going to start a new module, spiro_3.py. We will need code similar to the first module coded for this series on spirographs.

I think I will start this experiment with that 5 wheel curve from the last post. May try other combinations.

But, first of all, I want to add a command line argument to control what gets processed/plotted.

Command Line Arg(s) and Data File

I was originally going to create a data structure within the module for all the possible tests. But have since decided to create CSV or JSON files with the data for each test. Seems to me to be a bit tidier. That means the command line argument will need to take a filename. I will keep it simple with the test files in a subdirectory of the modules home directory (in my case \learn\py_play\spirograph\sp_tst\).

That also means that we will need to define the structure of the file being loaded, once we decide on CSV or JSON. But first, let’s cover getting that command line argument.

To start, will add import argparse, pathlib with all the other imports. Pathlib will make file handling just a touch easier (as mentioned in a post some time back). Will also define a few variables that may come in handy.

Oh yes, turns out JSON does not handle complex numbers in any easy fashion. So going to go with CSV. Was going to use pandas to read the CSV files but it also doesn’t like complex numbers. Just treats them as strings. Could use Numpy, but decided in the end to go with plain Python to read the file and convert the contents into a useable form.

""" r:\learn\py_play\spirograph\spiro_3.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 be trying to compare curves with wheels of the same radii with
  differing frequencies and vice-versa.

  I will try using subplots for the variations to hopefully make comparison easier.

  Command line arg will provide file to use for radii/frequencies to use for plots
  
  Version 0.1.0, 2022.02.03: start with copy of spiro_2_b.py and modify
"""

import argparse, pathlib
import matplotlib.pyplot as plt
import numpy as np

# Misc vars
# line weight
lw = 1
tst_dir = pathlib.Path("sp_tst")
def_t_fl = pathlib.Path("w5_chg_freq_1.csv")
# using CSV as JSON doesn't easily handle complex numbers
# also not using pandas to read CSV for same reason
# test file is assumed to be in sub-dir /sp_tst/
tst_type = 'CSV'

Now we will define a parser object, add a command line argument, retrieve it and generate the path to the specified file.

# will be using command line argument to get name of file with the 'test' data
parser = argparse.ArgumentParser(description='Compare spirograph variations')
parser.add_argument('-f', '--file', type=pathlib.Path, help=f'Name of {tst_type} file containing test data to generate comparison plot (max 8 subplots)')

args = parser.parse_args()

f_pth = None
if args.file:
  f_pth = tst_dir / args.file
else:
  f_pth = tst_dir / def_t_fl

And, of course I ran a test or two to make sure the code worked as expected.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_3.py -f w5_chg_freq_1.csv
sp_tst\w5_chg_freq_1.csv
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_3.py -f w5_chg_rad_1.csv
sp_tst\w5_chg_rad_1.csv

Reading CSV File and Generating Data Structures

After creating a test file (we’ll get to that), I started writing the code to read the file and create the data structures to use for plotting the various curves in subplots.

But, I thought I’d round out those radii a bit (a touch of manual work).

sp_rds = [1, 0.7079204688847565j, 0.6727215820151806, 0.5612608810815637, 0.35936127484837616j]
sp_rds = [1, 7j/10, 2/3, 9/16, 10j/28]

So I created a test file and went to work. That’s when I discovered the problems of working with complex numbers in CSV files. That said this was the first attempt at the datafile. I was going to use fixed radii with changing frequencies for each subplot. Eight subplots in total.

In a CSV file you normally don’t have the white space following the comma, but I wanted to be able to read things easily. So, I will need to account for that in my code.

1, 7j/10, 2/3, 9/16, 10j/28
1, 1, 5, 5, 5
1, 1, 5, 5, 17
1, 1, 5, -19, 17
1, 1, -15, -19, 17
1, -3, -15, -19, 17
1, -3, 5, -7, 17
1, -3, 5, 17, -7
-3, -3, 5, 17, -7

Fractions and Complex Numbers

Nothing I initially tried seemed to be too happy converting those strings of complex numbers to an actual number. One of the issues was converting a string like “7/10” to a real number.

Converting String Fractions to Float

print(f"float('7/10') ->")
print(f"\t{float('7/10')}")
Traceback (most recent call last):
  File "R:\learn\py_play\spirograph\spiro_3.py", line 47, in <module>
    print(f"\t{float('7/10')}")
ValueError: could not convert string to float: '7/10'

So, I wrote a function to convert a string fraction to a real number (float). It also converts any valid integer/real number string to a real. Couple of pieces involved. I first try to convert the string to a float (in a try/except block). If that doesn’t work, I assume there is a / in the string and split the string accordingly. Then I check for a space in the first part of the string, i.e. to cover something like “1 3/10”. Finally I convert all the pieces to float and return the corresponding value (adding and/or dividing as necessary). That all took some trial and error. Here’s the current version, string_fraction_2_float():

def s_frac_2_float(s_frac):
  try:
    return float(s_frac)
  except ValueError:
    num, dnm = s_frac.split("/")
    try:
        fr_int, num = num.split(' ')
        rslt = float(fr_int)
    except ValueError:
        rslt = 0
    frac = float(num) / float(dnm)
    return rslt - frac if rslt < 0 else rslt + frac

A little testing.

print(f"s_frac_2_float('7/10') -> ", end="")
print(f"\t{s_frac_2_float('7/10')}")
print(f"s_frac_2_float('1 7/10') -> ", end="")
print(f"\t{s_frac_2_float('1 7/10'):.5f}")
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_3.py
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_3.py
s_frac_2_float('7/10') ->       0.7
s_frac_2_float('1 7/12') ->     1.58333

And, to be sure.

PS R:\learn\py_play> perl -e "print 7/12;"
0.583333333333333

Converting Strings to Complex/Imaginary

Turns out converting the string of numbers (integer, float or imaginary) is now pretty easy. But do keep in mind, this is a special case in that there are no complex numbers, e.g. 1 + 7j/10, in the radii or frequencies.

def s_2_nbrs(s_nbrs):
  """Convert list of comma separated numbers (integer, float and/or imaginary but not complex)
  into array of float/imaginary.
  """
  cn_vals = s_nbrs.split(", ")
  cn_nbrs = [complex(0, s_frac_2_float(cn.replace('j', ''))) if "j" in cn else s_frac_2_float(cn) for cn in cn_vals]
  return cn_nbrs

Reading the File and Building Data Structures

I use the term data structures somewhat loosely, but not entirely incorrectly.

While working on this part of the code, I decided it would be nice to have some additional information regarding the curve in the CSV file. So, in the first row, I added the number of wheels, the k-fold symmetry, the congruency value mod k and the item, radii or frequencies (freq), that is fixed for this test. So, the CSV file now looks like the following:

5, 4, 1, radii
1, 7j/10, 2/3, 9/16, 10j/28
1, 1, 5, 5, 5
1, 1, 5, 5, 17
1, 1, 5, -19, 17
1, 1, -15, -19, 17
1, -3, -15, -19, 17
1, -3, 5, -7, 17
1, -3, 5, 17, -7
-3, -3, 5, 17, -7

To start, let’s set up a few variables, read the file and just print it. Will use a with context so I don’t have to worry about closing the file handle once done.

# metadata for the curve
c_dtl = None
wheels, sym, cng, fxd = (0, 0, 0, "radii")
# the original string and array of converted numbers for the fixed item
fx_str = ""
fx_vals = []
# array of arrays for the data for the changing item in the test
cgg_str = []
cgg_vals = []

# open and parse CSV file
with f_pth.open('r') as fl:
  print(fl.read())

And:

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_3.py -f w5_chg_freq_1.csv
5, 4, 1, radii
1, 7j/10, 2/3, 9/16, 10j/28
1, 1, 5, 5, 5
1, 1, 5, 5, 17
1, 1, 5, -19, 17
1, 1, -15, -19, 17
1, -3, -15, -19, 17
1, -3, 5, -7, 17
1, -3, 5, 17, -7
-3, -3, 5, 17, -7

Now, when I get that first line, I will want to convert it as appropriate and assign to the related variables (wheels, sym, cng, fxd). Another small function seemed appropriate.

def get_dtls(s_dtls):
  dtls = s_dtls.split(", ")
  # convert the first three to integers
  for i in range(0, 3):
    dtls[i] = int(dtls[i])
  return tuple(dtls)

Now I am going to read the file line by line, stripping the trailing \n, doing what is appropriate for each. Given the functions we’ve created above, it really is pretty straightforward.

# open and parse CSV file
with f_pth.open('r') as fl:
  wheels, sym, cng, fxd = get_dtls(fl.readline().rstrip())
  line = fl.readline().rstrip()
  fx_str = line
  fx_vals = s_2_nbrs(line)
  line = fl.readline().rstrip()
  while line:
    cgg_str.append(line)
    cgg_vals.append(s_2_nbrs(line))
    line = fl.readline().rstrip()

And, a quick test.

print(wheels, sym, cng, fxd, wheels*sym)

print(f"\n{fx_str}")
print(f"\n{fx_vals}")

print(f"\n{cgg_str}\n")
print(f"\n{cgg_vals}\n")
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_3.py -f w5_chg_freq_1.csv
5 4 1 radii 20

1, 7j/10, 2/3, 9/16, 10j/28

[1.0, 0.7j, 0.6666666666666666, 0.5625, 0.35714285714285715j]

['1, 1, 5, 5, 5', '1, 1, 5, 5, 17', '1, 1, 5, -19, 17', '1, 1, -15, -19, 17', '1, -3, -15, -19, 17', '1, -3, 5, -7, 17', '1, -3, 5, 17, -7', '-3, -3, 5, 17, -7']


[[1.0, 1.0, 5.0, 5.0, 5.0], [1.0, 1.0, 5.0, 5.0, 17.0], [1.0, 1.0, 5.0, -19.0, 17.0], [1.0, 1.0, -15.0, -19.0, 17.0], [1.0, -3.0, -15.0, -19.0, 17.0], [1.0, -3.0, 5.0, -7.0, 17.0], [1.0, -3.0, 5.0, 17.0, -7.0], [-3.0, -3.0, 5.0, 17.0, -7.0]]

Creating the Figure with Subplots

We will need a little extra information before we can actually start on the plot(s). I will need to specify the appropriate variable for the fixed data to allow execution of f(t). The changing data will be specified appropriately in a loop.

Once I instantiate the figure and axes, I will also turn off display of the subplot axes, set a title, etc. Most of this is duplication from previous posts, so here’s the code.

I have decided to let matplotlib determine the axes limits. So no need for that r_rad variable.

# also want to have a label for the changing parameter (radii or frequency)
# will be used in plotting figure and subplot titles
chgg = ""
if fxd == "radii":
  sp_rds = fx_vals
  chgg = "frequency"
else:
  sp_frq = fx_vals
  chgg = "radii"

# number of circles initialized one time, rather than in multiple functions
nbr_c = len(fx_vals)
# 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, 500)

# Set up plot/figure
# number of rows and columns, i.e. number of suplots p_rw * p_cl
p_rw = 4
p_cl = 2
# instantiate figure and axes objects, add figure title with metadata and fixed parameter values
fig, axs = plt.subplots(p_rw, p_cl, figsize=(8,11), squeeze=True)
if fxd == 'radii':
  fig.suptitle(f"{wheels} wheels with {sym}-fold symmetry ({cng} mod {sym})\n\nradii: {fx_str}")
else:
  fig.suptitle(f"{wheels} wheels with {sym}-fold symmetry ({cng} mod {sym})\n\nfrequencies: {fx_str}")
plt.axis('off')

# initialize each subplot
for i in range(p_rw):
  for j in range(p_cl):
    axs[i, j].axis('off')
    axs[i, j].set_aspect('equal')

No test at this point. Now let’s get on to generating the curves and putting into an appropriate subplot. Calls for a loop. And, we will need variables to track/define the current subplots position. Based on previous posts, should be straightforward enough. So, here’ my version.

# and let's plot upto 8 subplots
# init starting row and column
rw = 0
cl = 0
n_sbp = len(cgg_vals)
for nsb in range(n_sbp):
  # determine current column
  cl = nsb % p_cl
  # and current row
  if nsb > 0:
    if cl == 0:
      rw += 1
  if fxd == 'radii':
    sp_frq = cgg_vals[nsb]
  else:
    sp_rds = cgg_vals[nsb]
    # r_rad = [max(np.real(rd), np.imag(rd)) for rd in sp_rds]

  sp_pts = f(t)
  if fxd == 'radii':
    axs[rw, cl].set_title(f"freq: {cgg_str[nsb]}")
  else:
    axs[rw, cl].set_title(f"radii: {cgg_str[nsb]}")
  axs[rw, cl].plot(np.real(sp_pts[-1]), np.imag(sp_pts[-1]))

# display final figure
plt.show()

Now to a full test. Do note, I have not shown the code for f(t) above, but it is definitely included in the module, python_3.py.

Fixed Radii, Changing Frequencies

The CSV file above uses a fixed radii for the curves and changing frequencies for each subplot. And, here’s what the above code managed to produce.

curves generated using 5 wheels with fixed radii using changing frequencies

Here’s another. Not showing the CSV file as info is on figure.

curves generated using 5 wheels with fixed radii using changing frequencies

Fixed Frequencies, Changing Radii

One more example, this time with multiple radii for a fixed set of frequencies.

curves generated using 5 wheels with fixed frequencies using changing radii

Done

I am not quite sure what the above shows us. But looks like higher frequencies for the inner wheels produces more garbage on the inside of the curve. While those on the outside do the same for the outer portions of the curve. And, adding imaginary frequencies seems to improve the interest of the curve. Much less symmetry in the final result.

Whereas, radii that are too similar seem to produce more symmetrical looking curves.

More code than curves in this one, but we can now go off and play to our heart’s content.

Next time, I think I will look at coding a function or function(s) to generate random curve parameters. Ensuring that k-fold symmetry is maintained. Likely should have done that for this one rather than messing with those CSV files. But, never hurts to practice working with files.

Until then, be happy, have fun.