I have recently started doing some outdoors bodyweight workouts. I also want to start running again, but I’m recovering from a minor knee injury until the start of next month.
Tonight I decided to put together a weekly schedule to start following next month. The first pen and paper versions were fine, but I wasn’t completely satisfied. The next logical step was to write a quick program to see what possible plans I was missing.
The schedule must satisfy a few constraints, but the core of it is that I should do, every week, on one axis, one short run (high-intensity) and one long run (long distance), and, on the other axis, have two pull days (as in pull-ups), two push days (as in push-ups), and two leg days (as in squats).
Finding a weekly workout that satisfies certain constraints is an answer-set-programming kind of problem, best solved by some kind of logic programming. Rather than turning to Prolog or Clingo, I decided to just stick to Haskell and use the logic-programming monad from logict!
1 A workout planner in 100 lines of Haskell
What follows is mostly just the demonstration of using logict
applied to this
particular problem. I believe the weeklySchedule
function can be easily understood
in general, even by anyone unfamiliar with Haskell and/or logic programming –
and that’s the meat of this short post and program.
Note that the program is a
cabal shell script which
can be run by executing the script file (as in ./ScheduleExercise
, as long as
cabal
is in path). It is standalone, and exactly 100 lines (with comments,
shebangs and everything). Feel free to try and modify it!
#!/usr/bin/env cabal
{- cabal:
build-depends: base, logict
-}
import Control.Applicative
import Control.Monad
import Control.Monad.Logic
import Data.List
import Data.Maybe
= ["Push day", "Pull day", "Leg day", "No workout"]
workout = ["Long run", "Short run", "No run"]
running = ["Seg.", "Ter.", "Qua.", "Qui.", "Sex.", "Sab.", "Dom."]
weekdays
weeklySchedule :: Logic [(String, [String])]
= do
weeklySchedule -- For every weekday, pick an element from `workout` and one from `running`
-- that satisfy the "nested" conditions
<- forM weekdays $ \d -> do
p <- choose workout
w <- choose running
r
-- No running on leg day
== "Leg day" ==> r == "No run"
w
-- Short intervals run is after an outdoor pull/push workout
== "Short run" ==> w /= "No workout"
r
-- Workout on Monday outdoors always, not legs
== "Seg." ==> w /= "No workout"
d == "Seg." ==> w /= "Leg day"
d
-- Pull day during the week?
== "Pull day" ==> (d /= "Sab." && d /= "Dom.")
w
pure [w,r]
-- Now, pick the set `p` of (weekdays X exercises) that satisfy the following conditions:
-- One long run, one short run
1 "Long run" p
exactly 1 "Short run" p
exactly
-- Two push, two pull, two leg
2 "Push day" p
exactly 2 "Pull day" p
exactly 2 "Leg day" p
exactly
-- Long run on weekend
"Long run" "Sab." p <|> onDay "Long run" "Dom." p
onDay
-- Run spaced out at least 2 days
2 "Short run" "Long run" p
daysBetween 2 "Long run" "Short run" p
daysBetween
-- Space out workouts at least 2 days
"Push day" 2 p
spacedOut "Pull day" 2 p
spacedOut "Leg day" 2 p
spacedOut
-- No leg day before short run
1 "Leg day" "Short run" p
daysBetween -- No leg day before a long run
1 "Leg day" "Long run" p
daysBetween
-- At least one of the runs without a leg day after please
1 "Short run" "Leg day" p <|> daysBetween 1 "Long run" "Leg day" p
daysBetween
return (zip weekdays p)
--------------------------------------------------------------------------------
-- Logic utils
= foldr ((<|>) . pure) empty
choose
= guard (length (filter (s `elem`) p) == n)
exactly n s p
= guard (s `elem` getDay d p) where
onDay s d p = p !! fromMaybe undefined (elemIndex s weekdays)
getDay s p
-- space out as at least n days
= guard (all (>n) dists) where
spacedOut a n p = zipWith (-) (drop 1 is) is
dists = findIndices (a `elem`) (take 14 $ cycle p {- cycle week around -})
is
=
daysBetween n a b p take 14 (cycle p) `zip` [1..]) $ \(x,i) -> do
forM_ (`elem` x ==> all (b `notElem`) (take n $ drop i (take 14 (cycle p)))
a
==>) a b = guard (not a || b)
(infixr 0 ==>
--------------------------------------------------------------------------------
-- Main
= mapM (\(d, ls) -> putStrLn (d ++ " " ++ intercalate ", " ls))
printSched
= do let r = observeAll weeklySchedule
main mapM (const (putStrLn "") <=< printSched) r
The heavy lifting is done by the logict
package.
It allows us to consider alternative values as solutions to logic statements.
The possible alternatives are separated by <|>
, and observeAll
returns
a list with all valid alternatives. A trivial example:
= pure "a" <|> pure "b" <|> pure "c"
x print (observeAll x)
Result: ["a", "b", "c"]
If the alternative is empty
, it is not an answer and therefore not included in the result.
The guard
combinator takes a boolean and returns empty
if it is false,
therefore constraining the solution.
= pure "a" <|> empty <|> pure "c"
x print (observeAll x)
Result: ["a", "c"]
Finally, we can use the monadic do
notation to combine sets of alternatives.
Put together with guard
, we can write slightly more interesting programs:
= do
x <- pure "a" <|> pure "b" <|> pure "c"
y <- pure "a" <|> pure "b" <|> pure "c"
z /= z)
guard (y return (y,z)
print (observeAll x)
Result: [("a","b"),("a","c"),("b","a"),("b","c"),("c","a"),("c","b")]
All in all, the body of weeklySchedule
uses just these principles plus a few
domain-specific combinators I wrote in the Logic utils
section of the code
(which themselves use also only these principles from logict
, plus some
laziness-coolness). The program entry point (main
) prints out the
schedule.
And by the way, these are the workout schedules satisfying all those constraints:
Seg. Pull day, No run
Ter. Push day, Short run
Qua. No workout, No run
Qui. Leg day, No run
Sex. Pull day, No run
Sab. Push day, Long run
Dom. Leg day, No run
Seg. Pull day, No run
Ter. Leg day, No run
Qua. Push day, No run
Qui. Pull day, Short run
Sex. Leg day, No run
Sab. Push day, No run
Dom. No workout, Long run
Seg. Pull day, No run
Ter. Leg day, No run
Qua. Push day, No run
Qui. Pull day, Short run
Sex. Leg day, No run
Sab. No workout, No run Dom. Push day, Long run