I have had a lot of fun working on the code for this post. Well on all the spirograph posts to-date. This post will, once again, have more code than curves. But you will be able to get all the curves you like if you write and run the code yourself.

I also decided to reduce some of the duplication between the modules I have written to-date, so I created a package, spiro_plotlib.py, to provide functions to use for plotting. So far it contains only what I needed for the current stage in my look at spirographs. I don’t know if I will add functions to cover animating the curves, with or without wheels.

The code to generate random sets of radii and frequencies, which we will be looking at in this post, will also go in a package.

The packages will be imported into a separate module, spiro.test.py. Though there may be some test code in the packages as well. I really should learn how to write and implement unit tests. But I think I will leave that for another day — don’t want to ruin the fun.

Random Radii and Frequencies

No real rules for the radii. Though likely certain combinations are going to produce better looking curves. Something I really haven’t sorted just yet.

The frequencies on the other hand do have a rule that needs to be accounted for. That is all the frequencies must be congruent mod k (where k is the k-fold symmetry for the curve).

What do we need to know? Well the number of wheels for sure. The k-fold symmetry desired. And the congruency value. The functions we create, get_freqs() and get_radii() will be passed those values each needs. In the case of the congruency value, I am going to make that optional by specifying a default of None for that parameter. If no value is provided, the function will randomly generate a suitable value. You may recall that having all the frequencies congruent to some value modulus the k-fold symmetry allows for a more rapid and guaranteed closing of the curve.

get_radii()

Let’s start with perhaps the simplest one. I don’t currently know what’s best when selecting radii. So, I decided that the radius of the first wheel will be equal to 1. Each subsequent wheel will be same size as the preceding one or slightly smaller. As I play around that might change. As for slightly smaller, that’s also pretty subjective. I decided no wheel should have a radius less than 1/8 times the first. See below for the multipliers I selected for the 2nd wheel onwards. nw_min is the new wheel’s minimum relative size. And nmw_max is the wheel’s maximum relative size.

""" r:\learn\py_play\spirograph\spiro_get_rand.py:
  Generate, based on possible parameters (wheels, symmetry, modulus congruence value),
  a set of radii and frequencies with the specified k-fold symmetry.

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

  This module may become a package, so writing it that way from the get go.

  __main__ will use argparse to get values for the various parameters.
  All of which currently optional

  Version 0.1.0: 2022.02.05, rek
"""

import random
import numpy as np  

# wheel radii: 1st wheel always radius of 1, 2nd wheel smaller than first, successive wheels smaller or equal in size to previous wheel
# 40% of the time make radius imaginary
# very small wheels at the end are not so good, loose too much detail
# specify min/max multipliers for circles each position, starting at the 2nd (i.e. index 1)
nw_min = [.5, .5] + [.5] * 7
nw_max = [.8, .75] + [1] * 7
min_rad = 1/8

So, the second wheel will always have a radius of:

$$0.5 <= r_2 <= 0.8$$

I also decided to make about 40% of the radii imaginary. Including imaginary radii really seems to make for better (more interesting) looking curves. And, because I will be applying the multiplier to the radius of the previous wheel, we will need to deal with the fact that that radius may be imaginary. All in all, pretty straightforward.

def get_radii(nbr_w):
  radii = [1]
  for i in range(1, nbr_w):
    # make radius imaginary ~40% of the time
    make_complex = (np.random.randint(5) in [2, 3])
    # need real numbers for the first bit of arithmetic
    r_rad = [max(np.real(rd), np.imag(rd)) for rd in radii]
    # always select the largest possible radius
    tmp_r = max(min_rad, r_rad[i-1] * np.random.uniform(nw_min[i], nw_max[i]))
    if make_complex:
      radii.append(1j * tmp_r)
    else:
      radii.append(tmp_r)
  return radii

get_freqs()

This one isn’t really any more complicated. Based on the Wheels on Wheels on Wheels-Surprising Symmetry. paper, we don’t really want to use frequencies that are congruent to 0 mod k. This doesn’t plot a k-fold curve in the common sense of the concept of k-fold. So, I will limit the congruency value to 1, 2, ..., k. As stated before, if not supplied, this value will be randomly selected from the available choices.

The individual radii are calculated as follows:

$$multiplier * k + congruency\_value$$ $$where: -4 <= multipler < 5$$

I also decided to limit the frequency for the first wheel to one of three values. I.E. multiplier in [-1, 0, 1]. Here’s my code.

