# Analyzing spoke overlaps during rotation

For a personal project that I've recently started I have to analyze the rotation of two shapes with sets of spokes, where the spokes are evenly-spaced, and split into a given number.

That is, if I have a shape with x evenly-spaced spokes, and a shape with y evenly-spaced spokes, I need to analyze two major properties:

1. What is the "Phase Difference"? That is, if one shape were rotating, how many degrees would it need to rotate before any spoke overlaps the other shape?

2. What is the number of overlaps in a single 360° rotation? That is, if one shape were rotating, how many times would any spoke overlap the other shape?

To start with, I'll provide the type of output this program produces. This is an animation of how one shape with 8 spokes and one shape with 9 spokes would overlap. Each frame in the GIF is a single overlap. For this one, there are 72 such overlaps.

Next, we look at the console output (for this exact set):

Shape 1 Angle (8 spokes): 45.000000° / 0.785398 rads
Shape 2 Angle (9 spokes): 40.000000° / 0.698132 rads
Phase Difference: 5.000000° / 0.087266 rads (0.013889 rotations)
Overlaps per Rotation: 72.000000


We note that the console tells us the difference in "Phase" (that is: how far must it rotate to overlap?) is 5°, and the number of overlaps is 72. This means the shapes would overlap 72 times in a given rotation, were either of them spinning.

All this stuff happens in three locations:

Graphics.fs which just helps us draw points and lines from an "origin" properly:

module Spoke_Analyzer.Graphics
open System.Drawing

let drawLine (origin : Point) (g : Graphics) (pen : Pen) (start : Point, stop : Point) =
g.DrawLine(pen, Point(start.X + origin.X, start.Y + origin.Y), Point(stop.X + origin.X, stop.Y + origin.Y))

let drawPoint (origin : Point) (g : Graphics) (brush : Brush) (start : Point) width =
g.FillEllipse(brush, Rectangle(start.X + origin.X - (width / 2), start.Y + origin.Y - (width / 2), width, width))


Input.fs which helps us consume CLI input:

module Spoke_Analyzer.Input
open System

let rec getInput convert validate prompt =
printf "%s" prompt
let input = () |> Console.ReadLine
if input |> validate then
input |> convert
else
getInput convert validate prompt
let getInputInt = getInput Int32.Parse (Int32.TryParse >> function | true, f when f > 0 -> true | _ -> false)
let getInputIntOption =
getInput
(function | "" -> None | s -> s |> Int32.Parse |> Some)
(function | "" -> true | s -> s |> Int32.TryParse |> function | true, f when f > 0 -> true | _ -> false)
let getInputDoubleOption =
getInput
(function | "" -> None | s -> s |> Double.Parse |> Some)
(function | "" -> true | s -> s |> Double.TryParse |> function | true, f when f >= 0. && f <= 1. -> true | _ -> false)
let getInputDouble = getInput Double.Parse (Double.TryParse >> function | true, f when f >= 0. && f <= 1. -> true | _ -> false)
let getInputFileOption (file : string) =
getInput
(function | "" -> None | s -> Some s)
(function
| "" -> true
| s ->
if Uri.IsWellFormedUriString(sprintf "file:///%s" (s.Replace('\\', '/')), UriKind.RelativeOrAbsolute) then
let test =
if s.EndsWith(file) = false && s.EndsWith(sprintf "%s.exe" file) = false then
let t = System.IO.Path.Combine(s, file)
if System.IO.File.Exists(t) then t
else System.IO.Path.Combine(s, sprintf "%s.exe" file)
else s
if System.IO.File.Exists(test) then
true
else false
else false)


And finally, the raw Program.fs:

open System
open System.Drawing
open Spoke_Analyzer

let inline degToRad deg = deg * Math.PI / 180.

[<Literal>]
let FULL_CIRCLE = 360.
[<Literal>]
let ROTATION_OFFSET = -90. // -FULL_CIRCLE / 4.
[<Literal>]
let IMAGE_DIR = "temp/images"

