Running Coach (#82)

by Benjohn Barnes

I've started to jog with my girlfriend. It's hell. We're following a "programme" at this web site:

The Couch-to-5K Running Plan

The aim is to get you from being able to alternate hobbling and brisk walking, to being able to jog for 20 minutes solidly. Over eight weeks you exercise for twenty minutes, three times a week. Over the eight weeks, the ratio of jog to walk steadily increases, and the jogs get longer, while the walks become shorter.

I was explaining to a friend that it's incredibly difficult for me to look at a stop watch and work out in my head if we're supposed to be jogging or walking, how many more jogs we've got to do, and when I can stop and rest. He suggested: 'why not tape yourself giving prompts about when to start and stop'. A brilliant plan. 'Even better, record it on to your phone'. Genius! Except I'm the kind of person who's lazy enough to spend eight times as long writing a program to try to do this for me.

So, the quiz is:

Write a program to create the tracks for each of the eight weeks. Make it give helpful and enthusiastic advice like "you've got to run for another minute / 30 seconds / 15 seconds ...", "walk now for two minutes, you've got three jogs left", "you're on jog 2 of 6", or "well done, that's your last jog. Don't forget to cool down and stretch!"

I just used my Mac's speech synth, and parked my phone near to the speaker on record, in a quiet room (except for the planes every minute heading down to Heathrow). There'd be "bonus points" for actually creating the MP3 directly. Of course, you don't really need to get the computer to speak. It could just print out the messages at the appropriate time.


Quiz Summary

This quiz turns out to be a little bit of work, if you want to get some decent feedback to the user. Adam Shelly hammered out a reasonably complete solution though, so let's have a look at it:

ruby
$CheerThreshold = 6 #decrease to get more random encouragement
$LongThreshold = 120 #minimum time to be considered a "long" run

class Phase
attr_reader :action, :seconds
def initialize action, time
@action = action.downcase
@seconds = time.to_i
end
end

# ...

We can see some setup work here for variables that allow users to tweak the output. We also have the trivial Phase class definition, which is just a data class for linking actions and times.

Here's the main event loop:

ruby
class Coach
def initialize filename
File.open(filename) {|f|
@rawdata = f.read.split("\n")
}
@duration = 0
@runs = @longs = @walks = 0
@encouragometer = 0
@step = [30,15,10,5,5]
end

def coach
build_timeline
say summarize(2)
say start_prompt
@time = Time.now
@target_time = @time
while (phase = @phases.shift)
update_summary phase
narrate_phase phase
if @phases.size > 0
say transition(@phases[0].action)
say summarize(rand(2))
end
end
say finish_line
end

# ...

There's nothing too interesting about initialize() which is just assigning defaults to the instance variables. Have a look at the coach() method though. This is the process the application runs through, and I really like how well it reads. It builds up the timeline of events, hits user with a summary and starting prompt, then launches into Phase processing. Each Phase is narrated to the user, and then the code transitions naturally to the next Phase. Finally the code sends the finish line message to indicate a successful workout.

Let's see what narrating a phase involves:

ruby
# ...

def narrate_phase phase
say what_to_do_for(phase)
@target_time += phase.seconds
delta = (@target_time - Time.now).to_i
stepidx = 0
while (delta > 0)
stepidx+=1 if delta < @step[stepidx]+1
wait_time = delta % @step[stepidx]
wait_time += @step[stepidx] if wait_time <= 0
wait(wait_time)
delta = (@target_time - Time.now).to_i
encourage_maybe
say whats_left(phase.action,delta) if delta > 0
end
end

# ...

Obviously, this method is mostly about time management. It breaks a Phase down into smaller chunks, so that it can provide encouragement frequently and inform the user of what is left to be done.

Note the clever output messages here again that read so naturally: what_to_do_for(), encourage_maybe(), and whats_left().

ruby
# ...

def update_summary phase
@duration -= phase.seconds
@runs -= 1 if phase.action == 'run'
@longs -= 1 if phase.action == 'run' and phase.seconds >= $LongThreshold
@walks -= 1 if phase.action == 'walk'
end

def build_timeline
@phases = @rawdata.map {|command|
p = Phase.new(*command.split)
@duration += p.seconds
@runs += 1 if p.action == 'run'
@longs += 1 if p.action == 'run' and p.seconds >= $LongThreshold
@walks += 1 if p.action == 'walk'
p
}
end

# ...

These two methods are quite similar save that one adds and the other subtracts. First, build_timeline() constructs the Phase objects from the import file. As it goes through, it counts things like the total number of walks and runs a person needs to complete. Then, update_summary() runs inside each Phase of the event loop ticking off the walks and runs the user has completed.

Here's the say() method that would eventually need to be replaced with speech programming:

ruby
# ...

def say s
puts s
#todo: replace with speech
end

# ...

Now, take a look at this:

ruby
# ...

def wait n
if $DEBUG
puts "...waiting #{n} seconds..."
@target_time -= n
else
$stdout.flush
sleep(n)
end
end

# ...

This is obviously the delay method and it mainly just calls sleep(). However, I like how it can be set to just explain what the pause would have been, in $DEBUG mode. That makes testing the application much more pleasant.

Two more helper methods:

ruby
# ...

def encourage_maybe
@encouragometer += rand(3)
if (@encouragometer > $CheerThreshold)
say cheer
@encouragometer = 0
end
end

def timesay secs
secs = secs.to_i
s = ""
if secs > 60
min = secs/60
secs -= min*60
s += "#{min} minute"
s += 's' if min > 1
s += ' and ' if secs > 0
end
if secs > 0
s += "#{secs} second"
s += 's' if secs > 1
end
s
end

# ...

There's the definition for the encourage_maybe() call I pointed out earlier. It just randomly decides if a cheer should be emitted.

The other method, timesay(), is a helper like we are use to in Rails. It just humanizes the output of some number of seconds by breaking it into minutes and seconds.

Next the code has several output methods, of which I'll just show a couple:

ruby
# ...

# All the phrases should be below this line, not mixed up in the logic
def what_to_do_for phase
s = "#{phase.action} for #{timesay(phase.seconds)} \n"
s += "You are almost done" if @phases.size == 1
s
end
def whats_left act, time
timestr = timesay(time)
s = [
"You have #{timestr} more to #{act}",
"#{act} for #{timestr} more",
"only #{timestr} left of #{act}ing",
"You have #{timestr} more to #{act}",
"#{timestr} left in this phase",
"There are #{timestr} until the next activity"
]
s[rand(s.size)]
end

# ... several more output routines not shown ...
end

# ...

You can see that these methods just use simple conditional logic or random picks to vary the program's output. With several of these methods, the end result is a fairly good mix of prompts for the user.

Here's the last line that turns it into a solution:

ruby
# ...

Coach.new(ARGV[0]||"week3.txt").coach

My thanks to those who stole the time from their busy running schedules to code up a solution. These scripts should have us all in shape by RubyConf!

Tomorrow, we will try an extremely common computerism, but see if we can handle it a little better than the usual treatment...