def get_freqs(nbr_w, kf, mcg=None):
  speeds = []
  # mod congruency value
  m_add = None
  if mcg:
    m_add = mcg
  else:
    m_add = np.random.randint(1, kf)
  speeds = [np.random.choice([m_add - kf, m_add, m_add + kf])]
  for _ in range(1, nbr_w):
    m_mult = np.random.randint(-4, 5)
    speeds.append(m_mult * kf + m_add)
  # I want to know m_add for plot titles
  return m_add, speeds

Testing

Okay, let’s use the package and test it out. Creating a new module, spiro.test.py. I am going to set defaults for various values, then use command line parameters to change any of the ones I’m interested in. Since I will likely have a couple or more different tests I will add a command line parameter for that as well.

Get the basics set up. Since I plan to add a test with plotting I am importing my spiro_plotlib package now as well. When I get to plotting, I will want to be able to specify which feature gets fixed for the multiplot situation. I am including that parameter now so I don’t need to talk about it later.

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

# defaults
nbr_w = 3
k_f = 6
c_val = None
fx_frq = False
mx_tst = 3
do_test = 1

# define and get command line arguments/options, all are optional, no defaults specified, use above
parser = argparse.ArgumentParser(description='Generate spirograph')
parser.add_argument('-w', '-wheels', type=int, help='Number of wheels to use to generate spirograph, min: 2, max: 8, default: 3')
parser.add_argument('-s', '--sym', type=int, help='k-fold symmetry to use, min: 3, max: 10, default: 6')
parser.add_argument('-v', '--vmod', type=int, help='congruency value (optional), 1 <= cval <= sym - 2, default: 1')
parser.add_argument('-t', '--tnbr', type=int, help=f'number of test to run (optional), 1 <= tnbr <= {mx_tst}')
parser.add_argument('-f', '--freq', help="use fixed frequencies, default is radii", action="store_true")

args = parser.parse_args()
# print(args)
# Namespace(w=8, sym=3, vmod=None, tnbr=2, freq=False)

if args.w and args.w >= 2 and args.w <= 8:
  nbr_w = args.w
if args.sym and args.sym >= 3 and args.sym <= 10:
  k_f = args.sym
if args.vmod and args.vmod >= 1 and args.vmod <= (k_f):
  c_val = args.vmod
if args.tnbr >= 1 and args.tnbr <= mx_tst:
  do_test = args.tnbr
fx_frq = args.freq

if do_test == 1:
  ...

if do_test == 2:
  ...

if do_test == 3:
  ...

Test 1

And our first will use any supplied parameters to generate a set of radii and frequencies. Then do the same for 3 to 8 wheels. Randomly generating a k-fold symmetry and congruency value for each case. Printing the details to the terminal. Bit of code. And a touch sloppy should likely have added a function for the repeitious portions.

if do_test == 1:
  print(f"Running test number {do_test}: generate random frequencies and radii")
  # no limit for wheels coded in spiro_get_rand.py, but...
  if args.w or args.sym or args.vmod:
    print(f"\nsupplied/default parameters:\n\tnbr_w {nbr_w}, k_f: {k_f}, c_val: {c_val}")
    radii = get_radii(nbr_w)
    print(f"get_radii({nbr_w}) -> {radii}")
    cgv, freqs = get_freqs(nbr_w=nbr_w, kf=k_f, mcg=c_val)
    print(f"get_freqs(nbr_w={nbr_w}, kf={k_f}, mcg={c_val}) -> {cgv}, {freqs}")

  print("\nRandom values for 3 to 8 wheels:")
  for wheels in range (3, 9):
    # select k-fold symmetry and congruency value
    kf = np.random.randint(2, wheels)
    cgv = np.random.randint(1, kf)
    print(f"\tnbr_w {wheels}, k_f: {kf}, c_val: {cgv}")
    radii = get_radii(wheels)
    print(f"\tget_radii({wheels}) -> {radii}")
    cgv, freqs = get_freqs(nbr_w=wheels, kf=kf, mcg=cgv)
    print(f"\tget_freqs(nbr_w={wheels}, kf={k_f}, mcg={cgv}) -> {cgv}, {freqs}\n")
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -w 8 -s 3 -t 1
Running test number 1: generate random frequencies and radii

supplied/default parameters:
        nbr_w 8, k_f: 3, c_val: None
get_radii(8) -> [1, 0.6695394352757726, 0.4520289482916753, 0.37620918502107953, 0.30333304947863177j, 0.25790036162327273, 0.24445950457637014j, 0.22827115174570098j]
get_freqs(nbr_w=8, kf=3, mcg=None) -> 1, [4, 7, -5, 10, 1, 10, 1, 4]