[<EntryPoint>]
let main argv =
let imageWidth = Input.getInputIntOption "Enter the image dimension (whole number > 0) : " |> Option.defaultValue 160
let spoke1 = Input.getInputInt "Enter the number of spokes for shape 1 (whole number > 0): "
let spoke2 = Input.getInputInt "Enter the number of spokes for shape 2 (whole number > 0): "
let offset = Input.getInputDoubleOption "Enter the radial offset in percentage (0.0 - 1.0) : " |> Option.defaultValue 0.
let ffmpeg = Input.getInputFileOption "ffmpeg" "Enter the location of ffmpeg (if available) []: "
let fps = ffmpeg |> Option.bind (fun s -> Input.getInputIntOption "Enter the fps of the output (whole number > 0) : ") |> Option.defaultValue 24
let angleDegrees1 = FULL_CIRCLE / (spoke1 |> float)
let angleDegrees2 = FULL_CIRCLE / (spoke2 |> float)
printfn ""

let rec getRotation small large =
if small > large then
getRotation large small
else
if small = large then small
elif large = FULL_CIRCLE then small
else
let v1 = large / small
let divisions = v1 |> int |> float
let v2 = min (large - small) (large - small * divisions)

let v1 = FULL_CIRCLE / v1
min v1 v2

let rotation = getRotation angleDegrees1 angleDegrees2
let rotations = FULL_CIRCLE / rotation

let rec drawSaveImage i =
use bmp = new Bitmap(imageWidth, imageWidth)
do
use g = bmp |> Graphics.FromImage
g.SmoothingMode <- Drawing2D.SmoothingMode.AntiAlias
use fillBrush = new SolidBrush(Color.FromArgb(255, 32, 32, 32))
g.FillRectangle(fillBrush, Rectangle(0, 0, bmp.Width, bmp.Height))
let origin = Point(bmp.Width / 2, bmp.Height / 2)
use pen1 = new Pen(Color.FromArgb(255, 224, 32, 32), 2.5f)
use brush1 = new SolidBrush(Color.FromArgb(255, 224, 32, 32))
let drawLine1 = Spoke_Analyzer.Graphics.drawLine origin g pen1
use pen2 = new Pen(Color.FromArgb(255, 32, 224, 32), 1.5f)
use brush2 = new SolidBrush(Color.FromArgb(255, 32, 224, 32))
let drawLine2 = Spoke_Analyzer.Graphics.drawLine origin g pen2
let drawPoint = Spoke_Analyzer.Graphics.drawPoint origin g

let distance = (imageWidth / 2) |> float

let rec drawSpoke drawLine distance offset angle max num =
if num = max then ()
else
drawLine
(Point(0, 0),
Point((distance * ((angle * (num |> float) + offset) |> degToRad |> cos)) |> int,
(distance * ((angle * (num |> float) + offset) |> degToRad |> sin)) |> int))
drawSpoke drawLine distance offset angle max (num + 1)

drawSpoke drawLine1 distance ROTATION_OFFSET angleDegrees1 spoke1 0
drawSpoke drawLine2 distance (angleDegrees2 * offset + ROTATION_OFFSET + rotation * (i |> float)) angleDegrees2 spoke2 0
drawPoint brush1 (Point(0, -distance |> int)) 8
drawPoint brush2 (Point((distance * ((rotation * (i |> float) + ROTATION_OFFSET) |> degToRad |> cos)) |> int,
(distance * ((rotation * (i |> float) + ROTATION_OFFSET) |> degToRad |> sin)) |> int)) 6
()
bmp.Save(sprintf "%s/rot_%i.png" IMAGE_DIR i, Imaging.ImageFormat.Png)
if Double.IsInfinity(rotations) |> not && (i |> float) + 1. < rotations then drawSaveImage (i + 1)
()

if IMAGE_DIR |> System.IO.Directory.Exists |> not then IMAGE_DIR|> System.IO.Directory.CreateDirectory |> ignore
IMAGE_DIR |> System.IO.Directory.GetFiles |> Array.iter System.IO.File.Delete
drawSaveImage 0
printfn "Images saved."

