Skip to main content

max / ripgrow

3.8 KB · 105 lines History Blame Raw
1 //! Estimated one-rep-max (e1RM).
2 //!
3 //! Reps-alone formulas (Epley, Brzycki, Lander, Mayhew) all discard the
4 //! effort signal we already collect. ripgrow logs RPE per set, so we can
5 //! do better: convert RPE to "reps in reserve," add it to the reps
6 //! performed to get a hypothetical reps-to-failure, then apply Epley to
7 //! that. This is the Tuchscherer / RTS approach in closed form; it
8 //! approximates the RTS percentage chart within a few percent and needs
9 //! no lookup table.
10 //!
11 //! e1RM = load * (1 + (reps + rir) / EPLEY_DIVISOR)
12 //!
13 //! All the constants and the RIR mapping live in [`crate::heuristics`]
14 //! so they're auditable in one place; this module only composes them.
15 //! The return type is [`Estimate<f64>`], not `f64`, so callers cannot
16 //! forget they're looking at a formula-derived number.
17
18 use crate::heuristics::{E1RM_METHOD_LABEL, EPLEY_DIVISOR, rir_from_rpe};
19 use crate::values::{Estimate, Load, Reps, Rpe};
20
21 /// Estimated 1RM from a single set. `None` when the set carries no signal
22 /// — warmup RPE (see `heuristics::rir_from_rpe`) or zero reps.
23 pub fn estimate_e1rm_from_set(load: Load, reps: Reps, rpe: Rpe) -> Option<Estimate<f64>> {
24 if load.get() <= 0.0 || reps.get() <= 0 {
25 return None;
26 }
27 let rir = rir_from_rpe(rpe)?;
28 let reps_to_failure = (reps.get() + rir) as f64;
29 let value = load.get() * (1.0 + reps_to_failure / EPLEY_DIVISOR);
30 Some(Estimate::new(value, E1RM_METHOD_LABEL))
31 }
32
33 /// Convenience wrapper over primitives for the DB layer, which reads
34 /// f64/i32 columns and cannot construct the newtypes without additional
35 /// error handling for stored-invalid rows. Returns the bare value; if
36 /// you need the method label, use [`estimate_e1rm_from_set`].
37 ///
38 /// Panics-free: any invariant violation from the raw inputs (negative
39 /// load, out-of-range RPE) collapses to `None`, matching the pre-newtype
40 /// behavior.
41 pub fn estimate_e1rm(load: f64, reps: i32, rpe: i32) -> Option<f64> {
42 let load = Load::new(load).ok()?;
43 let reps = Reps::new(reps).ok()?;
44 let rpe = Rpe::new(rpe).ok()?;
45 estimate_e1rm_from_set(load, reps, rpe).map(|e| e.value)
46 }
47
48 #[cfg(test)]
49 mod tests {
50 use super::*;
51
52 fn set(load: f64, reps: i32, rpe: i32) -> (Load, Reps, Rpe) {
53 (
54 Load::new(load).unwrap(),
55 Reps::new(reps).unwrap(),
56 Rpe::new(rpe).unwrap(),
57 )
58 }
59
60 #[test]
61 fn rpe_1_yields_none() {
62 assert_eq!(estimate_e1rm(100.0, 5, 1), None);
63 }
64
65 #[test]
66 fn max_effort_five_reps_matches_epley_at_zero_rir() {
67 let (l, r, e) = set(100.0, 5, 5);
68 let est = estimate_e1rm_from_set(l, r, e).unwrap();
69 assert!((est.value - 116.666_666_67).abs() < 1e-6);
70 assert_eq!(est.method, E1RM_METHOD_LABEL);
71 }
72
73 #[test]
74 fn easy_set_reads_higher_than_hard_set_at_same_load() {
75 let hard = estimate_e1rm(100.0, 5, 5).unwrap();
76 let easy = estimate_e1rm(100.0, 5, 2).unwrap();
77 assert!(easy > hard);
78 }
79
80 #[test]
81 fn rpe_3_five_reps_matches_epley_at_seven_reps_to_failure() {
82 // 5 reps @ RPE 3 -> 2 RIR -> effectively a 7-rep max at this load.
83 // 100 * (1 + 7/30) = 123.33
84 let e = estimate_e1rm(100.0, 5, 3).unwrap();
85 assert!((e - 123.333_333_33).abs() < 1e-6);
86 }
87
88 #[test]
89 fn non_positive_or_out_of_range_inputs_return_none() {
90 assert_eq!(estimate_e1rm(0.0, 5, 3), None);
91 assert_eq!(estimate_e1rm(-1.0, 5, 3), None);
92 assert_eq!(estimate_e1rm(100.0, 0, 3), None);
93 assert_eq!(estimate_e1rm(100.0, -1, 3), None);
94 assert_eq!(estimate_e1rm(100.0, 5, 0), None);
95 assert_eq!(estimate_e1rm(100.0, 5, 6), None);
96 }
97
98 #[test]
99 fn method_label_flows_through_estimate() {
100 let (l, r, e) = set(100.0, 5, 3);
101 let est = estimate_e1rm_from_set(l, r, e).unwrap();
102 assert!(est.method.contains("rir"));
103 }
104 }
105