Random values for 3 to 8 wheels:
        nbr_w 3, k_f: 2, c_val: 1
        get_radii(3) -> [1, 0.5196602399194656j, 0.3708105239018866j]
        get_freqs(nbr_w=3, kf=3, mcg=1) -> 1, [1, -7, 7]

        nbr_w 4, k_f: 3, c_val: 2
        get_radii(4) -> [1, 0.6323702002276301, 0.442076574864848, 0.4107952664664783j]
        get_freqs(nbr_w=4, kf=3, mcg=2) -> 2, [5, -1, -7, -7]

        nbr_w 5, k_f: 3, c_val: 1
        get_radii(5) -> [1, 0.5278619748404973, 0.34857374488433807j, 0.2893164252455362, 0.2715769756297129]
        get_freqs(nbr_w=5, kf=3, mcg=1) -> 1, [-2, 7, 1, -8, 7]

        nbr_w 6, k_f: 2, c_val: 1
        get_radii(6) -> [1, 0.5623097552000933j, 0.365072190461336, 0.3381915393077283, 0.25273817666942594, 0.13497444800318098j]
        get_freqs(nbr_w=6, kf=3, mcg=1) -> 1, [-1, 5, -5, 9, 5, -7]

        nbr_w 7, k_f: 5, c_val: 2
        get_radii(7) -> [1, 0.672191272092875, 0.6156018774670398, 0.4617080002870203, 0.30431406511672415, 0.16433264388386032j, 0.16250361438361416]
        get_freqs(nbr_w=7, kf=3, mcg=2) -> 2, [2, 12, 22, 7, 17, 22, 7]

        nbr_w 8, k_f: 3, c_val: 2
        get_radii(8) -> [1, 0.5676079974464865j, 0.31553151222481113, 0.20906735751981087j, 0.13138868803497425, 0.125j, 0.125, 0.125]
        get_freqs(nbr_w=8, kf=3, mcg=2) -> 2, [5, 8, -10, -4, 11, 8, -1, -7]

Test 2

Okay, let’s use that new plotting package and plot something. Pretty straightforward this one.

if do_test == 2:
  print(f"Running test number {do_test}")
  print(f"nbr_w: {nbr_w}, k_f: {k_f}, c_val: {c_val}")
  radii = get_radii(nbr_w)
  print(f"get_radii({nbr_w}) -> {radii}")
  cgv, freqs = get_freqs(nbr_w=nbr_w, kf=k_f, mcg=c_val)
  print(f"get_freqs(nbr_w={nbr_w}, kf={k_f}, mcg={c_val}) -> {cgv}, {freqs}")

  splt.set_spiro(freqs, radii, 500)
  fig, ax = splt.sp_plot_curve()
  plt.show()

And a sample result or two.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -w 8 -s 3 -t 2
Running test number 2
nbr_w: 8, k_f: 3, c_val: None
get_radii(8) -> [1, 0.5767047823864255, 0.475797103363703j, 0.31272600981671894, 0.18385403310788132j, 0.1763250826950549, 0.17135077092398893, 0.125]
get_freqs(nbr_w=8, kf=3, mcg=None) -> 2, [5, -10, -10, -1, 11, 5, 5, -4]
test of random curve generation and plotting packages showing plot of 8 wheel and 3-fold symmetry curve
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -w 8 -s 5 -v 2 -t 2
Running test number 2
nbr_w: 8, k_f: 5, c_val: 2
get_radii(8) -> [1, 0.5130550962112912, 0.38665662602834333j, 0.3345519407044923, 0.31614536410663624j, 0.20251541787006705, 0.1767531385629905j, 0.16128346656760156j]
get_freqs(nbr_w=8, kf=5, mcg=2) -> 2, [-3, -8, -8, -8, 17, -8, -8, 2]
test of random curve generation and plotting packages showing plot of 8 wheel and 5-fold symmetry curve

And, to show that the values change with each call.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -w 8 -s 5 -v 2 -t 2
Running test number 2
nbr_w: 8, k_f: 5, c_val: 2
get_radii(8) -> [1, 0.6378218228624757j, 0.4976108420101476, 0.29536639924323344, 0.24929948209153913, 0.23266237324362524, 0.2263637215206011j, 0.16551925633155146]
get_freqs(nbr_w=8, kf=5, mcg=2) -> 2, [7, -18, -3, -13, 2, 12, 17, 12]
test of random curve generation and plotting packages showing plot of 8 wheel and 5-fold symmetry curve

Test 3

Let’s try producing that multiplot figure we looked at in the last post. We’ll generate the data for the first curve. Save the selected item as the fixed element, then generate seven more sets of curve data using the same parameters, but only use the data for the changing element to generate the remaining 7 subplots. Again lots of duplicated code that should likely have been in separate functions.