match ffmpeg with
| Some ffmpeg ->
let ffmpeg =
if ffmpeg.EndsWith("ffmpeg") = false && ffmpeg.EndsWith("ffmpeg.exe") = false then
System.IO.Path.Combine(ffmpeg, "ffmpeg")
else ffmpeg

printfn "Running ffmpeg..."
System
.Diagnostics
.Process
.Start(ffmpeg, sprintf "-framerate %i -f image2 -i %s/rot_%%d.png -c:v libx264 -crf 0 -r %i -preset ultrafast -tune stillimage %s/temp.avi" fps IMAGE_DIR fps IMAGE_DIR)
.WaitForExit()
System
.Diagnostics
.Process
.Start(ffmpeg, sprintf "-i %s/temp.avi -pix_fmt rgb24 %s/_final.gif" IMAGE_DIR IMAGE_DIR)
.WaitForExit()
printfn "Images converted to gif."
printfn ""
| _ -> ()

printfn "Shape 1 Angle (%i spokes): %f° / %f rads" spoke1 angleDegrees1 (angleDegrees1 |> degToRad)
printfn "Shape 2 Angle (%i spokes): %f° / %f rads" spoke2 angleDegrees2 (angleDegrees2 |> degToRad)
printfn "Phase Difference: %f° / %f rads (%f rotations)" rotation (rotation |> degToRad) (rotation / FULL_CIRCLE)
printfn "Overlaps per Rotation: %f" (rotations)

0


Overall, nothing overly-complex here.

Appreciate any/all advice. Source code is on GitHub:

https://github.com/EBrown8534/Spoke_Analyzer

Don't get me wrong, this is something pretty cool and it's really well-crafted for the most part. But I'm still waiting for this to go the last mile:

There is 12 local variables or functions in your main. I reckon you can easily reduce that to half as many by adding some more abstraction.
While we're working on that, we should also look at the naming of these things.

Rough steps to make this much easier to grasp:

1. Extract getRotation into a separate module and rename it to computePhaseDelta or something like that. Note that neither "Rotation" nor "Phase Difference" are accurate descriptions of what you try to describe.
A phase difference is really only meaningful when comparing waveforms with the same frequency. For all other waveforms the phase difference is a function of time. I do prefer "phase difference" over "rotation", though.

2. Extract input-prompting into it's own function and encapsulate all information relating to the problem into a single type. This allows you to utterly abstract all the "low-level" prompting and handling away another step.

3. Move the image-drawing local function out of main. It's an infodump that's interrupting the control flow. It also adds another bunch of local variables.

4. Extract the gif conversion into it's own function. It's comparatively long as well.

Overall main is intermingling a lot of abstaction levels. To avoid that you'll need to extract more functions.

Now that I've bashed around there, I should mention that I found the Input module somewhat annoying to follow, mostly because of the line length and the the way functions are chained. Then again writing nice and abstract input functions in a functional language often is somewhat ugly ;)

I like very much the way you've set up drawLine and drawPoint. It's a bit hard to grasp on first reading, but you've made them "coordinate-system aware". They are also trivial to partially apply, which lends itself really well to functional programming.

You could've used extended partial application to reduce the complexity inside drawSaveImage after encapsulating it into a separate module (or attaching it to Graphics.fs). While you used it to construct drawLine1, drawLine2 and drawPoint, you have not gained that much because all of the setup for colors and pens is still in drawSaveImage

On that note: you're recursing inside drawSaveImage. I'd have preferred the function to only draw and save a single image given it's name. That might have also allowed you to simplify the pen and draw* setup across all invocations of the function by passing these as parameters.

Overall I very much like this code, but I want to see the abstraction it performs taken to it's logical conclusion.

• With regard to "Phase Difference": you wouldn't believe how long we spent in the F# Slack trying to come up with a name for that...my original was worse, still trying to find a good one. (Maybe "Angle Offset"?) Feb 11, 2019 at 19:57