Sunday, November 2, 2014

Generating a runner pace-band with Powershell

Powershell is about added value. Powershell is built upon .NET. And Powershell is flexible. Three statements. Once you know those, you know that you can cross the borders of what Powershell was meant for, invent something new, and still get great results.

I am a runner. Some days ago I have been running an half-marathon, and, during preparation, I found myself in the need of a wrist pace-band showing split times for each km, in order to evite erratic running speeds.


Of course there are pace-band generators on the web, like the one at RunnersWorld, but I took the decision that I wanted to make one myself with my preferred tool: Powershell.

There are several obstacles to reaching the desired result. I'll walk trough the required steps and we will together build a Powershell function named Get-Pace.

The Powershell function need of course to take in some parameters. The first three parameters are just used for customising your pace-band, so they'll be of type System.String.
  • RunnerName
  • RaceName
  • RaceDate
Then there are the parameters used for actual split pace calculation:
  • Distance as System.Decimal
  • Time as System.TimeSpan
  • Unit as System.String
  • Open as System.Management.Automation.SwitchParameter
We will be casting the values for these variables into the required types by using some well known accelerators.

In Powershell, to cast a Decimal you can use:

[decimal]$Distance = 42.195
or

[decimal]$Distance = '42.195'
They both will convert the passed string to a decimal.

Now we want to tell Powershell that we want to set the expected duration of the race as a TimeSpan.
 
When casting a TimeSpan, Powershell expects you to pass the string in the one of the following formats:

[timespan]$Time = '1:3:4'
will set a TimeSpan of 1 hours, 3 minutes and 4 seconds;

[timespan]$Time = '1:3'
will set a TimeSpan of 1 minutes and 3 seconds;

[timespan]$Time = '3'
will set a TimeSpan of three days;

[timespan]$Time = '3:1:2:3'
will set a TimeSpan of 3 days, 1 hours, 2 minutes and 3 seconds;

When setting a TimeSpan, always remember to enclose the string into quotes, otherwise the interpreter won't be able to do the required conversion and throw the following error:

+ [timespan]$Time = 1:3:4
+                   ~~~~~
    + CategoryInfo          : ObjectNotFound: (1:3:4:String) [], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException

The unit must accept only one of two possible value: miles or kilometers. To do so, best option is to use ValidateSet for parameter validation:

[ValidateSet('km', 'mile')][string]$Unit = 'km'
Powerful, isn't it? Parameter validation should be one of your closest allies when working on a Powershell function, remember.
 
Now there is an unfortunate problem when generating a pace-band. One of historical kind. Let me explain that. Most runners need a pace-band when running a marathon. A marathon has a weird distance. More than two milleniums ago, Pheidippides ran from the city of Marathon to Athens for roughly 40 km or 25 miles. When in 1908 the Olympics were held in London, the race was extended to 26.2 miles, or 42.195 km, so the runners could cross the finish line in front of the royal family's viewing box.
 
This brings us to the fact that while a race like a 10k has an exact number of split, races like marathon or half-marathon have a last split of shorter length. And we need to take this in consideration in our script.
 
To determine if a race has only full splits, first we need to round its distance to the closest smaller integer. Two correct ways to do that:

$FullSplits = [decimal]::Floor($Distance)
or

$FullSplits = [System.Math]::Floor($distance)
Then to get the last incomplete split length, just use:

$LastIncompleteSplit = $Distance - $FullSplits
After having determined the number of split, next step is to calculate the number of seconds per split: 
$TimeSpanPerSplit = New-TimeSpan -seconds ($Time.TotalSeconds/$Distance)
and convert it to speed using a simple formula you all know:

$Speed = 60/$TimespanPerSplit.TotalMinutes
Now, since Powershell is object-based, we proceed to the set-up of the array that will contain split information to print on the pace-band.

For each split we are going to show the three basic information each runner needs:
  1. Split number
  2. Target split time
  3. Cumulative time
Since we are printing the data on the wrist band as a string, we can take advange of the -f operator to format data in a readable way while running.

Let's see that:

# Creating an empty array for storing full splits
$Splits = @()
 
# Populating the array with split and cumulate time association
1..$FullSplits | % {
    $CumulateTime = $TimespanPerSplit.TotalSeconds * $_
    $CumulateTimeSpan = New-TimeSpan -Seconds $CumulateTime
    $Splits += "{0:D2} {1} {2:D2}h{3:D2}'{4:D2}''" -f $_,$Unit,$CumulateTimeSpan.Hours,$CumulateTimeSpan.Minutes,$CumulateTimeSpan.Seconds
    }
Notice the use of :D2 to tell -f to pad hours, minutes and seconds with a zero in case they have one digit only, so that 0h25'7'' will appear as 00h25'07''. Useful.

In case there is an additional incomplete split, like in a half marathon where the last split is 97.5 meters long, we repeat the step once more, so to complete the array. Thanks to the if(){} condition, this step will be skipped for races like a 10k or a 20k, because $FullSpits and $Distance will have equal value.

if($Distance -gt $FullSplits)
    {
    $CumulateTimeFinal = $TimespanPerSplit.TotalSeconds * $Distance
    $CumulateTimeFinalSpan = New-TimeSpan -Seconds $CumulateTimeFinal
    $Splits += "{0:N2} {1} {2:D2}h{3:D2}'{4:D2}''" -f $LastIncompleteSplit,$Unit,$CumulateTimeFinalSpan.Hours,$CumulateTimeFinalSpan.Minutes,$CumulateTimeFinalSpan.Seconds
    }
In the next part of the Get-Pace function we are going to generate one text header showing the average target pace:

