View raw

1 module Stimulus = struct 2 type extension = Forced_reps | Negatives | Rest_pause | Static_hold 3 4 type outcome = 5 | Positive_failure 6 | Beyond_failure of extension * extension list 7 8 let extensions_of_outcome = function 9 | Positive_failure -> [] 10 | Beyond_failure (first, rest) -> first :: rest 11 12 let pp_extension ppf e = 13 Format.pp_print_string ppf 14 (match e with 15 | Forced_reps -> "forced reps" 16 | Negatives -> "negatives" 17 | Rest_pause -> "rest-pause" 18 | Static_hold -> "static hold") 19 20 module Effort = struct 21 type t = { 22 exercise : Exercise.t; 23 load : float; 24 reps : int; 25 outcome : outcome; 26 } 27 28 let make ~exercise ~load ~reps ~outcome = { exercise; load; reps; outcome } 29 let exercise t = t.exercise 30 let load t = t.load 31 let reps t = t.reps 32 let outcome t = t.outcome 33 let extensions t = extensions_of_outcome t.outcome 34 35 let pp ppf t = 36 Format.fprintf ppf "%a %g kg x %d" Exercise.pp t.exercise t.load t.reps; 37 match extensions t with 38 | [] -> () 39 | es -> 40 Format.fprintf ppf " (%a)" 41 (Format.pp_print_list 42 ~pp_sep:(fun ppf () -> Format.pp_print_string ppf ", ") 43 pp_extension) 44 es 45 end 46 47 type delivery = 48 | Single of Effort.t 49 | Pair of { first : Effort.t; second : Effort.t } 50 51 type t = { delivery : delivery } 52 53 let make delivery = { delivery } 54 let delivery t = t.delivery 55 56 let efforts t = 57 match t.delivery with 58 | Single effort -> [ effort ] 59 | Pair { first; second } -> [ first; second ] 60 61 let exercises t = List.map Effort.exercise (efforts t) 62 let extensions t = List.concat_map Effort.extensions (efforts t) 63 let is_extended t = extensions t <> [] 64 65 let pp ppf t = 66 match t.delivery with 67 | Single effort -> Effort.pp ppf effort 68 | Pair { first; second } -> 69 Format.fprintf ppf "%a then %a" Effort.pp first Effort.pp second 70 end 71 72 module Feedback = struct 73 type level = Very_poor | Poor | Fair | Good | Very_good 74 75 let level_to_score = function 76 | Very_poor -> 1 77 | Poor -> 2 78 | Fair -> 3 79 | Good -> 4 80 | Very_good -> 5 81 82 let level_of_score = function 83 | 1 -> Some Very_poor 84 | 2 -> Some Poor 85 | 3 -> Some Fair 86 | 4 -> Some Good 87 | 5 -> Some Very_good 88 | _ -> None 89 90 type signal = 91 | Sleep of level 92 | Appetite of level 93 | Readiness of level 94 | Motivation of level 95 | Difficulty of level 96 | Pain 97 | Injury 98 | Preparation_insufficient 99 100 type t = { reported_at : Recovery.timestamp; signals : signal list } 101 type error = Duplicate_signal of signal 102 103 exception Invalid of error 104 105 let same_category left right = 106 match (left, right) with 107 | Sleep _, Sleep _ 108 | Appetite _, Appetite _ 109 | Readiness _, Readiness _ 110 | Motivation _, Motivation _ 111 | Difficulty _, Difficulty _ 112 | Pain, Pain 113 | Injury, Injury 114 | Preparation_insufficient, Preparation_insufficient -> 115 true 116 | _ -> false 117 118 let make ~reported_at signals = 119 let rec validate seen = function 120 | [] -> { reported_at; signals } 121 | signal :: rest -> 122 if List.exists (same_category signal) seen then 123 raise (Invalid (Duplicate_signal signal)) 124 else validate (signal :: seen) rest 125 in 126 validate [] signals 127 128 let reported_at t = t.reported_at 129 let signals t = t.signals 130 end 131 132 module Workout = struct 133 type shape = As_single | As_pair 134 type completeness = Complete | Incomplete 135 136 type error = 137 | Not_prescribed of Exercise.id 138 | Delivery_mismatch of { 139 exercise : Exercise.id; 140 prescribed : shape; 141 logged : shape; 142 } 143 | No_such_slot of int 144 145 exception Invalid of error 146 147 type t = { 148 prescription : Prescription.Workout.t; 149 clearance : Recovery.clearance; 150 started_at : Recovery.timestamp; 151 ended_at : Recovery.timestamp option; 152 performed : (int * Stimulus.t) list; 153 } 154 155 let pp_shape ppf = function 156 | As_single -> Format.pp_print_string ppf "a single set" 157 | As_pair -> Format.pp_print_string ppf "a pair" 158 159 let pp_error ppf = function 160 | Not_prescribed id -> 161 Format.fprintf ppf "%s is not prescribed for this workout" 162 (id :> string) 163 | Delivery_mismatch { exercise; prescribed; logged } -> 164 Format.fprintf ppf "%s is prescribed as %a but was logged as %a" 165 (exercise :> string) 166 pp_shape prescribed pp_shape logged 167 | No_such_slot slot -> 168 Format.fprintf ppf "slot %d is not part of this workout" slot 169 170 let start prescription ~clearance ~started_at = 171 { prescription; clearance; started_at; ended_at = None; performed = [] } 172 173 let prescription t = t.prescription 174 let clearance t = t.clearance 175 let started_at t = t.started_at 176 let ended_at t = t.ended_at 177 let is_finished t = Option.is_some t.ended_at 178 let stimuli t = List.map snd (List.rev t.performed) 179 let performed t = List.rev t.performed 180 181 let duration t = 182 Option.map 183 (fun ended -> Recovery.elapsed ~since:t.started_at ~now:ended) 184 t.ended_at 185 186 let prescribed_shape p = 187 match Prescription.Stimulus.delivery p with 188 | Prescription.Stimulus.Single _ -> As_single 189 | Prescription.Stimulus.Pre_exhaust _ -> As_pair 190 191 let logged_shape s = 192 match Stimulus.delivery s with 193 | Stimulus.Single _ -> As_single 194 | Stimulus.Pair _ -> As_pair 195 196 let fills ~prescribed ~logged p = 197 Exercise.equal prescribed logged 198 || List.exists (Exercise.equal logged) 199 (Prescription.Stimulus.allowed_substitutes p) 200 && Exercise.may_substitute ~original:prescribed ~candidate:logged 201 202 let mentions p s = 203 List.for_all 204 (fun e -> Prescription.Stimulus.permits p e) 205 (Stimulus.exercises s) 206 207 let conforms p s = 208 let logged = Stimulus.exercises s in 209 let prescribed = Prescription.Stimulus.exercises p in 210 List.length logged = List.length prescribed 211 && List.for_all2 212 (fun prescribed logged -> fills ~prescribed ~logged p) 213 prescribed logged 214 215 let indexed t = 216 List.mapi (fun i p -> (i, p)) (Prescription.Workout.stimuli t.prescription) 217 218 let answered t = List.map fst t.performed 219 220 let outstanding t = 221 indexed t |> List.filter (fun (i, _) -> not (List.mem i (answered t))) 222 223 let unperformed t = List.map snd (outstanding t) 224 let completeness t = if outstanding t = [] then Complete else Incomplete 225 226 (* Distinct answered slots, so recorded extra volume never inflates the 227 count past the prescription length. *) 228 let filled_slots t = List.length (List.sort_uniq Int.compare (answered t)) 229 230 let add_stimulus t s = 231 let candidates = List.filter (fun (_, p) -> mentions p s) (indexed t) in 232 let matching = List.filter (fun (_, p) -> conforms p s) candidates in 233 let leading = Exercise.id (List.hd (Stimulus.exercises s)) in 234 match (candidates, matching) with 235 | [], _ -> raise (Invalid (Not_prescribed leading)) 236 | (_, p) :: _, [] -> 237 raise 238 (Invalid 239 (Delivery_mismatch 240 { 241 exercise = leading; 242 prescribed = prescribed_shape p; 243 logged = logged_shape s; 244 })) 245 | _, matching -> 246 let unanswered = 247 List.filter (fun (i, _) -> not (List.mem i (answered t))) matching 248 in 249 let i, _ = 250 match unanswered with chosen :: _ -> chosen | [] -> List.hd matching 251 in 252 { t with performed = (i, s) :: t.performed } 253 254 (* Validate a stimulus against one named slot, raising as [add_stimulus] 255 does. Shared by the slot-targeted operations. *) 256 let prescription_at t slot = 257 match List.nth_opt (Prescription.Workout.stimuli t.prescription) slot with 258 | Some p -> p 259 | None -> raise (Invalid (No_such_slot slot)) 260 261 let check_against p s = 262 if not (mentions p s) then 263 raise 264 (Invalid (Not_prescribed (Exercise.id (List.hd (Stimulus.exercises s))))) 265 else if not (conforms p s) then 266 raise 267 (Invalid 268 (Delivery_mismatch 269 { 270 exercise = Exercise.id (List.hd (Stimulus.exercises s)); 271 prescribed = prescribed_shape p; 272 logged = logged_shape s; 273 })) 274 275 let record_at t ~slot s = 276 check_against (prescription_at t slot) s; 277 { t with performed = (slot, s) :: t.performed } 278 279 let replace_stimulus t ~slot s = 280 check_against (prescription_at t slot) s; 281 (* A correction targets the slot; it never adds to the recorded volume. 282 Replace the slot's fill in place, keeping its position in performance 283 order. When the slot was empty, record it as its first fill. *) 284 if List.mem_assoc slot t.performed then 285 let replaced = ref false in 286 let performed = 287 List.map 288 (fun (i, existing) -> 289 if i = slot && not !replaced then begin 290 replaced := true; 291 (i, s) 292 end 293 else (i, existing)) 294 t.performed 295 in 296 { t with performed } 297 else { t with performed = (slot, s) :: t.performed } 298 299 let finish t ~ended_at = 300 match t.ended_at with 301 | None -> { t with ended_at = Some ended_at } 302 | Some _ -> t 303 304 let pp ppf t = 305 Format.fprintf ppf "%a (%d of %d)" Prescription.Workout.pp t.prescription 306 (List.length t.performed) 307 (List.length (Prescription.Workout.stimuli t.prescription)) 308 end 309 310 module Log = struct 311 type t = Workout.t list 312 313 type observation = { 314 exercise : Exercise.t; 315 effort : Stimulus.Effort.t; 316 performed_at : Recovery.timestamp; 317 } 318 319 let empty = [] 320 let add t workout = workout :: t 321 322 let started workout = 323 Recovery.timestamp_to_unix_seconds (Workout.started_at workout) 324 325 (* Sorted on read, so workouts need not be added in order. *) 326 let chronological t = 327 List.sort (fun a b -> Int.compare (started a) (started b)) t 328 329 let workouts t = List.rev (chronological t) 330 331 let last_prescription t = 332 match workouts t with 333 | [] -> None 334 | latest :: _ -> Some (Workout.prescription latest) 335 336 let observations_of workout = 337 Workout.stimuli workout 338 |> List.concat_map Stimulus.efforts 339 |> List.map (fun effort -> 340 { 341 exercise = Stimulus.Effort.exercise effort; 342 effort; 343 performed_at = Workout.started_at workout; 344 }) 345 346 let observations t exercise = 347 chronological t 348 |> List.concat_map observations_of 349 |> List.filter (fun o -> Exercise.equal o.exercise exercise) 350 351 let readiness t ~now ~recommended = 352 let finished = 353 chronological t 354 |> List.filter_map (fun w -> Workout.ended_at w) 355 |> List.rev 356 in 357 match finished with 358 | [] -> Recovery.Ready 359 | last :: _ -> 360 Recovery.evaluate_readiness 361 ~elapsed:(Recovery.elapsed ~since:last ~now) 362 ~recommended 363 364 let pp ppf t = Format.fprintf ppf "%d workouts" (List.length t) 365 end 366