max / ripgrow
4 files changed,
+161 insertions,
-1 deletion
| @@ -0,0 +1,100 @@ | |||
| 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) / 30) | |
| 12 | + | //! | |
| 13 | + | //! RIR mapping for ripgrow's 1-5 RPE scale: | |
| 14 | + | //! - RPE 5 = 0 RIR (a max effort or a missed rep) | |
| 15 | + | //! - RPE 4 = 1 RIR | |
| 16 | + | //! - RPE 3 = 2 RIR | |
| 17 | + | //! - RPE 2 = 3 RIR | |
| 18 | + | //! - RPE 1 = warmup, no signal about the ceiling; the estimator returns None. | |
| 19 | + | //! | |
| 20 | + | //! Swapping in the empirical RTS chart later is a private-function change. | |
| 21 | + | ||
| 22 | + | /// Reps in reserve implied by an RPE value on ripgrow's 1-5 scale. | |
| 23 | + | /// Returns `None` for values outside the domain including RPE 1 (warmup). | |
| 24 | + | pub fn rir_from_rpe(rpe: i32) -> Option<i32> { | |
| 25 | + | match rpe { | |
| 26 | + | 5 => Some(0), | |
| 27 | + | 4 => Some(1), | |
| 28 | + | 3 => Some(2), | |
| 29 | + | 2 => Some(3), | |
| 30 | + | _ => None, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Estimated 1RM from a single set. `None` when the set carries no signal | |
| 35 | + | /// (warmup RPE, zero/negative reps, or non-positive load). | |
| 36 | + | pub fn estimate_e1rm(load: f64, reps: i32, rpe: i32) -> Option<f64> { | |
| 37 | + | if load <= 0.0 || reps <= 0 { | |
| 38 | + | return None; | |
| 39 | + | } | |
| 40 | + | let rir = rir_from_rpe(rpe)?; | |
| 41 | + | let reps_to_failure = (reps + rir) as f64; | |
| 42 | + | Some(load * (1.0 + reps_to_failure / 30.0)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | #[cfg(test)] | |
| 46 | + | mod tests { | |
| 47 | + | use super::*; | |
| 48 | + | ||
| 49 | + | #[test] | |
| 50 | + | fn rir_map_covers_rpe_2_through_5() { | |
| 51 | + | assert_eq!(rir_from_rpe(5), Some(0)); | |
| 52 | + | assert_eq!(rir_from_rpe(4), Some(1)); | |
| 53 | + | assert_eq!(rir_from_rpe(3), Some(2)); | |
| 54 | + | assert_eq!(rir_from_rpe(2), Some(3)); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | #[test] | |
| 58 | + | fn rpe_1_is_warmup_and_yields_none() { | |
| 59 | + | assert_eq!(rir_from_rpe(1), None); | |
| 60 | + | assert_eq!(estimate_e1rm(100.0, 5, 1), None); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | #[test] | |
| 64 | + | fn max_effort_five_reps_matches_epley_at_zero_rir() { | |
| 65 | + | // 100 * (1 + 5/30) = 116.67 | |
| 66 | + | let e = estimate_e1rm(100.0, 5, 5).unwrap(); | |
| 67 | + | assert!((e - 116.666_666_67).abs() < 1e-6); | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | #[test] | |
| 71 | + | fn easy_set_reads_higher_than_hard_set_at_same_load() { | |
| 72 | + | // Same load, same reps: easier set implies more in the tank, so a | |
| 73 | + | // bigger 1RM ceiling. | |
| 74 | + | let hard = estimate_e1rm(100.0, 5, 5).unwrap(); | |
| 75 | + | let easy = estimate_e1rm(100.0, 5, 2).unwrap(); | |
| 76 | + | assert!(easy > hard); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | #[test] | |
| 80 | + | fn rpe_3_five_reps_matches_epley_at_seven_reps_to_failure() { | |
| 81 | + | // 5 reps @ RPE 3 -> 2 RIR -> effectively a 7-rep max at this load. | |
| 82 | + | // 100 * (1 + 7/30) = 123.33 | |
| 83 | + | let e = estimate_e1rm(100.0, 5, 3).unwrap(); | |
| 84 | + | assert!((e - 123.333_333_33).abs() < 1e-6); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | #[test] | |
| 88 | + | fn non_positive_inputs_return_none() { | |
| 89 | + | assert_eq!(estimate_e1rm(0.0, 5, 3), None); | |
| 90 | + | assert_eq!(estimate_e1rm(-1.0, 5, 3), None); | |
| 91 | + | assert_eq!(estimate_e1rm(100.0, 0, 3), None); | |
| 92 | + | assert_eq!(estimate_e1rm(100.0, -1, 3), None); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | #[test] | |
| 96 | + | fn out_of_range_rpe_returns_none() { | |
| 97 | + | assert_eq!(estimate_e1rm(100.0, 5, 0), None); | |
| 98 | + | assert_eq!(estimate_e1rm(100.0, 5, 6), None); | |
| 99 | + | } | |
| 100 | + | } |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | ||
| 6 | 6 | pub mod db; | |
| 7 | 7 | pub mod error; | |
| 8 | + | pub mod estimator; | |
| 8 | 9 | pub mod generation; | |
| 9 | 10 | pub mod profiles; | |
| 10 | 11 | pub mod progression; | |
| @@ -13,6 +14,7 @@ pub mod templates; | |||
| 13 | 14 | ||
| 14 | 15 | pub use db::{Db, Unit}; | |
| 15 | 16 | pub use error::Error; | |
| 17 | + | pub use estimator::{estimate_e1rm, rir_from_rpe}; | |
| 16 | 18 | pub use generation::{PickedSlot, Readiness}; | |
| 17 | 19 | pub use profiles::{Profile, create_profile_in, list_profiles, profiles_dir, slugify}; | |
| 18 | 20 | pub use progression::{Prescription, PrescriptionResult, SessionOutcome, State}; |
| @@ -9,6 +9,7 @@ use rusqlite::params; | |||
| 9 | 9 | ||
| 10 | 10 | use crate::db::Db; | |
| 11 | 11 | use crate::error::Error; | |
| 12 | + | use crate::estimator::estimate_e1rm; | |
| 12 | 13 | ||
| 13 | 14 | #[derive(Debug, Clone, PartialEq)] | |
| 14 | 15 | pub struct SessionSet { | |
| @@ -156,6 +157,30 @@ impl Db { | |||
| 156 | 157 | Ok(out) | |
| 157 | 158 | } | |
| 158 | 159 | ||
| 160 | + | /// Best e1RM across every set logged for `exercise_id`. `None` when | |
| 161 | + | /// the exercise has no sets, or when no set carries an e1RM signal | |
| 162 | + | /// (all sets at warmup RPE, for instance). | |
| 163 | + | pub fn best_e1rm(&self, exercise_id: i64) -> Result<Option<f64>, Error> { | |
| 164 | + | let mut stmt = self.conn().prepare( | |
| 165 | + | "SELECT load, reps, rpe FROM sets WHERE exercise_id = ?1", | |
| 166 | + | )?; | |
| 167 | + | let rows = stmt.query_map(params![exercise_id], |row| { | |
| 168 | + | Ok(( | |
| 169 | + | row.get::<_, f64>(0)?, | |
| 170 | + | row.get::<_, i32>(1)?, | |
| 171 | + | row.get::<_, i32>(2)?, | |
| 172 | + | )) | |
| 173 | + | })?; | |
| 174 | + | let mut best: Option<f64> = None; | |
| 175 | + | for row in rows { | |
| 176 | + | let (load, reps, rpe) = row?; | |
| 177 | + | if let Some(e) = estimate_e1rm(load, reps, rpe) { | |
| 178 | + | best = Some(best.map_or(e, |b| b.max(e))); | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | Ok(best) | |
| 182 | + | } | |
| 183 | + | ||
| 159 | 184 | /// Delete a specific set row by id. Returns `NotFound` if it did not | |
| 160 | 185 | /// exist. Subsequent `append_set` calls do NOT reuse the freed index; | |
| 161 | 186 | /// gaps are fine for the state machine (which reads ordered by index). | |
| @@ -243,6 +268,34 @@ mod tests { | |||
| 243 | 268 | } | |
| 244 | 269 | ||
| 245 | 270 | #[test] | |
| 271 | + | fn best_e1rm_none_when_no_sets() { | |
| 272 | + | let (db, ex) = setup(); | |
| 273 | + | assert_eq!(db.best_e1rm(ex).unwrap(), None); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 277 | + | fn best_e1rm_takes_max_across_sets() { | |
| 278 | + | let (db, ex) = setup(); | |
| 279 | + | let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); | |
| 280 | + | // 100 x 5 @ RPE 5 -> e1RM 116.67 | |
| 281 | + | // 90 x 8 @ RPE 3 -> e1RM 90 * (1 + 10/30) = 120.0 | |
| 282 | + | // 120 x 3 @ RPE 5 -> e1RM 132.0 <- winner | |
| 283 | + | db.append_set(ex, date, 100.0, 5, 5).unwrap(); | |
| 284 | + | db.append_set(ex, date, 90.0, 8, 3).unwrap(); | |
| 285 | + | db.append_set(ex, date, 120.0, 3, 5).unwrap(); | |
| 286 | + | let e = db.best_e1rm(ex).unwrap().unwrap(); | |
| 287 | + | assert!((e - 132.0).abs() < 1e-6); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn best_e1rm_ignores_warmup_rpe_1_sets() { | |
| 292 | + | let (db, ex) = setup(); | |
| 293 | + | let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); | |
| 294 | + | db.append_set(ex, date, 200.0, 10, 1).unwrap(); | |
| 295 | + | assert_eq!(db.best_e1rm(ex).unwrap(), None); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | #[test] | |
| 246 | 299 | fn delete_removes_and_gap_is_fine() { | |
| 247 | 300 | let (db, ex) = setup(); | |
| 248 | 301 | let date = NaiveDate::from_ymd_opt(2026, 7, 18).unwrap(); |
| @@ -131,10 +131,15 @@ fn render_detail(frame: &mut Frame, area: Rect, db: &Db, ex: &Exercise) { | |||
| 131 | 131 | ||
| 132 | 132 | let (header_area, spark_area, table_area) = split_detail(inner); | |
| 133 | 133 | ||
| 134 | + | let e1rm_str = match db.best_e1rm(ex.id).ok().flatten() { | |
| 135 | + | Some(v) => format!("{:.1} {}", v, ex.load_unit), | |
| 136 | + | None => "-".to_string(), | |
| 137 | + | }; | |
| 134 | 138 | let header = format!( | |
| 135 | - | "state: {} sessions: {}", | |
| 139 | + | "state: {} sessions: {} e1RM: {}", | |
| 136 | 140 | state_str, | |
| 137 | 141 | series.len(), | |
| 142 | + | e1rm_str, | |
| 138 | 143 | ); | |
| 139 | 144 | frame.render_widget(Paragraph::new(header), header_area); | |
| 140 | 145 |