A domain-specific language for … interval workouts
This is something of a cross-over personal/technology post, hopefully interesting nonetheless. Perhaps a something-for-everyone post … or a nothing-for-anyone post.
I’m a fairly serious amateur cyclist. I raced for one season when I was in college (1992!), but I totally burned out and quit riding, almost entirely, until the 2007 season. I was helped back into riding through what I call “motivation through financial commitment” – I bought a $5000 (at the time) Cervelo Soloist Carbon. But my biggest motivation comes from the group I ride with – Crack o’ Dawn – that meets every weekday at 5:45 for a 25-30 mile ride and does weekend rides of 50-100 miles; the group convenes within 2 miles of my house in Newton, MA. I rode 5000ish miles in 2007 and 6500ish in 2008 (the CoD record this year was set by Michael Mercier, at 14105.69 miles, and many CoDers break 10K every year).
Now the bad news. It’s winter here in the Boston area. It’s a snowy winter. Unlike some of my CoD mates, I’m not all that fond of the cold or rolling the dice with icy roads (I had a crash late in the 2007 season, broken collarbone). I’m happy to ride if it’s above freezing, even if wet, and down to 20ish if it’s dry. But lately this has left precious little opportunity to ride outside. I’m coping by riding spin classes at Spynergy, where follow CoDer and good friend Andy Schiller is an awesome instructor. I also have a CycleOps Pro 300PT indoor trainer, which I ride 4-6 times a week. The coolest thing about about the trainer is that it has an integrated power meter, so you can track your wattage while you ride. There’s a lot of science behind training based on power; the Peaks Coaching Group is pretty well known, and their principal, Hunter Allen, is co-author of book on the topic: Training and Racing with a Power Meter (Allen and Coggan). This provides an excellent opportunity for me to integrate my bike self and my geek self into one well-trained, intellectually satisfied unit.
Here’s how: unlike some indoor training systems, like the CompuTrainer, which many of my cohorts swear by, the CycleOps doesn’t provide any facilities for guiding a workout. It just shows you your heart rate, cadence and power output in watts. I filled this gap with a small Windows Forms application (hopefully soon to be ported to WPF) that has a very simple UI (yes, some would say go so far as “lame”, and they’d be right, but that’s not the point):
This says: ride at 200 watts for 4 minutes and 44 seconds more; you’ve been riding for 19 seconds and have 54:44 to go. The pull down lets you select different workouts. You can skip over an interval (wimp) and pause/resume (say, to change the TV channel). Easy, to the point. But, there’s some underlying extra fun. When I first wrote this, I basically hardcoded workouts in a config file. They looked like this:
180 00:10:00
220 00:10:00
250 00:05:00
280 00:02:00
150 00:03:00
220 00:10:00
250 00:05:00
280 00:02:00
150 00:03:00
Again, simple and to the point. But repetitive and not very flexible. The opportunity expands…
I’ve been working with domain-specific languages in various forms for a long time, including with Microsoft’s DSL Tools, Boo and Oslo. As I’ve mentioned before, my good friend Harry Pierson is a Product Manager with the DLR group at Microsoft, so I’ve been playing with IronRuby and IronPython for a while (Harry gave an excellent talk at a brown bag session at VistaPrint on the DLR and IronPython a couple of months ago). I had read a few articles about implementing DSLs in Ruby, so … <light bulb/> Construct a Ruby DSL to express interval workouts and dispatch out from the WinForms app to the IronRuby runtime to fetch workout definitions.
workout "W - intervals" do
interval.target(0).time('00:01:00')
interval.target(180).time('00:05:00')
repeat 4 do
interval.target(220).time('00:10:00')
interval.target(280).time('00:02:00')
interval.target(150).time('00:03:00')
end
end
This defines a workout called “W – intervals”, made up of a 1 minute 0-power “click start, get on the bike and clipped in” step, 5 minutes at 180 watts, then 4 repeats of 10 minutes at 220, 2 at 280 (what I believe to be my functional threshold power) and 3 at 150. Aside: I’m not proposing this is a good workout – I’m studying the Allen and Coggan book to figure out how to actually use power properly in training. Again, not the point.
Simple enough, and the “repeat” construct achieves a lot of the intended benefit of reducing repetition. But there’s so much more potential. As I was reading the training book today, I came up with this one:
ftp = 280
work = 0.9*ftp
warmup = 0.68*ftp
rest = warmup
workout "L4 - Ride A" do
interval.target(0).time('00:01:00')
interval.target(warmup).time('00:05:00')
interval.target(ftp).time('00:02:30')
repeat 2 do
interval.target(work).time('00:13:00')
interval.target(rest).time('00:02:00')
end
repeat 6 do
interval.target(work).time('00:01:00').message("high cadence")
interval.target(rest).time('00:02:00')
end
interval.target(rest).time('00:05:00')
end
This is an adaptation (shortening to 1 hour) of a “typical level 4 threshold” workout from Coggan and Allen, on page 83.
The power of using an embedded DSL is clear: there are very few domain-specific implementation constructs required, making the DSL specification extremely short. All that was required was the definition of the workout and interval classes and the simple “repeat” construct, and that’s really only a personal preference (6.times would have done just as well).
The core of the Ruby DSL looks like this:
$intervals = []
class RInterval
def initialize()
@interval = Interval.new
$intervals.push self
end
def target(t)
@interval.Target = t
self
end
// message and time look the same as target
def Interval
@interval
end
end
def repeat(count, &block)
count.times do
yield
end
end
def workout(name, &block)
yield
intervals = System::Collections::ArrayList.new
$intervals.each {|interval| intervals.Add interval.Interval}
$intervals.clear
workouts.Add name, intervals
end
def interval
RInterval.new
end
On the .NET side, there’s a simple Interval class:
public class Interval
{
public TimeSpan Time { get; set; }
public int Target { get; set; }
public string Message { get; set; }
}
At runtime, the .NET application instantiates the IronRuby engine, sets up a Dictionary<string, ArrayList> in the engine’s ScriptScope (no interoperable generics right now
) and loads and executes the DSL spec and the DSL instance:
_scope = _engine.CreateScope();
_scope.SetVariable("workouts", new Dictionary<string, ArrayList>());
// ...
_engine.Execute(code, _scope); // code is the DSL definition + the DSL instance
object ret = _scope.GetVariable(returnVar);
And that’s it. The UI code is dead simple, and again, not the point. I’ve obviously got some more to learn about doing DSLs in Ruby, but this has proven to me the utility of the approach. And it helps with the winter riding blues.
Update: Here’s the code from Jim’s comment, which I couldn’t make show up in his comment:
;; User setting
(setq *ftp_limit 280)
;; Workout routine
(progn
(wait 1 "click start, get on the bike and clipped in")
(warmup 5)
(ftp 2.5)
(repeat 2
(work 13)
(relax 2))
(repeat 6
(work 1 "high cadence")
(relax 2))
(relax 5))
;; Here’s the DSL implementation
(defmacro interval (pace duration &optional message)
`(progn
(format t "~&~6a (~2$) for ~5 minutes" (car ,pace) (cadr ,pace) ,duration)
(and ,message (format t " *** ~a ***" ,message))))
(defmacro proportional-interval (name proportion duration &optional message)
`(interval (list ',name (* ,proportion *ftp_limit*)) ,duration ,message))
(defmacro deffactor (name factor)
`(defmacro ,name (duration &rest args)
`(proportional-interval ,',name ,',factor ,duration ,@args)))
(defmacro repeat (times &body body)
(let ((x (gensym)))
`(dotimes (,x ,times)
,@body)))
;; You can argue about whether the following is in the DSL designer
;; land, or in user land. Since it seems effort ratios would be
;; uniform across many workouts, I’ve put it here (though users could
;; override for their own workout easily enough.)
(deffactor ftp 1)
(deffactor work 0.9)
(deffactor relax 0.68 )
(deffactor warmup 0.68 )
(deffactor wait 0)
(defvar *ftp_limit* 280)
Update: I couldn’t leave this alone, after Jim’s elegance put me to shame. Here’s the same workout I had before in my new DSL:
workout "L4 - Ride A" do setup 1 warmup 5 work 2 2.times do work 13 rest 2 end 6.times do work 1, "high cadence" rest 2 end rest 5 end
Not quite ready to share the DSL definition yet – it’s still a bit ugly, but improving. I’m also trying to smooth out the IronRuby integration so that I can test the DSL independent of a .NET runtime.
Update: Here’s the new DSL definition. Much much cleaner. Thanks, Jim, for driving me to get this right.
$ftp = 280
$effort = {"setup" => 0, "ftp" => $ftp, "easy" => 0.8*$ftp, "work" => 0.9*$ftp, "warmup" => 0.68*$ftp, "rest" => 0.68*$ftp}
class WorkoutDSL
def method_missing(sym, *args)
interval = {:target => $effort["#{sym}"], :time => args[0], :message => args[1]}
@workouts[@name].push interval
end
def workout(name, &block)
@name = name
@workouts[@name] = []
yield
end
def load(filename)
@workouts = {}
instance_eval(File.read(filename), filename)
@workouts
end
end
WorkoutDSL.new.load("intervals.dsl")
When I think of the utility of a DSL, a significant measure is succinctness. (Unfortunately, the example above looks like it’s calling pre-packaged subroutines in an API rather than a true DSL.) If you ignore the parens, the following feels a lot more DSL-y to me:
[Jeromy: I couldn't get Jim's code to show up here, so I embedded in an update to the post.]
Yep, Lisp is a really nice language for building DSLs, based on what I’ve read/seen. But now I’ve got some nice ideas to improve my version – there are a lot of potential improvements… stay tuned.