Also have to account for the fact that the actual congruency value may change from what was passed in any command line parameters. And, remember there is a switch to determine which set of curve values gets fixed: radii (default), frequency (selected by command line option).

if do_test == 3:
  fxd = 'radii'
  chgg = 'freqs'
  if fx_frq:
    fxd = 'freqs'
    chgg = 'radii'
  
  # for this test don't want to use default parameter values if no parameters passed in
  if not (args.w or args.sym or args.vmod):
    nbr_w = np.random.randint(3, 9)
    k_f = np.random.randint(2, nbr_w+1)
    c_val = np.random.randint(1, kf)

  print(f"Running test number {do_test}: fixed {fxd}, changing {chgg}")
  print(f"nbr_w: {nbr_w}, k_f: {k_f}, c_val: {c_val}")

  p_rw, p_cl = 4, 2
  fig, axs = plt.subplots(p_rw, p_cl, figsize=(8,11), squeeze=False, sharex=False, sharey=False)
  plt.axis('off')

  # and let's plot p_rw * p_cl subplots
  # init starting row and column
  rw = 0
  cl = 0
  # limit to max nbr subplots
  for nsb in range(p_rw * p_cl):
    radii = get_radii(nbr_w)
    # print(f"get_radii({nbr_w}) -> {radii}")
    cgv, freqs = get_freqs(nbr_w=nbr_w, kf=k_f, mcg=c_val)
    # print(f"get_freqs(nbr_w={nbr_w}, kf={k_f}, mcg={c_val}) -> {freqs}")

    splt.set_spiro(freqs, radii, 500)

    if fxd == 'radii':
      fx_vals = [f"{rd:.3f}" for rd in radii]
      fx_vals = [rd.replace('0.000+','') for rd in fx_vals]
      fx_vals[0] = fx_vals[0].replace('.000','')

      if len(fx_vals) < 7:
        f_ttl = f"{nbr_w} wheels with {k_f}-fold symmetry ({cgv} mod {k_f})\n\n{fxd}: {fx_vals}\n"
      else:
        f_ttl = f"{nbr_w} wheels with {k_f}-fold symmetry ({cgv} mod {k_f})\n\n{fxd}: {fx_vals}\n"
    else:
      fx_vals = [f"{fq}" for fq in freqs]
      f_ttl = f"{nbr_w} wheels with {k_f}-fold symmetry ({cgv} mod {k_f})\n\n{fxd}: {fx_vals}\n"
    fig.suptitle(f_ttl)

  # determine current column
    cl = nsb % p_cl
    # and current row
    if nsb > 0:
      if cl == 0:
        rw += 1

    axs[rw, cl] = splt.sp_subplt_curve(ax=axs[rw, cl], sb_param={'fxd': fxd})
  
  plt.tight_layout()
  plt.show()

Let’s try a couple test runs.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -w 6 -s 6 -t 3
Running test number 3: fixed radii, changing freqs
nbr_w: 6, k_f: 6, c_val: None
test of random curve generation and plotting packages showing figure with subplots of 6 wheel and 6-fold symmetry curve with fixed radii

Let’s use the same parameters, except this time fix the frequencies.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -w 6 -s 6 -f -t 3
Running test number 3: fixed freqs, changing radii
nbr_w: 6, k_f: 6, c_val: None
test of random curve generation and plotting packages showing figure with subplots of 6 wheel and 6-fold symmetry curve with fixed frequencies

One last one, without specifying any parameters except the test number.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -t 3
Running test number 3: fixed radii, changing freqs
nbr_w: 6, k_f: 4, c_val: 2
test of random curve generation and plotting packages showing figure with subplots of 6 wheel and 4-fold symmetry curve with fixed radii

These things are now so easy to produce, let’s do one more

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -f -t 3
Running test number 3: fixed freqs, changing radii
nbr_w: 7, k_f: 2, c_val: 1
test of random curve generation and plotting packages showing figure with subplots of 7 wheel and 2-fold symmetry curve with fixed frequencies

Ah, why not one more.

(ds-3.9) PS R:\learn\py_play\spirograph> python spiro.test.py -f -t 3
Running test number 3: fixed freqs, changing radii
nbr_w: 5, k_f: 2, c_val: 1
test of random curve generation and plotting packages showing figure with subplots of 5 wheel and 2-fold symmetry curve with fixed frequencies

Done

That’s it for this one. Can’t believe how quickly this test can generate a bunch of random curves. My apologies for the length of the post.

If you are interested the code for the two packages and the test module is given in this (unlisted) post, Spirograph V: Package Code.

Not sure where the next post will go. But am thinking about playing around with the multi-line plot hiccup from an earlier post.

Until then have fun plotting spirographs.

Resources