$Header1 = "{0}'{1}''/{2}" -f $TimespanPerSplit.Minutes,$TimespanPerSplit.Seconds,$Unit
... and one with the average speed: in kmh if we specified 'km' as measure unit, in mph if we chose 'miles'. Notice here the use of Switch(){} statement, which replaces the use of multiple sequential if(){} conditions.

Switch ($Unit)
    {
    "km" {$suffix = 'kmh'}
    "mile" {$suffix = 'mph'}
    }
$Header2 = "$([System.Math]::Round($Speed,2))$suffix"
We continue setting up a side text containing the name and the date of the race:
$SideText = $RaceName + ' ' + $RaceDate
Then we for sure need a footer to encourage the runner to stick to the target pace. Here it comes:
$Footer = "Go $RunnerName!!!"
That was the easy part of the function.

Today we want to push our Powershell a little farther and see if we are capable of outputting the $Array object as well as $Header1, $Header2, $SideText and $Footer to a printable image.

First step is to load the assembly:
Add-Type -AssemblyName System.Drawing
The second step is to set the path and name of the image file:
$FileName = "$home\pace-band.Png"
The third step is to set the image height and width, trying to get something printable. This is very empiric, since the the resulting image size in centimeters will depend upon your DPI.... but this is another story and out of scope here, so take these values as granted but modify them if they don't work for you:
$PngHeight = 20 * 38
$PngWidth = 5 * 38
$Png = New-Object System.Drawing.Bitmap $PngWidth,$PngHeight
Now let's choose the font type and size for our wrist-band:
$FontSmall = New-Object System.Drawing.Font Consolas,8
$FontBig = New-Object System.Drawing.Font Consolas,14
$FontSideText = New-Object System.Drawing.Font Consolas,14
Next is to choose both foreground and background colors:
$BrushBgColor = [System.Drawing.Brushes]::LightYellow
$BrushFgColor = [System.Drawing.Brushes]::DarkBlue
Time to setup the background image:
# Creating a graphic from the image and putting some colors
$Graphics = [System.Drawing.Graphics]::FromImage($Png)
$Graphics.FillRectangle($BrushBgColor,0,0,$Png.Width,$Png.Height)
Once you are here, you have to print on the pace-band each split as a single line and move down on the paper. I did the math, so they could be wrong, but normally this worked well on my screen:
# Adding one line to the pace-band for each split
0..$FullSplits | % {
    $splitlength = $Graphics.MeasureString($Splits[$_],$FontSmall)
    $Graphics.DrawString($Splits[$_],$FontSmall,$BrushFgColor,($PngWidth-$splitlength.Width)/2,$PngHeight / ($Distance) + ($splitlength.Height*$_) + 75) 
    }
Let's now add the two headers as well as the footer text. Notice the use of MeasureString to measure string length, in order to center text on the pace-band with the help of the following formula: (width of the image - string length) / 2. Notice also how I assigned the DirectionVertical flag to my side text, so that it appears vertically centered. Resulting code is here:
# Measuring headers, sidetext and footer in order to center them on pace-band
$Header1Length = $Graphics.MeasureString($Header1,$FontBig)
$Header2Length = $Graphics.MeasureString($Header2,$FontBig)
$FooterLength = $Graphics.MeasureString($Footer.ToUpper(),$FontBig)
$SideTextLength = $Graphics.MeasureString($SideText,$FontSideText)

# Writing headers, footer and sidetext
$Graphics.DrawString($Header1,$FontBig,$BrushFgColor,($PngWidth-$Header1Length.Width)/2,25)
$Graphics.DrawString($Header2,$FontBig,$BrushFgColor,($PngWidth-$Header2Length.Width)/2,50) 
$Graphics.DrawString($Footer.ToUpper(),$FontBig,$BrushFgColor,($PngWidth-$FooterLength.Width)/2,($SplitLength.Height * $Distance) + 125)
$DrawFormat = New-Object System.Drawing.StringFormat("DirectionVertical");
$Graphics.DrawString($SideText.ToUpper(),$FontSideText,$BrushFgColor,$PngWidth-($SideTextLength.Height*1.5),($PngHeight-$SideTextLength.Width)/2,$DrawFormat)
Last two steps, dispose the object that consume memory, such as fonts and graphics (this is robust programming, guys!) and save:
$Graphics.Dispose()
$Png.Save($FileName)
You could now call the function with the following parameters:
Get-Pace -RunnerName Carlo -RaceName "Marathon" -RaceDate "01/XI/2014" -Distance "42.195" -Time "2:59:0" -Unit "km" -Open
and get a printable image like this:

Print it, cut it, cover both front and back with sticky tape, place the band on your wrist and then tape the top edge over the bottom tab.

Of course you could improve this function by adding parameters like the destination file, or the colors to use for your pace-band. I leave that to you on purpose, so you can increase your Powershell skills, but feel free to ask questions as soon as they arise. Enjoy and share!

7 comments:

  1. Great article. But what if I want to print the pace-band from the script? Thanks

    ReplyDelete
    Replies
    1. Nothing easier, just add a -Print parameter which does this:

      Start-Process -FilePath mspaint -ArgumentList '"P $FileName"

      and it's directly sent to your default printer.

      HTH
      Carlo

      Delete
    2. Sorry, it's:

      Start-Process -FilePath mspaint -ArgumentList '"/P $FileName"

      Delete
  2. Brilliant, finally a Powershell post which is not on DSC!

    ReplyDelete
  3. Yes, Daniel, but DSC post won't be late on this blog!

    ReplyDelete
  4. Interesting read on Powershell, kudos!

    ReplyDelete

Related Posts Plugin for WordPress, Blogger...